tiktok-auto.js 434 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205420642074208420942104211421242134214421542164217421842194220422142224223422442254226422742284229423042314232423342344235423642374238423942404241424242434244424542464247424842494250425142524253425442554256425742584259426042614262426342644265426642674268426942704271427242734274427542764277427842794280428142824283428442854286428742884289429042914292429342944295429642974298429943004301430243034304430543064307430843094310431143124313431443154316431743184319432043214322432343244325432643274328432943304331433243334334433543364337433843394340434143424343434443454346434743484349435043514352435343544355435643574358435943604361436243634364436543664367436843694370437143724373437443754376437743784379438043814382438343844385438643874388438943904391439243934394439543964397439843994400440144024403440444054406440744084409441044114412441344144415441644174418441944204421442244234424442544264427442844294430443144324433443444354436443744384439444044414442444344444445444644474448444944504451445244534454445544564457445844594460446144624463446444654466446744684469447044714472447344744475447644774478447944804481448244834484448544864487448844894490449144924493449444954496449744984499450045014502450345044505450645074508450945104511451245134514451545164517451845194520452145224523452445254526452745284529453045314532453345344535453645374538453945404541454245434544454545464547454845494550455145524553455445554556455745584559456045614562456345644565456645674568456945704571457245734574457545764577457845794580458145824583458445854586458745884589459045914592459345944595459645974598459946004601460246034604460546064607460846094610461146124613461446154616461746184619462046214622462346244625462646274628462946304631463246334634463546364637463846394640464146424643464446454646464746484649465046514652465346544655465646574658465946604661466246634664466546664667466846694670467146724673467446754676467746784679468046814682468346844685468646874688468946904691469246934694469546964697469846994700470147024703470447054706470747084709471047114712471347144715471647174718471947204721472247234724472547264727472847294730473147324733473447354736473747384739474047414742474347444745474647474748474947504751475247534754475547564757475847594760476147624763476447654766476747684769477047714772477347744775477647774778477947804781478247834784478547864787478847894790479147924793479447954796479747984799480048014802480348044805480648074808480948104811481248134814481548164817481848194820482148224823482448254826482748284829483048314832483348344835483648374838483948404841484248434844484548464847484848494850485148524853485448554856485748584859486048614862486348644865486648674868486948704871487248734874487548764877487848794880488148824883488448854886488748884889489048914892489348944895489648974898489949004901490249034904490549064907490849094910491149124913491449154916491749184919492049214922492349244925492649274928492949304931493249334934493549364937493849394940494149424943494449454946494749484949495049514952495349544955495649574958495949604961496249634964496549664967496849694970497149724973497449754976497749784979498049814982498349844985498649874988498949904991499249934994499549964997499849995000500150025003500450055006500750085009501050115012501350145015501650175018501950205021502250235024502550265027502850295030503150325033503450355036503750385039504050415042504350445045504650475048504950505051505250535054505550565057505850595060506150625063506450655066506750685069507050715072507350745075507650775078507950805081508250835084508550865087508850895090509150925093509450955096509750985099510051015102510351045105510651075108510951105111511251135114511551165117511851195120512151225123512451255126512751285129513051315132513351345135513651375138513951405141514251435144514551465147514851495150515151525153515451555156515751585159516051615162516351645165516651675168516951705171517251735174517551765177517851795180518151825183518451855186518751885189519051915192519351945195519651975198519952005201520252035204520552065207520852095210521152125213521452155216521752185219522052215222522352245225522652275228522952305231523252335234523552365237523852395240524152425243524452455246524752485249525052515252525352545255525652575258525952605261526252635264526552665267526852695270527152725273527452755276527752785279528052815282528352845285528652875288528952905291529252935294529552965297529852995300530153025303530453055306530753085309531053115312531353145315531653175318531953205321532253235324532553265327532853295330533153325333533453355336533753385339534053415342534353445345534653475348534953505351535253535354535553565357535853595360536153625363536453655366536753685369537053715372537353745375537653775378537953805381538253835384538553865387538853895390539153925393539453955396539753985399540054015402540354045405540654075408540954105411541254135414541554165417541854195420542154225423542454255426542754285429543054315432543354345435543654375438543954405441544254435444544554465447544854495450545154525453545454555456545754585459546054615462546354645465546654675468546954705471547254735474547554765477547854795480548154825483548454855486548754885489549054915492549354945495549654975498549955005501550255035504550555065507550855095510551155125513551455155516551755185519552055215522552355245525552655275528552955305531553255335534553555365537553855395540554155425543554455455546554755485549555055515552555355545555555655575558555955605561556255635564556555665567556855695570557155725573557455755576557755785579558055815582558355845585558655875588558955905591559255935594559555965597559855995600560156025603560456055606560756085609561056115612561356145615561656175618561956205621562256235624562556265627562856295630563156325633563456355636563756385639564056415642564356445645564656475648564956505651565256535654565556565657565856595660566156625663566456655666566756685669567056715672567356745675567656775678567956805681568256835684568556865687568856895690569156925693569456955696569756985699570057015702570357045705570657075708570957105711571257135714571557165717571857195720572157225723572457255726572757285729573057315732573357345735573657375738573957405741574257435744574557465747574857495750575157525753575457555756575757585759576057615762576357645765576657675768576957705771577257735774577557765777577857795780578157825783578457855786578757885789579057915792579357945795579657975798579958005801580258035804580558065807580858095810581158125813581458155816581758185819582058215822582358245825582658275828582958305831583258335834583558365837583858395840584158425843584458455846584758485849585058515852585358545855585658575858585958605861586258635864586558665867586858695870587158725873587458755876587758785879588058815882588358845885588658875888588958905891589258935894589558965897589858995900590159025903590459055906590759085909591059115912591359145915591659175918591959205921592259235924592559265927592859295930593159325933593459355936593759385939594059415942594359445945594659475948594959505951595259535954595559565957595859595960596159625963596459655966596759685969597059715972597359745975597659775978597959805981598259835984598559865987598859895990599159925993599459955996599759985999600060016002600360046005600660076008600960106011601260136014601560166017601860196020602160226023602460256026602760286029603060316032603360346035603660376038603960406041604260436044604560466047604860496050605160526053605460556056605760586059606060616062606360646065606660676068606960706071607260736074607560766077607860796080608160826083608460856086608760886089609060916092609360946095609660976098609961006101610261036104610561066107610861096110611161126113611461156116611761186119612061216122612361246125612661276128612961306131613261336134613561366137613861396140614161426143614461456146614761486149615061516152615361546155615661576158615961606161616261636164616561666167616861696170617161726173617461756176617761786179618061816182618361846185618661876188618961906191619261936194619561966197619861996200620162026203620462056206620762086209621062116212621362146215621662176218621962206221622262236224622562266227622862296230623162326233623462356236623762386239624062416242624362446245624662476248624962506251625262536254625562566257625862596260626162626263626462656266626762686269627062716272627362746275627662776278627962806281628262836284628562866287628862896290629162926293629462956296629762986299630063016302630363046305630663076308630963106311631263136314631563166317631863196320632163226323632463256326632763286329633063316332633363346335633663376338633963406341634263436344634563466347634863496350635163526353635463556356635763586359636063616362636363646365636663676368636963706371637263736374637563766377637863796380638163826383638463856386638763886389639063916392639363946395639663976398639964006401640264036404640564066407640864096410641164126413641464156416641764186419642064216422642364246425642664276428642964306431643264336434643564366437643864396440644164426443644464456446644764486449645064516452645364546455645664576458645964606461646264636464646564666467646864696470647164726473647464756476647764786479648064816482648364846485648664876488648964906491649264936494649564966497649864996500650165026503650465056506650765086509651065116512651365146515651665176518651965206521652265236524652565266527652865296530653165326533653465356536653765386539654065416542654365446545654665476548654965506551655265536554655565566557655865596560656165626563656465656566656765686569657065716572657365746575657665776578657965806581658265836584658565866587658865896590659165926593659465956596659765986599660066016602660366046605660666076608660966106611661266136614661566166617661866196620662166226623662466256626662766286629663066316632663366346635663666376638663966406641664266436644664566466647664866496650665166526653665466556656665766586659666066616662666366646665666666676668666966706671667266736674667566766677667866796680668166826683668466856686668766886689669066916692669366946695669666976698669967006701670267036704670567066707670867096710671167126713671467156716671767186719672067216722672367246725672667276728672967306731673267336734673567366737673867396740674167426743674467456746674767486749675067516752675367546755675667576758675967606761676267636764676567666767676867696770677167726773677467756776677767786779678067816782678367846785678667876788678967906791679267936794679567966797679867996800680168026803680468056806680768086809681068116812681368146815681668176818681968206821682268236824682568266827682868296830683168326833683468356836683768386839684068416842684368446845684668476848684968506851685268536854685568566857685868596860686168626863686468656866686768686869687068716872687368746875687668776878687968806881688268836884688568866887688868896890689168926893689468956896689768986899690069016902690369046905690669076908690969106911691269136914691569166917691869196920692169226923692469256926692769286929693069316932693369346935693669376938693969406941694269436944694569466947694869496950695169526953695469556956695769586959696069616962696369646965696669676968696969706971697269736974697569766977697869796980698169826983698469856986698769886989699069916992699369946995699669976998699970007001700270037004700570067007700870097010701170127013701470157016701770187019702070217022702370247025702670277028702970307031703270337034703570367037703870397040704170427043704470457046704770487049705070517052705370547055705670577058705970607061706270637064706570667067706870697070707170727073707470757076707770787079708070817082708370847085708670877088708970907091709270937094709570967097709870997100710171027103710471057106710771087109711071117112711371147115711671177118711971207121712271237124712571267127712871297130713171327133713471357136713771387139714071417142714371447145714671477148714971507151715271537154715571567157715871597160716171627163716471657166716771687169717071717172717371747175717671777178717971807181718271837184718571867187718871897190719171927193719471957196719771987199720072017202720372047205720672077208720972107211721272137214721572167217721872197220722172227223722472257226722772287229723072317232723372347235723672377238723972407241724272437244724572467247724872497250725172527253725472557256725772587259726072617262726372647265726672677268726972707271727272737274727572767277727872797280728172827283728472857286728772887289729072917292729372947295729672977298729973007301730273037304730573067307730873097310731173127313731473157316731773187319732073217322732373247325732673277328732973307331733273337334733573367337733873397340734173427343734473457346734773487349735073517352735373547355735673577358735973607361736273637364736573667367736873697370737173727373737473757376737773787379738073817382738373847385738673877388738973907391739273937394739573967397739873997400740174027403740474057406740774087409741074117412741374147415741674177418741974207421742274237424742574267427742874297430743174327433743474357436743774387439744074417442744374447445744674477448744974507451745274537454745574567457745874597460746174627463746474657466746774687469747074717472747374747475747674777478747974807481748274837484748574867487748874897490749174927493749474957496749774987499750075017502750375047505750675077508750975107511751275137514751575167517751875197520752175227523752475257526752775287529753075317532753375347535753675377538753975407541754275437544754575467547754875497550755175527553755475557556755775587559756075617562756375647565756675677568756975707571757275737574757575767577757875797580758175827583758475857586758775887589759075917592759375947595759675977598759976007601760276037604760576067607760876097610761176127613761476157616761776187619762076217622762376247625762676277628762976307631763276337634763576367637763876397640764176427643764476457646764776487649765076517652765376547655765676577658765976607661766276637664766576667667766876697670767176727673767476757676767776787679768076817682768376847685768676877688768976907691769276937694769576967697769876997700770177027703770477057706770777087709771077117712771377147715771677177718771977207721772277237724772577267727772877297730773177327733773477357736773777387739774077417742774377447745774677477748774977507751775277537754775577567757775877597760776177627763776477657766776777687769777077717772777377747775777677777778777977807781778277837784778577867787778877897790779177927793779477957796779777987799780078017802780378047805780678077808780978107811781278137814781578167817781878197820782178227823782478257826782778287829783078317832783378347835783678377838783978407841784278437844784578467847784878497850785178527853785478557856785778587859786078617862786378647865786678677868786978707871787278737874787578767877787878797880788178827883788478857886788778887889789078917892789378947895789678977898789979007901790279037904790579067907790879097910791179127913791479157916791779187919792079217922792379247925792679277928792979307931793279337934793579367937793879397940794179427943794479457946794779487949795079517952795379547955795679577958795979607961796279637964796579667967796879697970797179727973797479757976797779787979798079817982798379847985798679877988798979907991799279937994799579967997799879998000800180028003800480058006800780088009801080118012801380148015801680178018801980208021802280238024802580268027802880298030803180328033803480358036803780388039804080418042804380448045804680478048804980508051805280538054805580568057805880598060806180628063806480658066806780688069807080718072807380748075807680778078807980808081808280838084808580868087808880898090809180928093809480958096809780988099810081018102810381048105810681078108810981108111811281138114811581168117811881198120812181228123812481258126812781288129813081318132813381348135813681378138813981408141814281438144814581468147814881498150815181528153815481558156815781588159816081618162816381648165816681678168816981708171817281738174817581768177817881798180818181828183818481858186818781888189819081918192819381948195819681978198819982008201820282038204820582068207820882098210821182128213821482158216821782188219822082218222822382248225822682278228822982308231823282338234823582368237823882398240824182428243824482458246824782488249825082518252825382548255825682578258825982608261826282638264826582668267826882698270827182728273827482758276827782788279828082818282828382848285828682878288828982908291829282938294829582968297829882998300830183028303830483058306830783088309831083118312831383148315831683178318831983208321832283238324832583268327832883298330833183328333833483358336833783388339834083418342834383448345834683478348834983508351835283538354835583568357835883598360836183628363836483658366836783688369837083718372837383748375837683778378837983808381838283838384838583868387838883898390839183928393839483958396839783988399840084018402840384048405840684078408840984108411841284138414841584168417841884198420842184228423842484258426842784288429843084318432843384348435843684378438843984408441844284438444844584468447844884498450845184528453845484558456845784588459846084618462846384648465846684678468846984708471847284738474847584768477847884798480848184828483848484858486848784888489849084918492849384948495849684978498849985008501850285038504850585068507850885098510851185128513851485158516851785188519852085218522852385248525852685278528852985308531853285338534853585368537853885398540854185428543854485458546854785488549855085518552855385548555855685578558855985608561856285638564856585668567856885698570857185728573857485758576857785788579858085818582858385848585858685878588858985908591859285938594859585968597859885998600860186028603860486058606860786088609861086118612861386148615861686178618861986208621862286238624862586268627862886298630863186328633863486358636863786388639864086418642864386448645864686478648864986508651865286538654865586568657865886598660866186628663866486658666866786688669867086718672867386748675867686778678867986808681868286838684868586868687868886898690869186928693869486958696869786988699870087018702870387048705870687078708870987108711871287138714871587168717871887198720872187228723872487258726872787288729873087318732873387348735873687378738873987408741874287438744874587468747874887498750875187528753875487558756875787588759876087618762876387648765876687678768876987708771877287738774877587768777877887798780878187828783878487858786878787888789879087918792879387948795879687978798879988008801880288038804880588068807880888098810881188128813881488158816881788188819882088218822882388248825882688278828882988308831883288338834883588368837883888398840884188428843884488458846884788488849885088518852885388548855885688578858885988608861886288638864886588668867886888698870887188728873887488758876887788788879888088818882888388848885888688878888888988908891889288938894889588968897889888998900890189028903890489058906890789088909891089118912891389148915891689178918891989208921892289238924892589268927892889298930893189328933893489358936893789388939894089418942894389448945894689478948894989508951895289538954895589568957895889598960896189628963896489658966896789688969897089718972897389748975897689778978897989808981898289838984898589868987898889898990899189928993899489958996899789988999900090019002900390049005900690079008900990109011901290139014901590169017901890199020902190229023902490259026902790289029903090319032903390349035903690379038903990409041904290439044904590469047904890499050905190529053905490559056905790589059906090619062906390649065906690679068906990709071907290739074907590769077907890799080908190829083908490859086908790889089909090919092909390949095909690979098909991009101910291039104910591069107910891099110911191129113911491159116911791189119912091219122912391249125912691279128912991309131913291339134913591369137913891399140914191429143914491459146914791489149915091519152915391549155915691579158915991609161916291639164916591669167916891699170917191729173917491759176917791789179918091819182918391849185918691879188918991909191919291939194919591969197919891999200920192029203920492059206920792089209921092119212921392149215921692179218921992209221922292239224922592269227922892299230923192329233923492359236923792389239924092419242924392449245924692479248924992509251925292539254925592569257925892599260926192629263926492659266926792689269927092719272927392749275927692779278927992809281928292839284928592869287928892899290929192929293929492959296929792989299930093019302930393049305930693079308930993109311931293139314931593169317931893199320932193229323932493259326932793289329933093319332933393349335933693379338933993409341934293439344934593469347934893499350935193529353935493559356935793589359936093619362936393649365936693679368936993709371937293739374937593769377937893799380938193829383938493859386938793889389939093919392939393949395939693979398939994009401940294039404940594069407940894099410941194129413941494159416941794189419942094219422942394249425942694279428942994309431943294339434943594369437943894399440944194429443944494459446944794489449945094519452945394549455945694579458945994609461946294639464946594669467946894699470947194729473947494759476947794789479948094819482948394849485948694879488948994909491949294939494949594969497949894999500950195029503950495059506950795089509951095119512951395149515951695179518951995209521952295239524952595269527952895299530953195329533953495359536953795389539954095419542954395449545954695479548954995509551955295539554955595569557955895599560956195629563956495659566956795689569957095719572957395749575957695779578957995809581958295839584958595869587958895899590959195929593959495959596959795989599960096019602960396049605960696079608960996109611961296139614961596169617961896199620962196229623962496259626962796289629963096319632963396349635963696379638963996409641964296439644964596469647964896499650965196529653965496559656965796589659966096619662966396649665966696679668966996709671967296739674967596769677967896799680968196829683968496859686968796889689969096919692969396949695969696979698969997009701970297039704970597069707970897099710971197129713971497159716971797189719972097219722972397249725972697279728972997309731973297339734973597369737973897399740974197429743974497459746974797489749975097519752975397549755975697579758975997609761976297639764976597669767976897699770977197729773977497759776977797789779978097819782978397849785978697879788978997909791979297939794979597969797979897999800980198029803980498059806980798089809981098119812981398149815981698179818981998209821982298239824982598269827982898299830983198329833983498359836983798389839984098419842984398449845984698479848984998509851985298539854985598569857985898599860986198629863986498659866986798689869987098719872987398749875987698779878987998809881988298839884988598869887988898899890989198929893989498959896989798989899990099019902990399049905990699079908990999109911991299139914991599169917991899199920992199229923992499259926992799289929993099319932993399349935993699379938993999409941994299439944994599469947994899499950995199529953995499559956995799589959996099619962996399649965996699679968996999709971997299739974997599769977997899799980998199829983998499859986998799889989999099919992999399949995999699979998999910000100011000210003100041000510006100071000810009100101001110012100131001410015100161001710018100191002010021100221002310024100251002610027100281002910030100311003210033100341003510036100371003810039100401004110042100431004410045100461004710048100491005010051100521005310054100551005610057100581005910060100611006210063100641006510066100671006810069100701007110072100731007410075100761007710078100791008010081100821008310084100851008610087100881008910090100911009210093100941009510096100971009810099101001010110102101031010410105101061010710108101091011010111101121011310114101151011610117101181011910120101211012210123101241012510126101271012810129101301013110132101331013410135101361013710138101391014010141101421014310144101451014610147101481014910150101511015210153101541015510156101571015810159101601016110162101631016410165101661016710168101691017010171101721017310174101751017610177101781017910180101811018210183101841018510186101871018810189101901019110192101931019410195101961019710198101991020010201102021020310204102051020610207102081020910210102111021210213102141021510216102171021810219102201022110222102231022410225102261022710228102291023010231102321023310234102351023610237102381023910240102411024210243102441024510246102471024810249102501025110252102531025410255102561025710258102591026010261102621026310264102651026610267102681026910270102711027210273102741027510276102771027810279102801028110282102831028410285102861028710288102891029010291102921029310294102951029610297102981029910300103011030210303103041030510306103071030810309103101031110312103131031410315103161031710318103191032010321103221032310324103251032610327103281032910330103311033210333103341033510336103371033810339103401034110342103431034410345103461034710348103491035010351103521035310354103551035610357103581035910360103611036210363103641036510366103671036810369103701037110372103731037410375103761037710378103791038010381103821038310384103851038610387103881038910390103911039210393103941039510396103971039810399104001040110402104031040410405104061040710408104091041010411104121041310414104151041610417104181041910420104211042210423104241042510426104271042810429104301043110432104331043410435104361043710438104391044010441104421044310444104451044610447104481044910450104511045210453104541045510456104571045810459104601046110462104631046410465104661046710468104691047010471104721047310474104751047610477104781047910480104811048210483104841048510486104871048810489104901049110492104931049410495104961049710498104991050010501105021050310504105051050610507105081050910510105111051210513105141051510516105171051810519105201052110522105231052410525105261052710528105291053010531105321053310534105351053610537105381053910540105411054210543105441054510546105471054810549105501055110552105531055410555105561055710558105591056010561105621056310564105651056610567105681056910570105711057210573105741057510576105771057810579105801058110582105831058410585105861058710588105891059010591105921059310594105951059610597105981059910600
  1. /*! tiktok-auto v1.0.0
  2. * built 2026-11-01T07:56:06.930Z
  3. * modules: logger, protocol, bridge, config, wait, layout, ui, popups, tiktok, player, comment, interact, flow, nurture, parse, uidset, fingerprint, checkpoint, filters, profile, idsource, resolve, idflow, dm, dmflow, main
  4. * 由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。
  5. */
  6. (function () {
  7. var TikTokAuto = {};
  8. /* ============================== src/logger.js ============================== */
  9. /**
  10. * logger.js — 结构化日志
  11. *
  12. * 为什么单独成模块:设备脚本出错时,唯一能依赖的就是回传的日志。
  13. * 把日志格式固定下来(`[TTA][步骤名] 内容`),PC 侧就能按前缀过滤、按步骤计时,
  14. * 而不是靠人肉读一堆散乱的 console.log。
  15. *
  16. * 2026-10-13 增加**文件落盘通道**(sink):界面侧的「运行记录 → 详情的执行日志」
  17. * 要看的就是这些行。之所以不做成"界面去抓 console",是因为
  18. * console 是另一个进程的 stdout,界面根本读不到(见 bridge.js 的通道说明)。
  19. * sink 由 bridge 在启用宿主模式时装上,**独立运行时为空 → 零开销**。
  20. *
  21. * 依赖:无。始终通过 TikTokAuto.logger 取用,因此可以被放在 concat 顺序的最前面。
  22. */
  23. TikTokAuto.logger = (function () {
  24. 'use strict';
  25. var PREFIX = '[TTA]';
  26. var startedAt = Date.now();
  27. /** 文件落盘回调,由 bridge 注入:function(level, step, message) */
  28. var sink = null;
  29. function stamp() {
  30. return ('+' + (Date.now() - startedAt) + 'ms');
  31. }
  32. function write(level, step, message) {
  33. var line = PREFIX + stamp() + '[' + level + '][' + step + '] ' + message;
  34. console.log(line);
  35. if (sink) {
  36. /* 落盘失败绝不能影响流程:日志是"顺带记录的",不是"必须成功的"。 */
  37. try {
  38. sink(level, step, message);
  39. } catch (ignored) {
  40. /* 刻意吞掉:sink 抛错会把一次正常的日志调用变成流程中断 */
  41. }
  42. }
  43. }
  44. return {
  45. /** 装上文件落盘通道(传 null 卸载) */
  46. setSink: function (fn) {
  47. sink = typeof fn === 'function' ? fn : null;
  48. },
  49. /** 记录一次进入某步骤 */
  50. step: function (name, message) {
  51. write('STEP', name, message === undefined ? '' : message);
  52. },
  53. info: function (step, message) {
  54. write('INFO', step, message);
  55. },
  56. /** 可恢复的问题:值得记下来,但不中断流程 */
  57. warn: function (step, message) {
  58. write('WARN', step, message);
  59. },
  60. error: function (step, message) {
  61. write('ERROR', step, message);
  62. },
  63. /** 机器可读的结构化结果,PC 侧用它做断言 */
  64. result: function (payload) {
  65. console.log(PREFIX + 'RESULT ' + JSON.stringify(payload));
  66. },
  67. /** 从脚本开始到现在的耗时,用于定位"哪一步慢" */
  68. elapsedMs: function () {
  69. return Date.now() - startedAt;
  70. }
  71. };
  72. }());
  73. /* ============================== src/protocol.js ============================== */
  74. /* ===========================================================================
  75. * protocol.js —— 界面 ↔ 引擎 的运行期协议(唯一事实来源)
  76. *
  77. * 这个文件解决的是"两边各写一套枚举、靠魔法数字猜死活"的老问题。
  78. * 它只放**纯数据 + 纯判定**:不读文件、不写文件、不起线程、不看时间。
  79. * 所以界面侧与引擎侧可以各存一份完整拷贝,行为完全一致。
  80. *
  81. * ★ 两份拷贝的纪律:`tiktok-app/src/protocol.js` 与
  82. * `tiktok-auto/src/protocol.js` **除第 1 行(挂载点)外必须逐字节相同**。
  83. * 界面侧是 `var Protocol = …`,引擎侧是 `TikTokAuto.protocol = …`。
  84. * `tiktok-app/tools/protocol-check.js` 会剥掉第 1 行后逐字节比对,
  85. * 任何一边单独改都会立刻被抓出来。
  86. *
  87. * ★ 为什么状态判定要放在这里而不是各自实现:
  88. * `isTerminalStatus` 曾经只存在于界面(engine.js),引擎侧自己写
  89. * `'finished' / 'stopped' / 'failed'` 三元推导(main.js)。两边漏改一个
  90. * 就会出现"引擎以为结束了、界面还在等"这种零报错的悬挂。
  91. * =========================================================================== */
  92. TikTokAuto.protocol = (function () {
  93. 'use strict';
  94. /** 协议版本。加字段不改语义不用抬;改语义 / 改判定规则必须抬。 */
  95. var VERSION = 2;
  96. /* ------------------------------------------------------------ 状态枚举 */
  97. var STATUS = {
  98. STARTING: 'starting',
  99. RUNNING: 'running',
  100. STOPPING: 'stopping',
  101. FINISHED: 'finished',
  102. STOPPED: 'stopped',
  103. FAILED: 'failed'
  104. };
  105. /** 终态:到了这里就没有出边(再回到活跃态一律算 regress)。 */
  106. var TERMINAL = { finished: true, stopped: true, failed: true };
  107. /** 活跃态:运行条要显示、判活逻辑要关心的三档。 */
  108. var ACTIVE = { starting: true, running: true, stopping: true };
  109. /**
  110. * 合法迁移表:from → 允许去的 to。
  111. *
  112. * 空串代表"还没有状态"(本轮的第一次上报)。
  113. * `starting` 的出边特别宽:配置无效会在启动瞬间直接 failed,
  114. * 极短任务可能 starting 之后直接终态,这两种都是真实的。
  115. */
  116. var EDGES = {
  117. '': { starting: true },
  118. starting: {
  119. starting: true,
  120. running: true,
  121. stopping: true,
  122. finished: true,
  123. stopped: true,
  124. failed: true
  125. },
  126. running: {
  127. running: true,
  128. stopping: true,
  129. finished: true,
  130. stopped: true,
  131. failed: true
  132. },
  133. stopping: {
  134. stopping: true,
  135. finished: true,
  136. stopped: true,
  137. failed: true
  138. },
  139. finished: {},
  140. stopped: {},
  141. failed: {}
  142. };
  143. /* ------------------------------------------------------------ 阈值 */
  144. /**
  145. * 所有超时都在这里,界面侧从 `Protocol.SPEC.thresholds` 取,
  146. * 引擎侧心跳间隔也从这里取 —— 不允许在业务代码里再写字面量。
  147. *
  148. * 语义补充(光看名字看不出来的):
  149. * · beatFreshMs 只用于"加速判死"(见界面 pollEngine),**不产生文案**
  150. * · startAckSoftMs 只是日志与文案降级(「启动中(较慢)」),不改状态
  151. * · stopAckMs / stopFinishMs 是两段:先等"看见了停止请求",再等"真的收完尾"
  152. * · legacy* 是无心跳(v1)引擎的回退值,就是改造前在用的数字
  153. */
  154. var THRESHOLDS = {
  155. /* 心跳 */
  156. beatIntervalMs: 2000,
  157. beatFreshMs: 8000,
  158. beatDeadMs: 15000,
  159. /* 启动握手 */
  160. startAckSoftMs: 6000,
  161. startAckMaxMs: 15000,
  162. /* 停止两段 */
  163. stopAckMs: 3000,
  164. stopFinishMs: 10000,
  165. /* v1 回退组(老引擎 / 心跳线程起不来) */
  166. legacyChildGraceMs: 4000,
  167. legacyStopTimeoutMs: 8000,
  168. legacyNoBeatDeadMs: 30000
  169. };
  170. var SPEC = {
  171. version: VERSION,
  172. status: STATUS,
  173. terminal: TERMINAL,
  174. active: ACTIVE,
  175. edges: EDGES,
  176. thresholds: THRESHOLDS
  177. };
  178. /* ------------------------------------------------------------ 工具 */
  179. /* 单文件拼接后所有模块共享一个作用域,而 `Object.prototype.hasOwnProperty`
  180. * 的 call 形式会命中引擎侧构建黑名单里的那种写法(连注释都会被扫到),
  181. * 所以这里统一走 apply。 */
  182. function owns(object, key) {
  183. return Object.prototype.hasOwnProperty.apply(object, [key]) === true;
  184. }
  185. /** 把任意输入变成字符串状态;null / undefined → 空串(= 还没有状态)。 */
  186. function statusOf(value) {
  187. if (value === undefined || value === null) {
  188. return '';
  189. }
  190. return String(value);
  191. }
  192. /* ------------------------------------------------------------ 判定 */
  193. /**
  194. * 是否终态。
  195. * ★ 未知状态一律返回 false —— 界面绝不能因为读到一个不认识的 status
  196. * 就把正在跑的运行结束掉(前向兼容第一条)。
  197. */
  198. function isTerminal(status) {
  199. var key = statusOf(status);
  200. return owns(TERMINAL, key) === true && TERMINAL[key] === true;
  201. }
  202. /** 是否活跃态(运行条要显示的那三档)。未知状态同样返回 false。 */
  203. function isActive(status) {
  204. var key = statusOf(status);
  205. return owns(ACTIVE, key) === true && ACTIVE[key] === true;
  206. }
  207. /**
  208. * 三分类,给日志和排障用。
  209. * @returns 'active' | 'terminal' | 'unknown'
  210. */
  211. function levelOf(status) {
  212. var key = statusOf(status);
  213. if (isTerminal(key)) {
  214. return 'terminal';
  215. }
  216. if (isActive(key)) {
  217. return 'active';
  218. }
  219. return 'unknown';
  220. }
  221. /**
  222. * 迁移合法性。
  223. *
  224. * @returns 'ok' 表里有的合法迁移
  225. * 'idempotent' 原地重复上报(引擎允许重复写同一个状态)
  226. * 'regress' 从终态往外走 —— **必须拒绝**
  227. * 'unknown' 表里没有的组合,多半是前向兼容来的新状态,
  228. * 调用方应当"放宽"(不据此结束运行),
  229. * 只在日志里留一句
  230. */
  231. function legality(from, to) {
  232. var a = statusOf(from);
  233. var b = statusOf(to);
  234. if (a === b) {
  235. return 'idempotent';
  236. }
  237. if (isTerminal(a)) {
  238. return 'regress';
  239. }
  240. if (owns(EDGES, a) === true && owns(EDGES[a], b) === true) {
  241. return 'ok';
  242. }
  243. return 'unknown';
  244. }
  245. /* ------------------------------------------------------------ 导出 */
  246. return {
  247. VERSION: VERSION,
  248. STATUS: STATUS,
  249. SPEC: SPEC,
  250. statusOf: statusOf,
  251. isTerminal: isTerminal,
  252. isActive: isActive,
  253. levelOf: levelOf,
  254. legality: legality
  255. };
  256. })();
  257. /* ============================== src/bridge.js ============================== */
  258. /**
  259. * bridge.js — 与「界面宿主」之间的桥
  260. *
  261. * 为什么需要它:这个脚本有两种运行方式——
  262. * ① 独立运行(PC 侧下发、或直接打开产物):自己读配置,跑完打印 `__END__`;
  263. * ② 被界面宿主唤起(`engines.execScriptFile`,见 tiktok-app):
  264. * 配置由宿主生成,宿主还要能看见**进度**、能**叫停**。
  265. *
  266. * 两种方式共用同一套流程,差别只在「上下文从哪来、状态往哪去」,所以抽成这个模块:
  267. * 流程代码只调 `bridge.report()` / `bridge.checkStop()`,不必知道自己在被谁驱动。
  268. * **未启用时两者都是空操作**,独立运行零开销、零行为变化。
  269. *
  270. * 通道全部走**文件**,不走共享内存 —— 宿主与子引擎是两个独立的 ScriptEngine,
  271. * 脚本级全局变量互不可见(实测:父脚本读不到子引擎里的任何对象)。
  272. * 文件是这里唯一可靠的通道。
  273. *
  274. * 五个约定文件(默认都在 /sdcard/tiktok_app/ 下):
  275. * run-config.json 宿主 → 引擎:界面收集的完整配置快照(人可读,非本模块读取)
  276. * engine-config.json 宿主 → 引擎:映射后的引擎配置(由 --config 交给 config.resolve)
  277. * run-state.json 引擎 → 宿主:进度与终态(本模块主流程写)
  278. * engine-beat.json 引擎 → 宿主:心跳(本模块的**心跳线程**写,每 2 秒整份重写)
  279. * stop.flag 宿主 → 引擎:停止请求(本模块只读,主流程与心跳线程都读)
  280. *
  281. * ★ run-state 与 engine-beat 必须分开:前者按条目写(可能几十秒一次),
  282. * 后者每 2 秒一次。两个写者写同一个文件必然互相覆盖,而且频率差一个数量级。
  283. *
  284. * ★ stop.flag 里放的是**本次运行的 runId**,不是空文件、也不是布尔。
  285. * 好处一:不必删除它(`files.remove` 命中 MCP 危险调用黑名单;删除本身也有竞态)。
  286. * 好处二:上一轮残留的标志不会误杀本轮 —— runId 不同,自然不匹配。
  287. *
  288. * 依赖:TikTokAuto.logger(调用时取,避免 concat 顺序耦合)
  289. */
  290. TikTokAuto.bridge = (function () {
  291. 'use strict';
  292. var DEFAULT_DIR = '/sdcard/tiktok_app';
  293. var DEFAULT_STATE = DEFAULT_DIR + '/run-state.json';
  294. var DEFAULT_STOP_FLAG = DEFAULT_DIR + '/stop.flag';
  295. var DEFAULT_LOG = DEFAULT_DIR + '/run-log.jsonl';
  296. /* 心跳:独立文件、独立写者。和 run-state 分开是因为后者由主流程按条目写,
  297. * 两个写者写同一个文件必然互相覆盖,而且频率差一个数量级。 */
  298. var DEFAULT_BEAT = DEFAULT_DIR + '/engine-beat.json';
  299. /**
  300. * 检查停止标志的最小间隔。
  301. * 每次 tick(默认 150ms)都去 stat 一次文件是没必要的高频 IO;
  302. * 500ms 的响应延迟对人来说与"立刻"无异。
  303. */
  304. var STOP_POLL_MIN_MS = 500;
  305. /** 心跳间隔的兜底值:protocol 模块没加载时也能跑(此时 v=0,宿主会走 v1 回退)。 */
  306. var BEAT_INTERVAL_FALLBACK_MS = 2000;
  307. var state = {
  308. enabled: false,
  309. runId: '',
  310. statePath: DEFAULT_STATE,
  311. stopFlagPath: DEFAULT_STOP_FLAG,
  312. logPath: DEFAULT_LOG,
  313. beatPath: DEFAULT_BEAT,
  314. lastStopCheckMs: 0,
  315. /* 本轮开始的时刻:宿主拿它算"启动握手超时",与 run-state.updatedAt 无关 */
  316. startedAt: 0,
  317. /* run-state 的写入序号,单调递增。宿主据此判断"同一毫秒内的两次写" */
  318. seq: 0
  319. };
  320. var last = null;
  321. /**
  322. * 心跳线程的运行态。
  323. *
  324. * ★ 三条硬规则(实现时踩过,写在这里防止被"优化"掉):
  325. * ① **不得调用 checkStop()** —— 它会在命中时 report(stopping) 并抛 StopSignal,
  326. * 抛进心跳线程就让"收尾那 10 秒"再也没有人写心跳,而那正是最需要它的时候。
  327. * ② **不得调用 report()** —— report 的 `last` 是跨线程共享的可变对象、整份覆盖写,
  328. * 心跳借道会把 phase 写回旧值。
  329. * ③ **用裸 sleep,不用 wait.tick()** —— 理由同 ①。
  330. * `wait.js` 头注释说"裸 sleep 只允许出现在本文件",这里是受控例外。
  331. */
  332. var beat = {
  333. thread: null,
  334. running: false,
  335. seq: 0,
  336. /* 看见停止请求的时刻。★ 一旦写入就**不再归零**(sticky):
  337. * 心跳是整份覆盖写,如果每次都从 0 开始,2 秒后就把 ack 抹掉了,
  338. * 而宿主是 1 Hz 轮询 —— 有概率整整错过一拍。 */
  339. seenAt: 0
  340. };
  341. /**
  342. * 「用户要求停止」。**刻意用异常抛出**:
  343. * 停止请求可能发生在任意深度的等待里(等页面就绪、等播放开始、等结果卡片……),
  344. * 逐层返回码需要改动几十处调用点且极容易漏掉一处就变成"点了停止但不停"。
  345. * 抛出后由 main 统一兜住,语义明确(名字里带 Signal,不是 Error 那种"出错了")。
  346. */
  347. function StopSignal() {
  348. this.name = 'StopSignal';
  349. this.message = '收到停止请求';
  350. this.stack = new Error(this.message).stack;
  351. }
  352. StopSignal.prototype = new Error();
  353. StopSignal.prototype.constructor = StopSignal;
  354. /**
  355. * 判断自有属性。
  356. * 刻意写成 `.apply(...)` 而不是「属性名 + 点 + call」的形式——见 config.js 里的说明:
  357. * MCP 桥的危险调用黑名单会按词边界匹配「call + 左括号」,`.apply` 语义等价却不会被误判。
  358. */
  359. function owns(object, key) {
  360. return Object.prototype.hasOwnProperty.apply(object, [key]) === true;
  361. }
  362. function isStopSignal(error) {
  363. return !!(error && error.name === 'StopSignal');
  364. }
  365. /**
  366. * 取引擎启动参数。
  367. * Rhino 会把传进来的数组包装成 `{"0":..,"1":..}` 这种对象(实测),
  368. * 所以既接受真数组,也按数字下标收集一份。
  369. */
  370. function readExecArgv() {
  371. var list = [];
  372. try {
  373. var argv = engines.myEngine().execArgv;
  374. if (argv instanceof Array) {
  375. return argv;
  376. }
  377. for (var i = 0; argv && argv[i] !== undefined; i += 1) {
  378. list.push(argv[i]);
  379. }
  380. } catch (ignored) {
  381. // 独立运行时 execArgv 为空/不可用,按"没有参数"处理
  382. }
  383. return list;
  384. }
  385. /** 解析 `--key value` 形式的参数(不支持 `--key=value`,保持单一写法) */
  386. function parseArgs(argv) {
  387. var options = {};
  388. for (var i = 0; i < argv.length; i += 1) {
  389. var token = String(argv[i]);
  390. if (token === '--config') {
  391. options.configPath = argv[i + 1];
  392. i += 1;
  393. } else if (token === '--state') {
  394. options.statePath = argv[i + 1];
  395. i += 1;
  396. } else if (token === '--stop-flag') {
  397. options.stopFlagPath = argv[i + 1];
  398. i += 1;
  399. } else if (token === '--run-id') {
  400. options.runId = argv[i + 1];
  401. i += 1;
  402. } else if (token === '--log') {
  403. options.logPath = argv[i + 1];
  404. i += 1;
  405. } else if (token === '--resume') {
  406. /* 裸开关,不取值:断点续跑由界面点「继续」时带上 */
  407. options.resume = true;
  408. } else if (token === '--checkpoint') {
  409. options.checkpointPath = argv[i + 1];
  410. i += 1;
  411. }
  412. }
  413. return options;
  414. }
  415. /**
  416. * 入口用:把命令行参数翻译成「配置覆盖项 + 桥的开关」。
  417. *
  418. * @param base 构建时注入的配置(可为 null)
  419. * @returns 应当交给 main.run 的注入项
  420. */
  421. function fromExecArgv(base) {
  422. var injected = base || {};
  423. var options = parseArgs(readExecArgv());
  424. if (options.runId) {
  425. enable(options);
  426. }
  427. if (options.configPath) {
  428. injected.runtime = injected.runtime || {};
  429. injected.runtime.configPath = options.configPath;
  430. }
  431. /* 续跑开关只改 `id.resume`,**不动 sources** ——
  432. * `merge` 是深合并,所以不会把配置里的取材清单覆盖掉。 */
  433. if (options.resume) {
  434. injected.id = injected.id || {};
  435. injected.id.resume = injected.id.resume || {};
  436. injected.id.resume.enabled = true;
  437. if (options.checkpointPath) {
  438. injected.id.resume.path = options.checkpointPath;
  439. }
  440. }
  441. return injected;
  442. }
  443. /** 本次运行的 runId(断点里要记下"这是哪次运行写的",界面靠它把记录与断点对上) */
  444. function currentRunId() {
  445. return state.runId || '';
  446. }
  447. /**
  448. * 日志落盘(界面侧「执行日志」的来源)。
  449. *
  450. * 一行一条 JSON(jsonl):界面按行解析,坏行跳过即可 ——
  451. * 比整份 JSON 更抗"写着写着被杀进程"(半行只丢一行,不会让整份文件不可读)。
  452. * 用 append 而不是重写:日志是**流**,每写一次整文件会让长流程变成 O(n²) 的 IO。
  453. */
  454. function appendLog(level, step, message) {
  455. try {
  456. files.append(state.logPath,
  457. JSON.stringify({
  458. t: Date.now(),
  459. level: level,
  460. step: step,
  461. text: (step ? '[' + step + '] ' : '') + message
  462. }) + '\n');
  463. } catch (error) {
  464. /* 落盘失败不该影响流程:logger 那边已经吞了一层,这里再兜一层 */
  465. }
  466. }
  467. /** 协议版本号。protocol 模块缺失时是 0 —— 宿主据此走 v1 回退,不会认错。 */
  468. function protocolVersion() {
  469. return TikTokAuto.protocol ? TikTokAuto.protocol.VERSION : 0;
  470. }
  471. function beatIntervalMs() {
  472. var spec = TikTokAuto.protocol ? TikTokAuto.protocol.SPEC : null;
  473. var ms = spec && spec.thresholds ? spec.thresholds.beatIntervalMs : 0;
  474. return ms > 0 ? ms : BEAT_INTERVAL_FALLBACK_MS;
  475. }
  476. /**
  477. * 写一拍心跳。
  478. *
  479. * 只做两件事:看一眼停止标志(记下"看见了")、整份重写心跳文件。
  480. * 不 report、不抛异常、不碰 run-state。
  481. */
  482. function heartbeatOnce() {
  483. var content = '';
  484. try {
  485. if (files.exists(state.stopFlagPath)) {
  486. content = String(files.read(state.stopFlagPath)).replace(/\s/g, '');
  487. }
  488. } catch (error) {
  489. /* 读不到就当"还没看见":下一拍再试,绝不因为一次读失败就清掉 seenAt */
  490. content = '';
  491. }
  492. if (content !== '' && content === state.runId) {
  493. /* sticky:只在第一次记时刻 */
  494. if (beat.seenAt === 0) {
  495. beat.seenAt = Date.now();
  496. }
  497. }
  498. beat.seq += 1;
  499. try {
  500. files.write(state.beatPath, JSON.stringify({
  501. v: protocolVersion(),
  502. runId: state.runId,
  503. seq: beat.seq,
  504. beatAt: Date.now(),
  505. stopSeenAt: beat.seenAt
  506. }));
  507. } catch (error) {
  508. /* 心跳写不进去不该影响流程:宿主那边会表现为"心跳过期",
  509. * 它有自己的兜底(判死 → 结束运行),不需要引擎来救 */
  510. }
  511. }
  512. /**
  513. * 起心跳线程。
  514. * @returns 是否真的起来了 —— 起不来时把它写进 capabilities,宿主会退回 v1 阈值
  515. */
  516. function heartbeatStart() {
  517. if (!state.enabled) {
  518. return false;
  519. }
  520. if (beat.running) {
  521. return true;
  522. }
  523. beat.running = true;
  524. beat.seq = 0;
  525. beat.seenAt = 0;
  526. /* 先同步写一拍:宿主在启动握手阶段就有东西可读,不用等 2 秒 */
  527. heartbeatOnce();
  528. var interval = beatIntervalMs();
  529. try {
  530. beat.thread = threads.start(function () {
  531. while (beat.running) {
  532. /* 先睡再写:开场那一拍上面已经写过,避免同一毫秒写两次 */
  533. sleep(interval);
  534. if (!beat.running) {
  535. break;
  536. }
  537. heartbeatOnce();
  538. }
  539. });
  540. } catch (error) {
  541. beat.running = false;
  542. beat.thread = null;
  543. log('warn', '心跳线程启动失败,宿主将退回无心跳判定:' + error);
  544. return false;
  545. }
  546. return true;
  547. }
  548. /** 停心跳。只是放下旗子,线程自己在下一拍前后退出(不强制中断)。 */
  549. function heartbeatStop() {
  550. beat.running = false;
  551. beat.thread = null;
  552. }
  553. function isHeartbeatUp() {
  554. return beat.running === true;
  555. }
  556. /** 打开桥。没有 runId 时**不启用**——runId 是停止标志比对的依据,缺了它无法判定是谁的请求。 */
  557. function enable(options) {
  558. state.enabled = true;
  559. state.runId = String(options.runId);
  560. state.statePath = options.statePath || DEFAULT_STATE;
  561. state.stopFlagPath = options.stopFlagPath || DEFAULT_STOP_FLAG;
  562. state.logPath = options.logPath || DEFAULT_LOG;
  563. state.beatPath = options.beatPath || DEFAULT_BEAT;
  564. state.lastStopCheckMs = 0;
  565. state.startedAt = Date.now();
  566. state.seq = 0;
  567. /* ★ 清空上一轮的日志:界面在结束时整份读走,
  568. * 不清的话 D3 上会混进上一次运行的行(而且越跑越长)。 */
  569. try {
  570. files.write(state.logPath, '');
  571. } catch (error) {
  572. /* 清不掉也不致命:界面会按 t 只取本轮时间窗内的行 */
  573. }
  574. if (TikTokAuto.logger && TikTokAuto.logger.setSink) {
  575. TikTokAuto.logger.setSink(appendLog);
  576. }
  577. /* ★ 顺序:先起心跳、拿结果,再写第一次状态。
  578. * 反过来会先告诉宿主"有心跳"再改成"没有",宿主可能在那两秒里按心跳判活,
  579. * 而实际上根本没有心跳线程。 */
  580. var heartbeatUp = heartbeatStart();
  581. last = {
  582. v: protocolVersion(),
  583. runId: state.runId,
  584. status: 'starting',
  585. phase: '',
  586. done: 0,
  587. total: 0,
  588. seq: 0,
  589. startedAt: state.startedAt,
  590. updatedAt: 0,
  591. /* task 此刻还不知道(要等 config.resolve),main 会调 setTask 补上 */
  592. engine: { task: '', capabilities: { heartbeat: heartbeatUp } }
  593. };
  594. report({});
  595. log('info', '已连接到界面宿主,runId=' + state.runId +
  596. ' / 心跳=' + (heartbeatUp ? '开' : '关'));
  597. }
  598. /**
  599. * 告诉宿主"这次跑的是哪个任务"。
  600. * 只是补一个字段并立刻落盘一次:宿主用它做排障展示,
  601. * 也让"引擎起来了但配置是错的"这种情况在状态里看得见。
  602. */
  603. function setTask(task) {
  604. if (!state.enabled || !last) {
  605. return;
  606. }
  607. if (!last.engine) {
  608. last.engine = { task: '', capabilities: { heartbeat: isHeartbeatUp() } };
  609. }
  610. last.engine.task = String(task || '');
  611. report({});
  612. }
  613. function isEnabled() {
  614. return state.enabled;
  615. }
  616. /**
  617. * 写一次状态。宿主每秒读一次这个文件。
  618. * 失败只记 WARN:状态上报失败不该让正在跑的流程崩掉。
  619. */
  620. function report(patch) {
  621. if (!state.enabled || !last) {
  622. return;
  623. }
  624. for (var key in patch) {
  625. if (owns(patch, key)) {
  626. last[key] = patch[key];
  627. }
  628. }
  629. state.seq += 1;
  630. last.v = protocolVersion();
  631. last.seq = state.seq;
  632. last.startedAt = state.startedAt;
  633. last.runId = state.runId;
  634. last.updatedAt = Date.now();
  635. try {
  636. files.write(state.statePath, JSON.stringify(last));
  637. } catch (error) {
  638. log('warn', '状态上报失败:' + error);
  639. }
  640. }
  641. /** 写终态。宿主靠它结束「运行中」并弹出结果。 */
  642. function finish(status, summary) {
  643. if (!state.enabled) {
  644. return;
  645. }
  646. var patch = {
  647. status: status,
  648. phase: '',
  649. summary: summary || null,
  650. message: describe(status, summary)
  651. };
  652. /* ID 提取的 `done` 是"已收个数",而最后一次 `report` 往往发生在收下一个人之前 ——
  653. * 于是终态里 done 会比实际少一个(实测 11 个却报 10)。
  654. * 收尾时按 summary 校正一次,免得界面/记录上的数字自相矛盾。 */
  655. if (summary && typeof summary.extractedTotal === 'number') {
  656. /* 续跑时 extracted 只是"本轮新增",累计数才和界面上的进度一致 */
  657. patch.done = summary.extractedTotal;
  658. } else if (summary && typeof summary.extracted === 'number') {
  659. patch.done = summary.extracted;
  660. }
  661. report(patch);
  662. log('info', '终态已上报:' + status);
  663. }
  664. /**
  665. * 养号的三个动作计数拼成一句话。
  666. * 只写"真的做了"的个数(跳过的不算),否则用户会以为自己点了 N 个赞、
  667. * 实际全是"本来就已经赞过"。
  668. */
  669. function nurtureActions(summary) {
  670. return '点赞 ' + summary.liked + ' / 关注 ' + summary.followed +
  671. ' / 评论 ' + summary.commented;
  672. }
  673. /** 给界面看的一句人话,不要求界面自己去解析 summary 结构 */
  674. function describe(status, summary) {
  675. /* ID 提取与视频链路的 summary 形状不同(提取个数 vs 视频个数),
  676. * 但界面只有"一句人话"这一个位置 —— 所以在这里分支,让两条链路都说得清楚。
  677. * 判据是 `extracted` 字段是否存在,与 task 解耦(summary 自己就带得动语义)。 */
  678. var isIdTask = summary && typeof summary.extracted === 'number';
  679. /* 养号有自己的形状(轮数 + 三个动作计数),既不是"提取 N 个"也不是"处理 N 个视频"。
  680. * 判据用 summary 自己带的 task 字段,不靠调用方传——这样以后加任务不用再动这里。 */
  681. var isNurture = summary && summary.task === 'nurture' && typeof summary.rounds === 'number';
  682. /* 私信:关注与私信是两个不同的动作,要分开说 */
  683. var isDmTask = summary && summary.task === 'dm' && typeof summary.sent === 'number';
  684. var dmTail = function () {
  685. var tail = '';
  686. if (summary.skipped > 0) { tail += ',跳过 ' + summary.skipped + ' 个'; }
  687. if (summary.failed > 0) { tail += ',失败 ' + summary.failed + ' 个'; }
  688. if (summary.dryRun) { tail += '(演练 ' + (summary.dryRunCount || 0) + ' 条,未真发)'; }
  689. /* 人是哪儿来的要说清:脚本二跑完只说"关注 3 个/私信 2 条",
  690. * 用户没法确认它到底是不是从自己的粉丝里取的人。 */
  691. if (summary.sourceKind === 'ownFans') {
  692. tail += '(取材自你自己的粉丝列表,' + (summary.totalUids || 0) + ' 人)';
  693. }
  694. return tail;
  695. };
  696. /* 续跑时"数字"要给累计口径,并明确说这是续跑 ——
  697. * 否则用户看到"提取 5 个"会以为之前那 37 个丢了。 */
  698. var total = summary && typeof summary.extractedTotal === 'number'
  699. ? summary.extractedTotal
  700. : (summary ? summary.extracted : 0);
  701. var resumedTag = summary && summary.resumed ? '(续跑)' : '';
  702. var thisRound = summary && summary.resumed && summary.extracted !== total
  703. ? ',本轮新增 ' + summary.extracted + ' 个' : '';
  704. if (status === 'stopped') {
  705. if (isDmTask) {
  706. return '已停止:关注 ' + summary.followed + ' 个 / 私信 ' +
  707. summary.sent + ' 条' + dmTail();
  708. }
  709. if (isNurture) {
  710. return '已停止:浏览 ' + summary.rounds + ' 轮 / ' + nurtureActions(summary);
  711. }
  712. return isIdTask
  713. ? '已停止' + resumedTag + ':累计 ' + total + ' 个 ID' + thisRound +
  714. '(可点「继续」接着跑)'
  715. : '已停止';
  716. }
  717. if (status === 'finished') {
  718. if (isDmTask) {
  719. return '完成:关注 ' + summary.followed + ' 个 / 私信 ' +
  720. summary.sent + ' 条' + dmTail();
  721. }
  722. if (isNurture) {
  723. /* 时长先到要说出来 —— 用户下一步该调的是「浏览时长」而不是别的 */
  724. var why = summary.stoppedBy === 'time' ? '(时长先到,轮数没用满)' : '';
  725. return '完成:浏览 ' + summary.rounds + ' 轮 / ' +
  726. nurtureActions(summary) + why;
  727. }
  728. if (isIdTask) {
  729. var tail = summary.skipped > 0 ? ',按条件跳过 ' + summary.skipped + ' 个' : '';
  730. /* 生成了 uid 映射就顺带说一句 —— 用户不一定知道还有第二个文件 */
  731. var mapped = summary.mapFile ? '(已生成 uid 映射)' : '';
  732. return '完成' + resumedTag + ':提取 ' + total + ' 个 ID' + thisRound + tail + mapped;
  733. }
  734. var watched = summary ? summary.attemptedVideos : 0;
  735. var posted = summary ? summary.postedComments : 0;
  736. /* 只写"真的做了"的:跳过(已赞/已关注)不计入,否则用户会以为
  737. * 自己点了 N 个赞,实际全是"本来就已经赞过"。 */
  738. var liked = summary && summary.likedVideos ? summary.likedVideos : 0;
  739. var followed = summary && summary.followedAuthors ? summary.followedAuthors : 0;
  740. var actions = [];
  741. if (liked > 0 || (summary && summary.likesRequested)) {
  742. actions.push('点赞 ' + liked);
  743. }
  744. if (followed > 0 || (summary && summary.followsRequested)) {
  745. actions.push('关注 ' + followed);
  746. }
  747. actions.push('评论 ' + posted);
  748. return '完成:处理 ' + watched + ' 个视频 / ' + actions.join(' / ');
  749. }
  750. var reason = summary && summary.errors && summary.errors.length > 0
  751. ? summary.errors[0]
  752. : '未知原因';
  753. return '未完成:' + reason;
  754. }
  755. /**
  756. * 停止检查。**由唯一的等待原语 `wait.tick` 调用**,
  757. * 于是所有长等待都能被打断,而流程代码一处都不用改。
  758. * 命中即抛 StopSignal。
  759. */
  760. function checkStop() {
  761. if (!state.enabled) {
  762. return;
  763. }
  764. var now = Date.now();
  765. if (now - state.lastStopCheckMs < STOP_POLL_MIN_MS) {
  766. return;
  767. }
  768. state.lastStopCheckMs = now;
  769. var content = '';
  770. try {
  771. if (!files.exists(state.stopFlagPath)) {
  772. return;
  773. }
  774. content = String(files.read(state.stopFlagPath)).replace(/\s/g, '');
  775. } catch (error) {
  776. log('warn', '读取停止标志失败:' + error);
  777. return;
  778. }
  779. if (content !== '' && content === state.runId) {
  780. report({ status: 'stopping', phase: '正在收尾', message: '收到停止请求,正在收尾' });
  781. log('warn', '收到停止请求(runId 匹配),中断当前等待');
  782. throw new StopSignal();
  783. }
  784. }
  785. function log(level, message) {
  786. if (TikTokAuto.logger && TikTokAuto.logger[level]) {
  787. TikTokAuto.logger[level]('bridge', message);
  788. }
  789. }
  790. return {
  791. DEFAULT_DIR: DEFAULT_DIR,
  792. DEFAULT_STATE: DEFAULT_STATE,
  793. DEFAULT_STOP_FLAG: DEFAULT_STOP_FLAG,
  794. DEFAULT_LOG: DEFAULT_LOG,
  795. DEFAULT_BEAT: DEFAULT_BEAT,
  796. StopSignal: StopSignal,
  797. isStopSignal: isStopSignal,
  798. readExecArgv: readExecArgv,
  799. parseArgs: parseArgs,
  800. fromExecArgv: fromExecArgv,
  801. isEnabled: isEnabled,
  802. checkStop: checkStop,
  803. currentRunId: currentRunId,
  804. protocolVersion: protocolVersion,
  805. setTask: setTask,
  806. heartbeatStart: heartbeatStart,
  807. heartbeatStop: heartbeatStop,
  808. isHeartbeatUp: isHeartbeatUp,
  809. /* 单独导出"写一拍":PC 自检要能在不真起线程的情况下驱动心跳,
  810. * 排障时也想手动补一拍。业务代码**不应该**调它(会打乱 seq 的含义)。 */
  811. heartbeatOnce: heartbeatOnce,
  812. report: report,
  813. finish: finish,
  814. describe: describe,
  815. appendLog: appendLog
  816. };
  817. }());
  818. /* ============================== src/config.js ============================== */
  819. /**
  820. * config.js — 配置的默认值、设备端加载、合并与校验
  821. *
  822. * 设计依据(Zakas《编写可维护的JavaScript》第 9 章「使用配置对象」):
  823. * 所有"会变的业务数据"——搜索关键词、评论文案、观看时长、超时阈值、开关——
  824. * 都集中在配置文件里;代码只负责读,不负责写死。
  825. * 于是**改文案/改时长不需要动代码**,也不需要重新构建。
  826. *
  827. * 配置来源优先级(后者覆盖前者):
  828. * 1. DEFAULTS(本文件,保证"没有配置文件也能跑")
  829. * 2. 设备上的 JSON 文件(默认 /sdcard/Download/tiktok-auto/config.json)
  830. * 3. 运行时注入的 payload(PC 侧下发脚本时携带,便于免推送做实验)
  831. *
  832. * 依赖:TikTokAuto.logger
  833. */
  834. TikTokAuto.config = (function () {
  835. 'use strict';
  836. var DEFAULT_DEVICE_PATH = '/sdcard/Download/tiktok-auto/config.json';
  837. var DEFAULTS = {
  838. /**
  839. * 任务类型。缺省 'video' 是**刻意的向后兼容**:
  840. * 老配置(PC 侧下发、设备上残留的 config.json)里没有这个字段,
  841. * 它们本来就是视频链路的,所以默认值必须还是 video,
  842. * 否则一次升级会把所有老用法打成"未知任务"。
  843. */
  844. task: 'video',
  845. target: {
  846. packageName: 'com.zhiliaoapp.musically',
  847. keyword: 'fashion',
  848. maxVideos: 1,
  849. resultTab: 'Top',
  850. pickStrategy: 'random'
  851. },
  852. /**
  853. * ID 提取(task = 'id')。形状与界面 `sources.js` 对齐:
  854. * `sources` 是**不定长清单**,每条自带起点与深度。
  855. */
  856. id: {
  857. sources: [],
  858. filter: {
  859. private: false,
  860. noAvatar: false,
  861. langRegion: 'any',
  862. nicknameCheck: 'any',
  863. follow: { min: 0, max: 0 },
  864. fans: { min: 0, max: 0 },
  865. likes: { min: 0, max: 0 },
  866. videos: { min: 0, max: 0 }
  867. },
  868. output: {
  869. dir: '/sdcard/Download/tiktok_app',
  870. historyPath: '/sdcard/Download/tiktok_app/uid_extracted.txt',
  871. dedupeHistory: true,
  872. dryRun: false,
  873. limit: 0,
  874. /**
  875. * 抓完之后是否把 username 换成 TikTok 的**数字 uid**,另存一份映射文件。
  876. *
  877. * 默认开:数字 uid 在 App 界面上任何地方都读不到,不在这里换就得事后另跑一次。
  878. * 每个约 2.5 秒(要下整页 SSR 约 388KB),所以只要 handle 的场景可以关掉省时间。
  879. */
  880. resolveUid: true
  881. },
  882. /**
  883. * 物理护栏。界面里 `limit = 0` 的语义是"不限",
  884. * 但真跑起来没有上限等于跑到没电 —— 所以引擎自己有个时长上限。
  885. * 到点时**如实报告**,不装作抓完了。
  886. */
  887. guard: {
  888. /** 抓取阶段最长多久(不含收尾) */
  889. maxRuntimeMs: 30 * 60 * 1000,
  890. /**
  891. * 解析数字 uid 阶段**自己的**预算。
  892. *
  893. * 为什么必须独立:抓取很容易把 `maxRuntimeMs` 用满
  894. * (实测 83 个候选 × 每人约 8 秒 = 30 分钟),
  895. * 而解析 uid 是**收尾工作**,恰恰在这之后才做。
  896. * 两者共用一个预算时,抓满 = 收尾一点时间都不剩 = 一个 uid 都换不到。
  897. */
  898. resolveMaxMs: 10 * 60 * 1000
  899. },
  900. /**
  901. * 断点续跑。默认**关**:只有界面明确点「继续」才会带 `--resume` 进来。
  902. *
  903. * 为什么不默认开:一份手写的 engine-config.json 如果默认就去读断点,
  904. * 会出现"我只想从头跑一次,结果它接着上次跑"这种反直觉行为。
  905. * 而且 `state:'none'`/`'done'` 时它本来也不会续 —— 但**默认关**更安全。
  906. *
  907. * path 留空则由 `output.dir` 派生(`checkpoint.pathOf`),
  908. * 这样用户改产物目录时断点跟着走,不会出现"产物在新目录、断点在旧目录"。
  909. */
  910. resume: {
  911. enabled: false,
  912. path: ''
  913. }
  914. },
  915. watch: {
  916. minMs: 10000,
  917. maxMs: 35000
  918. },
  919. /**
  920. * 点赞 / 关注:与 comments 平级的两个动作开关。
  921. *
  922. * 界面的「执行操作」是四项多选(关注 / 私信 / 点赞 / 评论),
  923. * 这四项各自映射到一个 enabled —— 用户没勾就不做,不能因为"顺手"就点上。
  924. *
  925. * 与 ID 提取里的 `id.filter.likes`(按获赞数量筛账号)**不是一回事**,
  926. * 只是名字撞了,层级不同。
  927. */
  928. likes: {
  929. enabled: true
  930. },
  931. follows: {
  932. enabled: true
  933. },
  934. comments: {
  935. /* 是否执行评论动作。关掉就是「只浏览不发评论」——
  936. * 界面把「执行操作」里的评论开关映射到这里:
  937. * 用户没勾评论就不该发,哪怕评论池配好了。 */
  938. enabled: true,
  939. pool: [
  940. 'Love this!',
  941. 'So pretty',
  942. 'Great style',
  943. 'This is amazing',
  944. 'Need this look'
  945. ],
  946. avoidRepeatWithinRun: true,
  947. strictPostCheck: false,
  948. verifyInList: true
  949. },
  950. /**
  951. * 养号(task = 'nurture'):在首页信息流里随机浏览 + 按概率互动。
  952. *
  953. * 与视频链路共用 `watch` / `comments` / `probe` 不够——养号有两处语义不同:
  954. * ① **没有目标**:视频链路是"搜关键词看 N 个",养号是"往下刷",
  955. * 所以没有 target.keyword / maxVideos,换成 rounds 与 browse 双约束
  956. * ② **单次观看时长要短**:视频链路为了"观看有效"默认 10~35 秒,
  957. * 养号模拟的是"划过去看一眼",5~25 秒之间随机更像真人
  958. *
  959. * 概率是**小数**(0.3 = 30%),界面那边是档位(r30),由 engine.js 换算。
  960. * 顶层 `comments` 段(文案池、是否校验)在这里同样生效——一次只跑一个任务,不会打架。
  961. */
  962. nurture: {
  963. /** 单次操作的**轮数**(界面「单次操作 10~30」)。与 browse 各自独立,谁先到谁停 */
  964. rounds: { min: 10, max: 30 },
  965. /** 浏览时长(界面「浏览时长 3~8 分钟」) */
  966. browse: { minMs: 3 * 60 * 1000, maxMs: 8 * 60 * 1000 },
  967. /** 单个视频看多久。养号是"划过去看一眼",不该按视频链路的 10~35 秒 */
  968. watch: { minMs: 5000, maxMs: 25000 },
  969. /** 做完一个动作后的随机停顿(真人不会点完立刻滑) */
  970. pause: { minMs: 800, maxMs: 2500 },
  971. likeRate: 0.30,
  972. commentRate: 0.10,
  973. followRate: 0.15
  974. },
  975. /**
  976. * UID 私信(task = 'dm')。
  977. *
  978. * 界面的「关注数量 100~200」「私信数量 20~30」是**两组独立额度**,
  979. * 作用在**两批完全不同的人**上:前 `follow.max` 个只关注,接下来
  980. * `send.max` 个只发私信。所以 `max` 是**确定的数字**(界面在区间里随机取一次
  981. * 再传下来),不是区间。
  982. *
  983. * 「休息时间」是**每处理完一个 UID 之后**的间隔,也在这里传确定的区间。
  984. */
  985. dm: {
  986. /**
  987. * 's1' = 已提 UID 私信(读文本文件)
  988. * 's2' = 自己粉丝私信(打开自己的主页 → 粉丝列表取材,不用 uidPath)
  989. */
  990. script: 's1',
  991. /** s1 的 UID 来源:每行一个,`@` 可有可无,`#` 开头算注释(s2 忽略) */
  992. uidPath: '/sdcard/Download/tiktok_app/uid_list.txt',
  993. follow: { enabled: true, max: 0 },
  994. send: { enabled: true, max: 0 },
  995. /** 每个 UID 之后休息多久(v9 起界面默认 10~40 秒) */
  996. rest: { minMs: 10000, maxMs: 40000 },
  997. /** 'follow_dm' | 'follow' | 'dm' —— 按什么去重(跨轮,读 dm_history.txt) */
  998. dedupeMode: 'follow_dm',
  999. /** 首视频筛选与首视频动作(脚本一的两个设置) */
  1000. filterDate: '7d',
  1001. firstAction: 'both',
  1002. /** 私信文案池(界面「随机私信文本」文件里读出来的每一行) */
  1003. pool: [],
  1004. /** 评论文案池(首视频评论用,同样从文件读) */
  1005. commentPool: [],
  1006. /**
  1007. * 物理护栏:时长到点就收尾,别指望"跑完自然停"。
  1008. * 关注 150 + 私信 25,每个约 30~80 秒(搜索+进页+动作+休息),
  1009. * 一轮 1.5~4 小时;给 5 小时上限兜住"UID 文件特别长"的情况。
  1010. */
  1011. guard: { maxRuntimeMs: 5 * 60 * 60 * 1000 }
  1012. },
  1013. timeouts: {
  1014. appLaunchMs: 25000,
  1015. pageReadyMs: 15000,
  1016. /**
  1017. * 搜索结果首屏 / 切页签重刷的等待上限。
  1018. *
  1019. * 2026-10-28 实测:关键词 `cat` 从点搜索到网格出现约 **18 秒**
  1020. * (8 秒时截图还是空的,再等 10 秒才有卡)。原来 20 秒只剩 2 秒余量,
  1021. * 网络稍慢就会判成"没有可用卡片"。放到 30 秒。
  1022. */
  1023. searchResultsMs: 30000,
  1024. playerReadyMs: 20000,
  1025. playbackStartMs: 12000,
  1026. panelReadyMs: 8000,
  1027. postMs: 15000,
  1028. countRefreshMs: 6000
  1029. },
  1030. probe: {
  1031. pollTickMs: 150,
  1032. livenessIntervalMs: 2500,
  1033. stallTolerance: 3,
  1034. useAudio: true,
  1035. usePixels: false
  1036. },
  1037. guard: {
  1038. enabled: true,
  1039. intervalMs: 1200,
  1040. firstScanTimeoutMs: 4000,
  1041. dismissLabels: [
  1042. 'Not now', 'No thanks', 'Skip', 'Later', 'Cancel', 'Close',
  1043. '稍后', '暂不', '取消', '关闭', '以后再说'
  1044. ]
  1045. },
  1046. runtime: {
  1047. configPath: DEFAULT_DEVICE_PATH,
  1048. maxRuntimeMs: 0,
  1049. dryRun: false,
  1050. /* 本轮运行的标识。界面写进 engine-config.json,引擎把命令行里的
  1051. * `--run-id` 拿来比对 —— 用来挡住"上一次的引擎迟到启动、
  1052. * 却读到这一轮的配置并真的跑起来"。
  1053. * 独立运行(没有宿主)时为 '',校验会自动跳过。 */
  1054. runId: ''
  1055. }
  1056. };
  1057. /** 配置错误:单独类型,便于上层区分"配置写错了"和"运行时炸了" */
  1058. function ConfigError(message, detail) {
  1059. this.name = 'ConfigError';
  1060. this.message = message;
  1061. this.detail = detail;
  1062. this.stack = new Error(message).stack;
  1063. }
  1064. ConfigError.prototype = new Error();
  1065. ConfigError.prototype.constructor = ConfigError;
  1066. /**
  1067. * 判断自有属性。
  1068. *
  1069. * 这里刻意写成 `.apply(...)` 而不是更常见的「属性名 + 点 + 名字为 call 的方法」那种形式。
  1070. * 原因:MCP 桥会对下发的脚本做危险调用黑名单扫描,其中一条规则按词边界匹配
  1071. * 「名字为 call 的方法 + 左括号」的片段。`.apply` 语义完全等价,却不会被误判成
  1072. * 危险代码、从而让每次下发都多要一次人工确认。
  1073. */
  1074. function owns(object, key) {
  1075. return Object.prototype.hasOwnProperty.apply(object, [key]) === true;
  1076. }
  1077. function isPlainObject(value) {
  1078. return value !== null && typeof value === 'object' && !(value instanceof Array);
  1079. }
  1080. /** 深拷贝(Rhino 没有 Object.assign,也不该依赖它) */
  1081. function clone(value) {
  1082. if (value instanceof Array) {
  1083. var list = [];
  1084. for (var i = 0; i < value.length; i += 1) {
  1085. list.push(clone(value[i]));
  1086. }
  1087. return list;
  1088. }
  1089. if (isPlainObject(value)) {
  1090. var copy = {};
  1091. for (var key in value) {
  1092. if (owns(value, key)) {
  1093. copy[key] = clone(value[key]);
  1094. }
  1095. }
  1096. return copy;
  1097. }
  1098. return value;
  1099. }
  1100. /**
  1101. * 深合并:对象逐键递归,数组整体替换。
  1102. * 数组"整体替换"是刻意的——评论池这类配置如果被逐项合并,
  1103. * 用户就永远删不掉默认评论了。
  1104. */
  1105. function merge(base, override) {
  1106. if (!isPlainObject(override)) {
  1107. return clone(override);
  1108. }
  1109. var result = clone(base);
  1110. for (var key in override) {
  1111. if (!owns(override, key)) {
  1112. continue;
  1113. }
  1114. var value = override[key];
  1115. if (isPlainObject(value) && isPlainObject(result[key])) {
  1116. result[key] = merge(result[key], value);
  1117. } else {
  1118. result[key] = clone(value);
  1119. }
  1120. }
  1121. return result;
  1122. }
  1123. function readDeviceJson(path) {
  1124. var logger = TikTokAuto.logger;
  1125. try {
  1126. if (typeof files === 'undefined' || !files.exists(path)) {
  1127. logger.info('config', '未找到配置文件,使用内置默认值:' + path);
  1128. return null;
  1129. }
  1130. var raw = files.read(path);
  1131. var parsed = JSON.parse(raw);
  1132. logger.info('config', '已加载配置文件:' + path);
  1133. return parsed;
  1134. } catch (error) {
  1135. // 配置写坏了必须让用户知道,但不应因此让整脚本无法启动——
  1136. // 用默认值继续跑,同时把问题显著地抛到日志里。
  1137. logger.error('config', '配置文件解析失败,改用默认值:' + error);
  1138. return null;
  1139. }
  1140. }
  1141. function validate(config) {
  1142. /* —— ID 提取任务:校验的是"取材清单",不是搜索关键词 ——
  1143. * 早先这个函数硬性要求 target.keyword 非空,那是视频链路的前提。
  1144. * ID 提取可以不搜索(从粉丝列表、文本文件、当前屏幕取材),
  1145. * 所以按 task 分支,而不是给关键词塞一个假默认值把校验糊过去。 */
  1146. if (config.task === 'id') {
  1147. if (!(config.id && config.id.sources instanceof Array)) {
  1148. throw new ConfigError('id.sources 必须是数组(取材清单)', config.id && config.id.sources);
  1149. }
  1150. if (config.id.sources.length === 0) {
  1151. throw new ConfigError('id.sources 不能为空,至少需要一条取材', 0);
  1152. }
  1153. var limit = Number(config.id.output && config.id.output.limit);
  1154. if (limit < 0) {
  1155. throw new ConfigError('id.output.limit 不能为负数', limit);
  1156. }
  1157. return config;
  1158. }
  1159. /* —— 养号任务:没有搜索目标,校验的是"轮数 / 时长 / 概率" ——
  1160. * ★ 这一支必须放在 `var target = config.target` **之前**:
  1161. * 下面那段会硬性要求 `target.keyword` 非空,那是视频链路的前提。
  1162. * 养号根本不搜索,落到那里就会被判成"配置错误",而且报的是
  1163. * "target.keyword 不能为空"这种让人完全摸不着头脑的原因。 */
  1164. if (config.task === 'nurture') {
  1165. var nurture = config.nurture || {};
  1166. var rounds = nurture.rounds || {};
  1167. var browse = nurture.browse || {};
  1168. if (!(Number(rounds.min) >= 1)) {
  1169. throw new ConfigError('nurture.rounds.min 必须 >= 1', rounds.min);
  1170. }
  1171. if (Number(rounds.max) < Number(rounds.min)) {
  1172. throw new ConfigError('nurture.rounds.max 不能小于 rounds.min', rounds);
  1173. }
  1174. if (!(Number(browse.maxMs) > 0)) {
  1175. throw new ConfigError('nurture.browse.maxMs 必须 > 0', browse.maxMs);
  1176. }
  1177. /* 要发评论就必须有文案 —— 养号页补了「评论文案文件」正是为了这件事。
  1178. * 池子为空却把 commentRate 设成 10%,跑起来只会一轮轮地告警。 */
  1179. if (config.comments && config.comments.enabled &&
  1180. (!config.comments.pool || config.comments.pool.length === 0)) {
  1181. throw new ConfigError('要发评论就必须有评论文案(comments.pool 为空)', 0);
  1182. }
  1183. return config;
  1184. }
  1185. /* —— UID 私信:没有搜索目标,校验的是"有没有活可干、数量是否合法" —— */
  1186. if (config.task === 'dm') {
  1187. var dm = config.dm || {};
  1188. var followMax = Number(dm.follow && dm.follow.max);
  1189. var sendMax = Number(dm.send && dm.send.max);
  1190. var followOn = dm.follow && dm.follow.enabled === true;
  1191. var sendOn = dm.send && dm.send.enabled === true;
  1192. if (dm.script !== 's1' && dm.script !== 's2') {
  1193. throw new ConfigError('dm.script 只能是 "s1" 或 "s2"', dm.script);
  1194. }
  1195. /* 脚本一靠外部文本取材,路径空着必然一个都读不到 —— 提前报,
  1196. * 而不是先启动 App、跑到一半才发现"没有可用账号"。 */
  1197. if (dm.script === 's1' && !dm.uidPath) {
  1198. throw new ConfigError('脚本一必须指定 UID 文本(dm.uidPath 为空)', dm.uidPath);
  1199. }
  1200. if (!followOn && !sendOn) {
  1201. throw new ConfigError('「进行关注」与「进行私信」都关着,没有可执行的动作',
  1202. { follow: followOn, send: sendOn });
  1203. }
  1204. if (followOn && !(followMax >= 1)) {
  1205. throw new ConfigError('dm.follow.max 必须 >= 1(开着关注却没有数量)', followMax);
  1206. }
  1207. if (sendOn && !(sendMax >= 1)) {
  1208. throw new ConfigError('dm.send.max 必须 >= 1(开着私信却没有数量)', sendMax);
  1209. }
  1210. if (sendOn && (!dm.pool || dm.pool.length === 0)) {
  1211. throw new ConfigError('要发私信就必须有文案(「随机私信文本」那个文件是空的)', 0);
  1212. }
  1213. if (!(Number(dm.rest && dm.rest.maxMs) > 0)) {
  1214. throw new ConfigError('dm.rest.maxMs 必须 > 0', dm.rest && dm.rest.maxMs);
  1215. }
  1216. return config;
  1217. }
  1218. var target = config.target;
  1219. if (!target.keyword || String(target.keyword).replace(/\s/g, '') === '') {
  1220. throw new ConfigError('target.keyword 不能为空', target.keyword);
  1221. }
  1222. /* 「评论池为空」只有在**要发评论**时才算配置错误:
  1223. * 只浏览不发评论是合法的一次运行(界面上没勾评论就是这种情形)。 */
  1224. if (config.comments.enabled && config.comments.pool.length === 0) {
  1225. throw new ConfigError('comments.pool 不能为空,至少需要一条评论文案', 0);
  1226. }
  1227. if (config.watch.minMs > config.watch.maxMs) {
  1228. throw new ConfigError('watch.minMs 不能大于 watch.maxMs', {
  1229. minMs: config.watch.minMs,
  1230. maxMs: config.watch.maxMs
  1231. });
  1232. }
  1233. if (config.target.maxVideos < 1) {
  1234. throw new ConfigError('target.maxVideos 必须 >= 1', config.target.maxVideos);
  1235. }
  1236. return config;
  1237. }
  1238. /**
  1239. * 配置与本次运行必须对得上。
  1240. *
  1241. * 挡的是这一种真实故障:上一轮的引擎因为冷编译慢、迟到启动,
  1242. * 醒来时读到的已经是**这一轮**刚写的 engine-config —— 它照新配置跑起来,
  1243. * 而界面以为这一轮还没起来(于是两份流程同时操作一台手机)。
  1244. * 配置里绑一个 runId 就能让它当场收手。
  1245. *
  1246. * ★ 只在**双方都非空**时比较:独立运行时命令行没有 `--run-id`,
  1247. * `bridge.currentRunId()` 是空串,此时必须放行,否则引擎永远跑不起来。
  1248. */
  1249. function assertRunIdMatches(config) {
  1250. var wanted = config && config.runtime ? String(config.runtime.runId || '') : '';
  1251. var current = '';
  1252. if (TikTokAuto.bridge && TikTokAuto.bridge.currentRunId) {
  1253. current = String(TikTokAuto.bridge.currentRunId() || '');
  1254. }
  1255. if (wanted === '' || current === '') {
  1256. return;
  1257. }
  1258. if (wanted !== current) {
  1259. throw new ConfigError('配置属于另一次运行(runId 不匹配)', {
  1260. configRunId: wanted,
  1261. engineRunId: current
  1262. });
  1263. }
  1264. }
  1265. /**
  1266. * 解析最终生效的配置。
  1267. *
  1268. * @param injected PC 侧注入的覆盖项(可为 null)
  1269. */
  1270. function resolve(injected) {
  1271. var logger = TikTokAuto.logger;
  1272. var fromDefaults = clone(DEFAULTS);
  1273. var path = (injected && injected.runtime && injected.runtime.configPath) || DEFAULTS.runtime.configPath;
  1274. var fromDevice = readDeviceJson(path) || {};
  1275. var config = merge(merge(fromDefaults, fromDevice), injected || {});
  1276. validate(config);
  1277. assertRunIdMatches(config);
  1278. if (config.task === 'id') {
  1279. logger.info('config', '生效配置(ID提取):' + JSON.stringify({
  1280. sources: config.id.sources.length,
  1281. filter: config.id.filter,
  1282. limit: config.id.output.limit,
  1283. dedupeHistory: config.id.output.dedupeHistory,
  1284. dryRun: config.id.output.dryRun
  1285. }));
  1286. } else if (config.task === 'nurture') {
  1287. /* 养号没有关键词,报 video 那套字段(keyword/maxVideos)等于什么都没说。
  1288. * 排障时最需要的恰恰是"概率和轮数到底生效成了多少"。 */
  1289. var nur = config.nurture;
  1290. logger.info('config', '生效配置(养号):' + JSON.stringify({
  1291. rounds: [nur.rounds.min, nur.rounds.max],
  1292. browseMaxMin: Math.round(nur.browse.maxMs / 60000),
  1293. watchMs: [nur.watch.minMs, nur.watch.maxMs],
  1294. rates: {
  1295. like: nur.likeRate,
  1296. comment: nur.commentRate,
  1297. follow: nur.followRate
  1298. },
  1299. commentEnabled: config.comments.enabled,
  1300. commentCount: config.comments.pool.length,
  1301. dryRun: config.runtime.dryRun
  1302. }));
  1303. } else if (config.task === 'dm') {
  1304. /* 私信同样没有关键词,落到下面的 video 分支会打印
  1305. * `{"keyword":"fashion",...}` —— 排障时完全误导(曾经真被这条日志带偏过,
  1306. * 以为跑的是视频链路)。这里报"从哪儿取人、发多少、歇多久"。 */
  1307. var dm = config.dm;
  1308. logger.info('config', '生效配置(私信):' + JSON.stringify({
  1309. script: dm.script,
  1310. source: dm.script === 's2' ? '自己的粉丝列表'
  1311. : ('UID 文本 ' + dm.uidPath),
  1312. follow: dm.follow,
  1313. send: dm.send,
  1314. restSec: [Math.round(dm.rest.minMs / 1000), Math.round(dm.rest.maxMs / 1000)],
  1315. dedupeMode: dm.dedupeMode,
  1316. textCount: dm.pool.length,
  1317. dryRun: config.runtime.dryRun
  1318. }));
  1319. } else {
  1320. logger.info('config', '生效配置:' + JSON.stringify({
  1321. keyword: config.target.keyword,
  1322. maxVideos: config.target.maxVideos,
  1323. resultTab: config.target.resultTab,
  1324. watchMs: [config.watch.minMs, config.watch.maxMs],
  1325. commentEnabled: config.comments.enabled,
  1326. commentCount: config.comments.pool.length,
  1327. dryRun: config.runtime.dryRun
  1328. }));
  1329. }
  1330. return config;
  1331. }
  1332. return {
  1333. DEFAULTS: DEFAULTS,
  1334. DEFAULT_DEVICE_PATH: DEFAULT_DEVICE_PATH,
  1335. readDeviceJson: readDeviceJson,
  1336. merge: merge,
  1337. resolve: resolve,
  1338. ConfigError: ConfigError
  1339. };
  1340. }());
  1341. /* ============================== src/wait.js ============================== */
  1342. /**
  1343. * wait.js — 唯一的等待原语
  1344. *
  1345. * 核心主张:**脚本里不允许出现裸 sleep 当作"等待"**。
  1346. *
  1347. * tick(轮询节拍) = "多久检查一次" → 允许存在,默认 150ms
  1348. * sleep(盲等) = "赌它 X 秒后会好" → 禁止用作等待手段
  1349. *
  1350. * 为什么这是稳定性的关键:网络抖动时页面可能 300ms 才渲染,也可能 8s 才渲染。
  1351. * 写 `sleep(2000)` 的脚本在快的时候白等 1.7s,在慢的时候直接失败;
  1352. * 而 `waitFor(条件, 超时)` 在快的时候立刻返回,慢的时候一直等到条件成立。
  1353. * 这把"网络有多慢"从"脚本能不能跑通"里彻底解耦。
  1354. *
  1355. * 依赖:TikTokAuto.logger(调用时取,避免 concat 顺序耦合)
  1356. */
  1357. TikTokAuto.wait = (function () {
  1358. 'use strict';
  1359. var DEFAULT_TICK_MS = 150;
  1360. function now() {
  1361. return Date.now();
  1362. }
  1363. /**
  1364. * 轮询节拍。
  1365. *
  1366. * ★ 这里是**唯一**的等待出口,所以「用户点了停止」的检查放在这一处就够了 ——
  1367. * 全流程(等待应用启动、等待播放、等待结果卡片、等待评论区……共几十处等待)
  1368. * 都会经过 tick,于是都能被打断,业务代码一行都不用改。
  1369. * bridge 未启用时是空操作(独立运行时零开销)。
  1370. */
  1371. function tick(ms) {
  1372. if (TikTokAuto.bridge) {
  1373. TikTokAuto.bridge.checkStop();
  1374. }
  1375. sleep(ms === undefined ? DEFAULT_TICK_MS : ms);
  1376. }
  1377. /**
  1378. * 反复求值 predicate 直到它返回"真值"或超时。
  1379. *
  1380. * predicate 的返回值语义:
  1381. * - null / false → 条件未满足,继续轮询
  1382. * - 其它任何值(含 0!) → 条件已满足,原样返回该值
  1383. * 之所以不用 `if (value)` 判断,是为了允许 predicate 合法地返回 0 / '' 这类"假值结果"。
  1384. *
  1385. * @returns 满足时的返回值;超时返回 null
  1386. */
  1387. function poll(predicate, timeoutMs, tickMs) {
  1388. var deadline = now() + timeoutMs;
  1389. var step = tickMs === undefined ? DEFAULT_TICK_MS : tickMs;
  1390. var last = null;
  1391. for (;;) {
  1392. try {
  1393. var value = predicate();
  1394. if (value !== null && value !== false && value !== undefined) {
  1395. return value;
  1396. }
  1397. last = value;
  1398. } catch (error) {
  1399. last = error;
  1400. }
  1401. if (now() >= deadline) {
  1402. return null;
  1403. }
  1404. tick(Math.min(step, Math.max(1, deadline - now())));
  1405. }
  1406. }
  1407. /**
  1408. * 带日志与语义名字的 poll —— 业务代码应该用这个,而不是裸 poll。
  1409. * 超时会记 WARN(而不是静默返回 null),这样"卡在哪一步"永远有据可查。
  1410. */
  1411. function waitFor(name, predicate, timeoutMs, tickMs) {
  1412. var began = now();
  1413. var value = poll(predicate, timeoutMs, tickMs);
  1414. var cost = now() - began;
  1415. if (value === null) {
  1416. TikTokAuto.logger.warn('waitFor', name + ' 超时(等了 ' + cost + 'ms / 上限 ' + timeoutMs + 'ms)');
  1417. } else {
  1418. TikTokAuto.logger.info('waitFor', name + ' 就绪(' + cost + 'ms)');
  1419. }
  1420. return value;
  1421. }
  1422. /** 等待条件成立,并把成立时的值原样返回;语义等价于 waitFor */
  1423. function until(condition, timeoutMs, tickMs) {
  1424. return poll(condition, timeoutMs, tickMs);
  1425. }
  1426. /** 等待某个东西消失(例如弹窗、加载圈) */
  1427. function untilGone(isPresent, timeoutMs, tickMs) {
  1428. return poll(function () {
  1429. return isPresent() ? null : true;
  1430. }, timeoutMs, tickMs);
  1431. }
  1432. /**
  1433. * 等待页面"安静"下来。
  1434. *
  1435. * 判据不是时间,而是**可见文案的拼接签名连续 N 次不变**。
  1436. * 这比 sleep 可靠:加载中会不断有新节点出现,签名就会变;
  1437. * 真正稳定后才返回,因此天然适配"网络慢 → 渲染慢"。
  1438. *
  1439. * @param sample () => string,页面签名
  1440. * @param options { maxMs, tickMs, stableTimes }
  1441. */
  1442. function waitQuiet(sample, options) {
  1443. var settings = options || {};
  1444. var maxMs = settings.maxMs === undefined ? 8000 : settings.maxMs;
  1445. var tickMs = settings.tickMs === undefined ? 250 : settings.tickMs;
  1446. var stableTimes = settings.stableTimes === undefined ? 3 : settings.stableTimes;
  1447. var deadline = now() + maxMs;
  1448. var previous = null;
  1449. var sameCount = 0;
  1450. while (now() < deadline) {
  1451. var current = sample();
  1452. if (current === previous) {
  1453. sameCount += 1;
  1454. if (sameCount >= stableTimes) {
  1455. return true;
  1456. }
  1457. } else {
  1458. sameCount = 0;
  1459. previous = current;
  1460. }
  1461. tick(tickMs);
  1462. }
  1463. return false;
  1464. }
  1465. return {
  1466. DEFAULT_TICK_MS: DEFAULT_TICK_MS,
  1467. tick: tick,
  1468. poll: poll,
  1469. waitFor: waitFor,
  1470. until: until,
  1471. untilGone: untilGone,
  1472. waitQuiet: waitQuiet
  1473. };
  1474. }());
  1475. /* ============================== src/layout.js ============================== */
  1476. /**
  1477. * layout.js — 对 AutoX 无障碍 API 的适配层(Boundary Adapter)
  1478. *
  1479. * 依据《代码整洁之道》第 8 章「边界」:
  1480. * 第三方/平台 API 一定有自己的脾气(`auto.root` 是属性不是函数、
  1481. * `text()` 是快照、离屏节点占了 40%+…)。把所有这些脾气**关在这一个文件里**,
  1482. * 业务代码就只面对一组干净、语义明确的函数。
  1483. * 平台 API 变了 → 只改这里。
  1484. *
  1485. * 依赖:TikTokAuto.logger、TikTokAuto.wait
  1486. */
  1487. TikTokAuto.layout = (function () {
  1488. 'use strict';
  1489. var SAFE_MARGIN = 8;
  1490. function screen() {
  1491. return {
  1492. width: device.width,
  1493. height: device.height
  1494. };
  1495. }
  1496. /** 当前应用窗口的根节点。注意:是属性 `auto.root`,不是函数 `auto().root()` */
  1497. function root() {
  1498. return auto.root;
  1499. }
  1500. /** 前台包名。**不能**用 currentPackage()——软键盘弹出时它返回输入法包名 */
  1501. function foregroundPackage() {
  1502. var node = root();
  1503. return node ? node.packageName() : null;
  1504. }
  1505. function findAll(selector) {
  1506. return selector.find();
  1507. }
  1508. function findOnce(selector) {
  1509. var collection = selector.find();
  1510. return collection && collection.size() > 0 ? collection.get(0) : null;
  1511. }
  1512. /**
  1513. * 收集整棵树的节点(含离屏)。
  1514. * AutoX 的 `find()` 只从根往下找**可见**节点,拿不到离屏节点;
  1515. * 而我们需要离屏信息来做"下面还有内容 → 该滚动"的判断。
  1516. */
  1517. function collectAll(maxNodes) {
  1518. var limit = maxNodes || 1200;
  1519. var result = [];
  1520. var start = root();
  1521. if (!start) {
  1522. return result;
  1523. }
  1524. var stack = [start];
  1525. while (stack.length > 0 && result.length < limit) {
  1526. var node = stack.pop();
  1527. if (!node) {
  1528. continue; // 遍历过程中节点可能失效,child(i) 会返回 null
  1529. }
  1530. result.push(node);
  1531. var count = node.childCount();
  1532. for (var i = 0; i < count; i += 1) {
  1533. stack.push(node.child(i));
  1534. }
  1535. }
  1536. return result;
  1537. }
  1538. /** 可见性判据:优先用原生 isVisibleToUser(),退化为"与屏幕求交" */
  1539. function isVisible(node) {
  1540. try {
  1541. if (typeof node.isVisibleToUser === 'function') {
  1542. return node.isVisibleToUser();
  1543. }
  1544. } catch (ignored) {
  1545. // 落到下面的几何判据
  1546. }
  1547. try {
  1548. if (typeof node.visibleToUser === 'function') {
  1549. return node.visibleToUser();
  1550. }
  1551. } catch (ignoredToo) {
  1552. // 落到下面的几何判据
  1553. }
  1554. return visibleRect(node) !== null;
  1555. }
  1556. /** 与屏幕求交后的可见矩形;完全在屏幕外时返回 null */
  1557. function visibleRect(node) {
  1558. var box = node.bounds();
  1559. var view = screen();
  1560. var left = Math.max(box.left, 0);
  1561. var top = Math.max(box.top, 0);
  1562. var right = Math.min(box.right, view.width);
  1563. var bottom = Math.min(box.bottom, view.height);
  1564. if (right - left <= 0 || bottom - top <= 0) {
  1565. return null;
  1566. }
  1567. return { left: left, top: top, right: right, bottom: bottom };
  1568. }
  1569. /**
  1570. * 可点击坐标 = **可见交集矩形的中心**,再夹到安全区内。
  1571. *
  1572. * 为什么要交集而不是几何中心:被状态栏切掉一半的节点,几何中心可能落在 y=0
  1573. * (点到了状态栏),交集中心才是真正能摸到的地方。
  1574. */
  1575. function center(node) {
  1576. var rect = visibleRect(node);
  1577. var view = screen();
  1578. if (!rect) {
  1579. return null;
  1580. }
  1581. var x = Math.round((rect.left + rect.right) / 2);
  1582. var y = Math.round((rect.top + rect.bottom) / 2);
  1583. return {
  1584. x: Math.min(Math.max(x, SAFE_MARGIN), view.width - SAFE_MARGIN),
  1585. y: Math.min(Math.max(y, SAFE_MARGIN), view.height - SAFE_MARGIN)
  1586. };
  1587. }
  1588. /**
  1589. * 去掉文案里的**不可见格式字符**(零宽空格、LTR/RTL 标记、软连字符等)。
  1590. *
  1591. * 这是真机踩出来的坑:评论列表头部的计数实际文本是 "‎150 comments",
  1592. * 前面挂着一个 U+200E(LEFT-TO-RIGHT MARK)——肉眼完全看不出来,
  1593. * 但 `/^\s*\d+ comments$/` 这种带锚点的正则就是匹配不上,
  1594. * 于是"评论列表确认"这条最强证据永远拿不到,只能退化到弱证据。
  1595. *
  1596. * 放在适配层里统一处理:平台这类噪音只在这里吸掉一次,业务代码就干净了。
  1597. */
  1598. function sanitize(value) {
  1599. if (value === null || value === undefined) {
  1600. return '';
  1601. }
  1602. return String(value)
  1603. .replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u2064\ufeff\u00ad]/g, '')
  1604. .replace(/\s+/g, ' ')
  1605. .trim();
  1606. }
  1607. function text(node) {
  1608. var value = '';
  1609. try {
  1610. value = node.text();
  1611. } catch (ignored) {
  1612. value = '';
  1613. }
  1614. return sanitize(value);
  1615. }
  1616. function desc(node) {
  1617. var value = '';
  1618. try {
  1619. value = node.desc();
  1620. } catch (ignored) {
  1621. value = '';
  1622. }
  1623. return sanitize(value);
  1624. }
  1625. /** 节点的"可读标签":文案优先,没有就看内容描述 */
  1626. function label(node) {
  1627. return text(node) || desc(node);
  1628. }
  1629. function resourceId(node) {
  1630. try {
  1631. var id = node.id();
  1632. return id ? String(id) : '';
  1633. } catch (ignored) {
  1634. return '';
  1635. }
  1636. }
  1637. function shortId(node) {
  1638. var id = resourceId(node);
  1639. var parts = id.split('/');
  1640. return parts.length > 1 ? parts[parts.length - 1] : id;
  1641. }
  1642. function classNameOf(node) {
  1643. try {
  1644. return String(node.className());
  1645. } catch (ignored) {
  1646. return '';
  1647. }
  1648. }
  1649. function shortClass(node) {
  1650. var parts = classNameOf(node).split('.');
  1651. return parts[parts.length - 1];
  1652. }
  1653. function isClickable(node) {
  1654. try {
  1655. return node.clickable() === true;
  1656. } catch (ignored) {
  1657. return false;
  1658. }
  1659. }
  1660. function isScrollable(node) {
  1661. try {
  1662. return node.scrollable() === true;
  1663. } catch (ignored) {
  1664. return false;
  1665. }
  1666. }
  1667. function isEditable(node) {
  1668. return /EditText/i.test(classNameOf(node));
  1669. }
  1670. function parent(node) {
  1671. try {
  1672. return node.parent();
  1673. } catch (ignored) {
  1674. return null;
  1675. }
  1676. }
  1677. /** 从 node 向上找到第一个可点击的祖先(不含自身) */
  1678. function clickableAncestor(node, maxDepth) {
  1679. var depth = 0;
  1680. var limit = maxDepth === undefined ? 8 : maxDepth;
  1681. var cursor = parent(node);
  1682. while (cursor && depth < limit) {
  1683. if (isClickable(cursor)) {
  1684. return cursor;
  1685. }
  1686. cursor = parent(cursor);
  1687. depth += 1;
  1688. }
  1689. return null;
  1690. }
  1691. /** 节点在屏幕上的矩形是否与给定纵向区间有明显重叠 */
  1692. function overlapsBand(node, top, bottom, minRatio) {
  1693. var rect = visibleRect(node);
  1694. if (!rect) {
  1695. return false;
  1696. }
  1697. var overlap = Math.min(rect.bottom, bottom) - Math.max(rect.top, top);
  1698. if (overlap <= 0) {
  1699. return false;
  1700. }
  1701. var ratio = minRatio === undefined ? 0.6 : minRatio;
  1702. return overlap >= (rect.bottom - rect.top) * ratio;
  1703. }
  1704. /** 扁平化渲染,用于把当前界面一行一个控件打进日志(排障时非常有用) */
  1705. function describe(nodes, limit) {
  1706. var lines = [];
  1707. var max = limit === undefined ? nodes.length : Math.min(limit, nodes.length);
  1708. for (var i = 0; i < max; i += 1) {
  1709. var node = nodes[i];
  1710. var point = center(node);
  1711. lines.push([
  1712. shortClass(node),
  1713. shortId(node),
  1714. point ? '(' + point.x + ',' + point.y + ')' : '(offscreen)',
  1715. isClickable(node) ? 'clk' : '-',
  1716. JSON.stringify(label(node)).slice(0, 48)
  1717. ].join(' '));
  1718. }
  1719. return lines.join(' | ');
  1720. }
  1721. /**
  1722. * 页面签名 = 所有可见节点文案/描述的拼接。
  1723. * 用途:判断"页面变没变""是否已经稳定",完全不依赖时间。
  1724. */
  1725. function signature() {
  1726. var nodes = collectAll(1500);
  1727. var parts = [];
  1728. for (var i = 0; i < nodes.length; i += 1) {
  1729. if (!isVisible(nodes[i])) {
  1730. continue;
  1731. }
  1732. var value = label(nodes[i]);
  1733. if (value) {
  1734. parts.push(value);
  1735. }
  1736. }
  1737. return parts.join('\u0001');
  1738. }
  1739. return {
  1740. SAFE_MARGIN: SAFE_MARGIN,
  1741. screen: screen,
  1742. root: root,
  1743. foregroundPackage: foregroundPackage,
  1744. findAll: findAll,
  1745. findOnce: findOnce,
  1746. collectAll: collectAll,
  1747. isVisible: isVisible,
  1748. visibleRect: visibleRect,
  1749. center: center,
  1750. text: text,
  1751. desc: desc,
  1752. label: label,
  1753. sanitize: sanitize,
  1754. resourceId: resourceId,
  1755. shortId: shortId,
  1756. classNameOf: classNameOf,
  1757. shortClass: shortClass,
  1758. isClickable: isClickable,
  1759. isScrollable: isScrollable,
  1760. isEditable: isEditable,
  1761. parent: parent,
  1762. clickableAncestor: clickableAncestor,
  1763. overlapsBand: overlapsBand,
  1764. describe: describe,
  1765. signature: signature
  1766. };
  1767. }());
  1768. /* ============================== src/ui.js ============================== */
  1769. /**
  1770. * ui.js — 通用交互动作
  1771. *
  1772. * 这一层只回答一个问题:"怎么把一次『点击』做成功"。
  1773. * 它不认识 TikTok,也不认识业务流程(那是 tiktok.js / flow.js 的事)——
  1774. * 这是《代码整洁之道》第 3 章「只做一件事」的落地。
  1775. *
  1776. * 点击的四级降级(都走无障碍,**不需要 Root**):
  1777. * 1. node.click()
  1778. * 2. 最近的可点击祖先 click() ← 大多数"点不动"的真凶:文案节点本身不可点
  1779. * 3. 手势 click(可见交集中心)
  1780. * 4. 手势 Tap(可见交集中心)
  1781. * 并且返回"真正点到了谁",让调用方可以核对,而不是默默点错地方。
  1782. *
  1783. * 依赖:TikTokAuto.logger、TikTokAuto.layout、TikTokAuto.wait
  1784. */
  1785. TikTokAuto.ui = (function () {
  1786. 'use strict';
  1787. /**
  1788. * 在坐标上点一下。
  1789. *
  1790. * ★ 用无障碍坐标点击 `click(x, y)`,**不要**用 `gesture()`(2026-10-28 实测)
  1791. *
  1792. * 踩过的弯路,记下来免得再走:
  1793. * 那天排查"点评论按钮没反应",一度以为 TikTok 吞掉了无障碍坐标点击,
  1794. * 于是把这里改成 `gesture(120, [[x,y],[x,y]])` —— 主脚本里探针确实点开了,
  1795. * 但**引擎(`execScriptFile` 起的子脚本)里 gesture 是无效的**:
  1796. * 同坐标下 gesture / click / shell 三种方式全部失败,而 `click` 在主脚本里也有效。
  1797. *
  1798. * 真正的故障其实在别处(键盘没弹 → 输入框停在屏幕底部 → `panelInput()` 判据不命中),
  1799. * 跟点击方式无关。引擎里后来一直用 `click` 打开评论面板,每次都能成功 —— 这就是证据。
  1800. */
  1801. function tapPoint(x, y) {
  1802. click(x, y);
  1803. }
  1804. /**
  1805. * 点击一个节点,逐级降级直到成功。
  1806. * @returns {ok, via, targetCls, targetLabel, point}
  1807. */
  1808. /* ---- 悬浮球避让(规格书 §10.9③)----
  1809. *
  1810. * 全局悬浮球浮在**所有应用之上**(包括 TikTok),所以坐标手势如果正好落在球的
  1811. * 窗口矩形里,点到的是球、不是 TikTok —— 实测就是这样:搜索建议列表的某一条
  1812. * 正好压在球位,`click(x,y)` 把球的扇形面板点了出来(用户看到的是"面板自己弹出来")。
  1813. *
  1814. * 规格书给的缓解办法是「脚本侧点击区域避开球位」,这里就做这一件事:
  1815. * ① 界面把球的窗口矩形写进 ball-rect.json(球的窗口比球大一圈,见 floatball.js);
  1816. * ② 本模块在**走坐标手势之前**读它,命中就把落点挪到同节点内、球区之外的角落;
  1817. * ③ 整块节点都在球区里(挪不开)→ 放弃这次点击并记 WARN,
  1818. * 让日志能解释"这次为什么没点成",而不是默默点到了球上。
  1819. *
  1820. * ⚠️ 注意 `node.click()` 全程不受影响:那是无障碍点击,不产生屏幕坐标,
  1821. * 压根碰不到悬浮窗 —— 所以避让只挂在手势这条兜底路径上。
  1822. */
  1823. var BALL_RECT_PATH = '/sdcard/tiktok_app/ball-rect.json';
  1824. var ballRectCache = { at: 0, rect: null };
  1825. function ballRect() {
  1826. var now = Date.now();
  1827. if (now - ballRectCache.at < 500) { return ballRectCache.rect; }
  1828. ballRectCache.at = now;
  1829. ballRectCache.rect = null;
  1830. try {
  1831. if (!files.exists(BALL_RECT_PATH)) { return null; }
  1832. var raw = JSON.parse(String(files.read(BALL_RECT_PATH)));
  1833. if (!raw || raw.hidden || raw.open) { return null; } /* 隐藏 / 扇形展开时不挡点击判断 */
  1834. if (typeof raw.x !== 'number' || typeof raw.w !== 'number') { return null; }
  1835. ballRectCache.rect = raw;
  1836. } catch (error) {
  1837. /* 读不到就当没有球:宁可偶尔点到球,也不要因为一个坏文件让整个脚本点不动 */
  1838. ballRectCache.rect = null;
  1839. }
  1840. return ballRectCache.rect;
  1841. }
  1842. function inside(rect, x, y) {
  1843. return x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h;
  1844. }
  1845. /**
  1846. * 把落点挪出球区。挪不动返回 null。
  1847. * 候选点是「节点四角内缩」:同一节点内的任何位置对这次点击都等价,
  1848. * 而角落最可能远离屏幕中部的球位。
  1849. */
  1850. function shiftOffBall(point, node, rect) {
  1851. if (!inside(rect, point.x, point.y)) { return point; }
  1852. var layout = TikTokAuto.layout;
  1853. /* 复用 layout.visibleRect:它已经处理了"可见区域/被遮挡"这些边界情况,
  1854. * 自己再写一份 node.bounds() 会与 center() 的口径不一致。 */
  1855. var b = layout.visibleRect(node);
  1856. if (!b) { return null; }
  1857. var pad = 6;
  1858. var cands = [
  1859. { x: b.left + pad, y: b.top + pad },
  1860. { x: b.right - pad, y: b.top + pad },
  1861. { x: b.left + pad, y: b.bottom - pad },
  1862. { x: b.right - pad, y: b.bottom - pad }
  1863. ];
  1864. for (var i = 0; i < cands.length; i++) {
  1865. if (cands[i].x > 0 && cands[i].y > 0 && !inside(rect, cands[i].x, cands[i].y)) {
  1866. return cands[i];
  1867. }
  1868. }
  1869. return null;
  1870. }
  1871. function clickNode(node, step) {
  1872. var story = step || 'click';
  1873. var layout = TikTokAuto.layout;
  1874. var targetClass = layout.shortClass(node);
  1875. var targetLabel = layout.label(node);
  1876. if (node.click()) {
  1877. return finish(story, true, 'node.click()', targetClass, targetLabel, layout.center(node));
  1878. }
  1879. var ancestor = layout.clickableAncestor(node);
  1880. if (ancestor && ancestor.click()) {
  1881. return finish(story, true, 'clickableAncestor.click()', targetClass, targetLabel, layout.center(ancestor));
  1882. }
  1883. var point = layout.center(node);
  1884. if (point) {
  1885. var rect = ballRect();
  1886. if (rect) {
  1887. var safe = shiftOffBall(point, node, rect);
  1888. if (!safe) {
  1889. return finish(story, false, 'blocked-by-floatball', targetClass, targetLabel, point);
  1890. }
  1891. point = safe;
  1892. }
  1893. /* 走 gesture 而不是 click —— 理由见 tapPoint 上面那段(TikTok 吞无障碍坐标点击)。 */
  1894. tapPoint(point.x, point.y);
  1895. return finish(story, true, 'gesture tap(visibleCenter)', targetClass, targetLabel, point);
  1896. }
  1897. return finish(story, false, 'no-clickable-target', targetClass, targetLabel, null);
  1898. }
  1899. function finish(step, ok, via, targetClass, targetLabel, point) {
  1900. var logger = TikTokAuto.logger;
  1901. var where = point ? '(' + point.x + ',' + point.y + ')' : '(n/a)';
  1902. logger.info(step, '点击 ' + (ok ? '成功' : '失败') + ' via=' + via +
  1903. ' target=' + targetClass + ' ' + JSON.stringify(targetLabel).slice(0, 40) + ' ' + where);
  1904. return {
  1905. ok: ok,
  1906. via: via,
  1907. targetClass: targetClass,
  1908. targetLabel: targetLabel,
  1909. point: point
  1910. };
  1911. }
  1912. /**
  1913. * 往一个**已知节点**写入文本,并重新查找节点回读校验。
  1914. *
  1915. * 必须回读:AutoX 的 UiObject 是 find 那一刻的快照,
  1916. * setText 之后立刻 .text() 读到的还是旧值,直接判定"写入失败"是经典误判。
  1917. */
  1918. function typeInto(node, value, step) {
  1919. var logger = TikTokAuto.logger;
  1920. var layout = TikTokAuto.layout;
  1921. var story = step || 'typeInto';
  1922. if (!node) {
  1923. logger.error(story, '输入目标为空');
  1924. return { ok: false, via: 'no-target' };
  1925. }
  1926. /* 先让输入框**真正获得焦点**。
  1927. *
  1928. * ★ 两种手段都上(2026-10-28 实测):无障碍 ACTION_CLICK 对输入框时灵时不灵,
  1929. * 坐标点击在子脚本(引擎)里也可能被 TikTok 吞掉。两个都试、都没有副作用
  1930. * (点输入框只会让它聚焦)。
  1931. *
  1932. * **必须聚焦**,不只是为了输入 —— 输入框不聚焦就不会弹键盘,
  1933. * 而首页信息流的评论面板里,**"发送"按钮只有在键盘弹起时才存在**。
  1934. * 少了这一步,整条评论链路会断在"定位不到发送按钮",而日志完全看不出是这个原因。
  1935. */
  1936. try {
  1937. node.click();
  1938. } catch (ignoredClick) {
  1939. /* 输入框不可点也没关系,下一种手段继续 */
  1940. }
  1941. TikTokAuto.wait.tick(150);
  1942. var point = layout.center(node);
  1943. if (point) {
  1944. tapPoint(point.x, point.y);
  1945. TikTokAuto.wait.tick(250);
  1946. }
  1947. node.setText(value);
  1948. TikTokAuto.wait.tick(300);
  1949. var actual = readEditableText(value);
  1950. var ok = actual === value;
  1951. if (!ok) {
  1952. logger.warn(story, '回读不一致,重试一次。期望=' + JSON.stringify(value) + ' 实际=' + JSON.stringify(actual));
  1953. node.setText(value);
  1954. TikTokAuto.wait.tick(400);
  1955. actual = readEditableText(value);
  1956. ok = actual === value;
  1957. }
  1958. logger.info(story, '写入' + (ok ? '成功' : '失败') + ' 实际=' + JSON.stringify(actual));
  1959. return { ok: ok, via: 'setText+readback', actual: actual };
  1960. }
  1961. /** 便捷版:传选择器,内部自己找节点 */
  1962. function setText(selector, value, step) {
  1963. return typeInto(TikTokAuto.layout.findOnce(selector), value, step);
  1964. }
  1965. /** 回读:取与期望值相等的输入框文本;没有就返回最长的那条(便于排查) */
  1966. function readEditableText(expected) {
  1967. var layout = TikTokAuto.layout;
  1968. var collection = className('android.widget.EditText').find();
  1969. var longest = '';
  1970. for (var i = 0; i < collection.size(); i += 1) {
  1971. var value = layout.text(collection.get(i));
  1972. if (value === expected) {
  1973. return value;
  1974. }
  1975. if (String(value).length > longest.length) {
  1976. longest = value;
  1977. }
  1978. }
  1979. return longest;
  1980. }
  1981. function isEditableText(expected) {
  1982. return readEditableText(expected) === expected;
  1983. }
  1984. /**
  1985. * 滑动。
  1986. * 注意函数名不能叫 `swipe`——那会遮蔽 AutoX 的全局 `swipe()`,导致自我递归。
  1987. */
  1988. function dragScreen(x1, y1, x2, y2, durationMs) {
  1989. swipe(x1, y1, x2, y2, durationMs === undefined ? 320 : durationMs);
  1990. }
  1991. /**
  1992. * 返回上一页。
  1993. * 注意:只在**明确的页面迁移**里调用(如 播放页 → 结果页)。
  1994. * 永远不要把它当成"兜底清理"——那会把正常页面也退掉。
  1995. */
  1996. function goBack(step) {
  1997. TikTokAuto.logger.info(step || 'back', '按下返回');
  1998. back();
  1999. }
  2000. return {
  2001. tapPoint: tapPoint,
  2002. clickNode: clickNode,
  2003. typeInto: typeInto,
  2004. setText: setText,
  2005. readEditableText: readEditableText,
  2006. isEditableText: isEditableText,
  2007. dragScreen: dragScreen,
  2008. goBack: goBack
  2009. };
  2010. }());
  2011. /* ============================== src/popups.js ============================== */
  2012. /**
  2013. * popups.js — 干扰弹窗的「检测 / 执行分离」
  2014. *
  2015. * 依据 AutoX 真机经验(也是《代码整洁之道》第 3 章的"指令查询分离"):
  2016. * - 把「看」和「动」拆成两组函数。guard 线程负责"动",
  2017. * 主流程只用只读的 popupPresent() 去**等**它清干净,绝不抢它的活。
  2018. * - **绝不 back() 正常页面**。登录页/权限页的兜底必须命中"登录方式"这类语境关键词,
  2019. * 否则在「我的」页看到"立即登录"就会把正常页面退掉。
  2020. *
  2021. * 对未知弹窗的策略是**记录而不是猜**:
  2022. * 命中已知签名 → 点掉;看起来像弹窗但不在名单里 → 只记 WARN + dump 界面,
  2023. * 由人来决定要不要加进名单。比"猜着点"安全得多。
  2024. *
  2025. * 依赖:TikTokAuto.logger、TikTokAuto.layout、TikTokAuto.ui、TikTokAuto.wait
  2026. */
  2027. TikTokAuto.popups = (function () {
  2028. 'use strict';
  2029. /**
  2030. * 已知的阻塞型弹窗签名。
  2031. * contains 里任一命中即认为出现;dismiss 决定点哪个按钮。
  2032. */
  2033. var KNOWN_DIALOGS = [
  2034. {
  2035. name: 'save-login-info',
  2036. contains: ['Save login info', 'Save your login info', '记住登录'],
  2037. dismiss: ['Not now', 'Cancel', '稍后', '取消']
  2038. },
  2039. {
  2040. name: 'notifications-permission',
  2041. contains: ['Turn on notifications', 'get notified', '打开通知'],
  2042. dismiss: ['Not now', 'No thanks', 'Skip', '稍后', '暂不']
  2043. },
  2044. {
  2045. name: 'rate-app',
  2046. contains: ['Enjoying TikTok', 'Rate this app', 'rate your experience'],
  2047. dismiss: ['Not now', 'No thanks', 'Later', '稍后']
  2048. },
  2049. {
  2050. name: 'update-app',
  2051. contains: ['Update TikTok', 'A new version'],
  2052. dismiss: ['Not now', 'Later', '稍后']
  2053. },
  2054. {
  2055. name: 'open-in-app',
  2056. contains: ['Open in app', 'Continue in app'],
  2057. dismiss: ['Cancel', 'Not now', '取消']
  2058. }
  2059. ];
  2060. /** 只读:扫描当前应用窗口,返回命中的已知弹窗名 + 疑似未知弹窗 */
  2061. function scan() {
  2062. var layout = TikTokAuto.layout;
  2063. var visibleLabels = collectVisibleLabels();
  2064. var joined = visibleLabels.join(' \u0001 ');
  2065. var hits = [];
  2066. for (var i = 0; i < KNOWN_DIALOGS.length; i += 1) {
  2067. var dialog = KNOWN_DIALOGS[i];
  2068. if (containsAny(joined, dialog.contains)) {
  2069. hits.push(dialog);
  2070. }
  2071. }
  2072. return {
  2073. known: hits,
  2074. looksBlocking: hits.length > 0 || looksLikeUnknownModal(visibleLabels)
  2075. };
  2076. }
  2077. function containsAny(haystack, needles) {
  2078. for (var i = 0; i < needles.length; i += 1) {
  2079. if (haystack.indexOf(needles[i]) !== -1) {
  2080. return true;
  2081. }
  2082. }
  2083. return false;
  2084. }
  2085. function collectVisibleLabels() {
  2086. var layout = TikTokAuto.layout;
  2087. var nodes = layout.collectAll(1200);
  2088. var labels = [];
  2089. for (var i = 0; i < nodes.length; i += 1) {
  2090. if (!layout.isVisible(nodes[i])) {
  2091. continue;
  2092. }
  2093. var value = layout.label(nodes[i]);
  2094. if (value) {
  2095. labels.push(value);
  2096. }
  2097. }
  2098. return labels;
  2099. }
  2100. /**
  2101. * "看起来像弹窗"的启发式判据(仅用于告警,不驱动动作):
  2102. * 可见文案很少(弹窗通常只有标题 + 一两个按钮),却又出现了否定的动作词。
  2103. * 正常页面(信息流/结果页)动辄几十条文案,不会命中。
  2104. */
  2105. function looksLikeUnknownModal(labels) {
  2106. var dismissLabels = TikTokAuto.config
  2107. ? TikTokAuto.config.DEFAULTS.guard.dismissLabels
  2108. : ['Not now', 'Cancel', '稍后', '取消'];
  2109. if (labels.length > 8) {
  2110. return false;
  2111. }
  2112. return containsAny(labels.join('\u0001'), dismissLabels);
  2113. }
  2114. /** 只读:是否还有阻塞弹窗(主流程用它"等"guard 清场) */
  2115. function popupPresent() {
  2116. return scan().looksBlocking;
  2117. }
  2118. /** 执行:尝试点掉一个已知弹窗。返回是否真的动了手 */
  2119. function dismissKnown(step) {
  2120. var logger = TikTokAuto.logger;
  2121. var layout = TikTokAuto.layout;
  2122. var found = scan();
  2123. if (found.known.length === 0) {
  2124. return false;
  2125. }
  2126. for (var i = 0; i < found.known.length; i += 1) {
  2127. var dialog = found.known[i];
  2128. for (var j = 0; j < dialog.dismiss.length; j += 1) {
  2129. var button = findExactLabel(dialog.dismiss[j]);
  2130. if (button) {
  2131. logger.info(step || 'popup', '关闭已知弹窗 ' + dialog.name + ' → 点击 ' + JSON.stringify(dialog.dismiss[j]));
  2132. TikTokAuto.ui.clickNode(button, step || 'popup');
  2133. TikTokAuto.wait.tick(400);
  2134. return true;
  2135. }
  2136. }
  2137. logger.warn(step || 'popup', '命中弹窗 ' + dialog.name + ' 但找不到关闭按钮,跳过');
  2138. }
  2139. return false;
  2140. }
  2141. /** 按精确文案/描述找可见节点;文案优先于描述 */
  2142. function findExactLabel(value) {
  2143. var layout = TikTokAuto.layout;
  2144. var nodes = layout.collectAll(1200);
  2145. for (var i = 0; i < nodes.length; i += 1) {
  2146. if (layout.text(nodes[i]) === value && layout.isVisible(nodes[i])) {
  2147. return nodes[i];
  2148. }
  2149. }
  2150. for (var j = 0; j < nodes.length; j += 1) {
  2151. if (layout.desc(nodes[j]) === value && layout.isVisible(nodes[j])) {
  2152. return nodes[j];
  2153. }
  2154. }
  2155. return null;
  2156. }
  2157. /**
  2158. * 启动守护线程:周期性清掉已知弹窗。
  2159. *
  2160. * 返回一个句柄,其中 passes 计数让主流程确认"它已经上岗了"——
  2161. * 否则主流程可能在守护线程第一次巡检之前就撞上弹窗。
  2162. */
  2163. function startGuard(config, logger) {
  2164. var state = { passes: 0, dismissed: 0, stopped: false };
  2165. if (!config.guard.enabled) {
  2166. logger.info('guard', '守护线程已通过配置关闭');
  2167. state.passes = 1;
  2168. return state;
  2169. }
  2170. var thread = threads.start(function () {
  2171. while (!state.stopped) {
  2172. try {
  2173. if (dismissKnown('guard')) {
  2174. state.dismissed += 1;
  2175. }
  2176. state.passes += 1;
  2177. } catch (error) {
  2178. /* 用户叫停时会从 wait.tick 抛出 StopSignal。守护线程不该继续巡检,
  2179. * 也不该每一轮都记一条 WARN 把日志刷满 —— 直接收工。 */
  2180. if (TikTokAuto.bridge && TikTokAuto.bridge.isStopSignal(error)) {
  2181. logger.info('guard', '收到停止请求,守护线程收工');
  2182. break;
  2183. }
  2184. logger.warn('guard', '巡检异常:' + error);
  2185. }
  2186. sleep(config.guard.intervalMs);
  2187. }
  2188. });
  2189. state.thread = thread;
  2190. logger.info('guard', '守护线程已启动,间隔 ' + config.guard.intervalMs + 'ms');
  2191. return state;
  2192. }
  2193. function stopGuard(state, logger) {
  2194. if (!state) {
  2195. return;
  2196. }
  2197. state.stopped = true;
  2198. logger.info('guard', '守护线程已停止,累计清场 ' + state.dismissed + ' 次');
  2199. }
  2200. return {
  2201. KNOWN_DIALOGS: KNOWN_DIALOGS,
  2202. scan: scan,
  2203. popupPresent: popupPresent,
  2204. dismissKnown: dismissKnown,
  2205. findExactLabel: findExactLabel,
  2206. startGuard: startGuard,
  2207. stopGuard: stopGuard
  2208. };
  2209. }());
  2210. /* ============================== src/tiktok.js ============================== */
  2211. /**
  2212. * tiktok.js — TikTok 的应用知识(选择器 + 页面状态判定)
  2213. *
  2214. * 这个文件是**唯一**允许出现"TikTok 具体长什么样"的地方。
  2215. * 换 App、或 TikTok 改版时,只动这里,flow.js 一行都不用改。
  2216. *
  2217. * 所有选择器都基于**真机实测**的锚点,并且在实测基础上刻意选了"语义锚点"而不是
  2218. * 混淆过的资源 id——TikTok 的 id 形如 `e4g`/`cn6`/`e8x`,每次改版都会重排;
  2219. * 而 "Read or add comments" 这类无障碍文案相当稳定。
  2220. * 少数确实只能靠 id 的地方(发送按钮)走 config.selectors 覆盖,便于不改代码修。
  2221. *
  2222. * 实测锚点(1080x1920,com.zhiliaoapp.musically):
  2223. * 信息流搜索入口 ImageView desc="Search" [968,50,1080,162]
  2224. * 搜索页输入框 EditText (top < 12%H)
  2225. * 搜索页提交 Button text="Search" (left > 80%W)
  2226. * 结果页分类页签 text/desc ∈ {Top,Users,Videos,...} y ≈ 152..232
  2227. * 结果页卡片 Button(卡片文案) width ≥ 35%W, top > 30%H
  2228. * 播放页 "Read or add comments" + "Like video." 同时存在
  2229. * 播放页评论入口 Button desc="Read or add comments. N comments"
  2230. * 播放页内联输入 EditText desc/text="Add comment..."
  2231. * 评论面板输入 EditText top ∈ [50%H,80%H]
  2232. * 面板工具行 desc="Stickers" / "Mention someone" / "Microphone"
  2233. * 面板发送按钮 工具行同高、最右、且 right ≤ W-8 的可点击 Button(实测 id=cn6)
  2234. *
  2235. * 依赖:TikTokAuto.logger、TikTokAuto.layout
  2236. */
  2237. TikTokAuto.tiktok = (function () {
  2238. 'use strict';
  2239. var PKG = 'com.zhiliaoapp.musically';
  2240. var COMMENT_DESC_KEYS = ['Read or add comments', '评论'];
  2241. var LIKE_DESC_KEYS = ['Like video.', '点赞'];
  2242. /** 已点赞后同一个按钮的 desc(实测是 `Video liked`,不是 `Unlike`) */
  2243. var LIKED_DESC_KEYS = ['Video liked', '已点赞', 'Unlike'];
  2244. /** 关注按钮 desc 前缀(后面跟作者名)。`Followers` 会被 followButton 里单独排除。 */
  2245. var FOLLOW_DESC_KEYS = ['Follow ', '关注'];
  2246. var COMMENT_PLACEHOLDER = ['Add comment...', '添加评论', 'Add comment'];
  2247. var TOOL_ROW_ANCHORS = ['Stickers', 'Mention someone', 'Microphone', '表情', '提及', '语音'];
  2248. /**
  2249. * 右侧竖排动作栏的 desc 前缀(作者 / 点赞 / 评论 / 收藏 / 分享 / 音乐)。
  2250. * 用于把"和输入框同高的动作栏按钮"从发送键候选里排掉 —— 详见 isActionBarButton。
  2251. */
  2252. var ACTION_BAR_DESC_PREFIXES = [
  2253. 'Follow', 'Following', 'Unfollow',
  2254. 'Like video', 'Video liked',
  2255. 'Read or add comments', 'Add or remove this video',
  2256. 'Share video', 'Sound:'
  2257. ];
  2258. var RESULT_TABS = ['Top', 'Users', 'Videos', 'Sounds', 'Shop', 'LIVE', 'Places', 'Photos'];
  2259. /**
  2260. * 结果页卡片搜索区的上边界(占屏高比例)。
  2261. *
  2262. * 实测布局:搜索框 → 页签栏 y 152~232 → GridView 从 y=232 开始(屏高 1920)。
  2263. * 取 0.13(=250)刚好卡在网格起始线下方,把搜索框和页签排掉。
  2264. * 详见 videoCards() 的注释(用 0.3 会漏掉滚动后的上排卡片)。
  2265. */
  2266. var ROWS_TOP_RATIO = 0.13;
  2267. var layout = TikTokAuto.layout; // 常量引用,模块加载顺序由 build.js 保证
  2268. var logger = TikTokAuto.logger;
  2269. function screenWidth() { return device.width; }
  2270. function screenHeight() { return device.height; }
  2271. function startsWithAny(value, keys) {
  2272. for (var i = 0; i < keys.length; i += 1) {
  2273. if (value.indexOf(keys[i]) === 0) {
  2274. return true;
  2275. }
  2276. }
  2277. return false;
  2278. }
  2279. function equalsAny(value, keys) {
  2280. for (var i = 0; i < keys.length; i += 1) {
  2281. if (value === keys[i]) {
  2282. return true;
  2283. }
  2284. }
  2285. return false;
  2286. }
  2287. function widthOf(node) {
  2288. var box = node.bounds();
  2289. return box.right - box.left;
  2290. }
  2291. function topOf(node) {
  2292. return node.bounds().top;
  2293. }
  2294. function bottomOf(node) {
  2295. return node.bounds().bottom;
  2296. }
  2297. /** 按"文案或描述命中"收集可见节点 */
  2298. function findByLabel(selector, predicate) {
  2299. var result = [];
  2300. var collection = selector.find();
  2301. for (var i = 0; i < collection.size(); i += 1) {
  2302. var node = collection.get(i);
  2303. if (predicate(node) && layout.isVisible(node)) {
  2304. result.push(node);
  2305. }
  2306. }
  2307. return result;
  2308. }
  2309. // ---------------------------------------------------------------- 信息流
  2310. /** 信息流右上角的搜索入口 */
  2311. function searchEntry() {
  2312. var hits = findByLabel(className('android.widget.ImageView'), function (node) {
  2313. var point = layout.center(node);
  2314. return layout.desc(node) === 'Search' && point !== null &&
  2315. point.y < screenHeight() * 0.12 && point.x > screenWidth() * 0.85;
  2316. });
  2317. return hits.length > 0 ? hits[0] : null;
  2318. }
  2319. /** 信息流就绪判据:底部导航存在 */
  2320. function bottomNavPresent() {
  2321. return layout.findOnce(desc('Home')) !== null && layout.findOnce(desc('Profile')) !== null;
  2322. }
  2323. /**
  2324. * 底部导航的「Profile」项 —— 用来打开**自己的**主页(脚本二要自己的粉丝列表)。
  2325. *
  2326. * 判据刻意很松:`desc === 'Profile'` 精确匹配 + 中心落在屏幕下缘。
  2327. * 不限定 class(它是 FrameLayout,和历史记忆里"首页顶部 Following 页签是
  2328. * FrameLayout"同一族,限定 Button 会一个都找不到),也不认文案 ——
  2329. * 底部导航这一项没有 text。
  2330. *
  2331. * 为什么还要判"在下缘":右侧竖排作者头像的 desc 是 `xxx profile`(小写、
  2332. * 带用户名前缀),精确匹配 `Profile` 本来就排除掉了;但万一新版把它改成
  2333. * 纯 `Profile`,这一条能挡住"点到了头像"这种最坏情况。
  2334. */
  2335. function profileNav() {
  2336. var node = layout.findOnce(desc('Profile'));
  2337. if (!node) {
  2338. return null;
  2339. }
  2340. var point = layout.center(node);
  2341. if (!point || point.y < screenHeight() * 0.88) {
  2342. return null;
  2343. }
  2344. return node;
  2345. }
  2346. // ---------------------------------------------------------------- 搜索页
  2347. /** 顶部搜索输入框(排除评论输入框:后者在屏幕下方) */
  2348. function searchInput() {
  2349. var hits = findByLabel(className('android.widget.EditText'), function (node) {
  2350. return topOf(node) < screenHeight() * 0.12;
  2351. });
  2352. return hits.length > 0 ? hits[0] : null;
  2353. }
  2354. function searchSubmit() {
  2355. var hits = findByLabel(className('android.widget.Button'), function (node) {
  2356. var point = layout.center(node);
  2357. return layout.text(node) === 'Search' && point !== null &&
  2358. point.y < screenHeight() * 0.12 && point.x > screenWidth() * 0.8;
  2359. });
  2360. return hits.length > 0 ? hits[0] : null;
  2361. }
  2362. /** 搜索页特征:搜索历史行(带删除按钮)或"发现"按钮 */
  2363. function searchHistoryPresent() {
  2364. return layout.findOnce(desc('Close')) !== null || layout.findOnce(desc('Find')) !== null;
  2365. }
  2366. // ---------------------------------------------------------------- 结果页
  2367. /** 结果页顶部分类页签 */
  2368. function resultTab(name) {
  2369. var hits = findByLabel(className('android.widget.TextView'), function (node) {
  2370. return equalsAny(layout.text(node) || layout.desc(node), [name]) &&
  2371. topOf(node) > screenHeight() * 0.06 && topOf(node) < screenHeight() * 0.16;
  2372. });
  2373. if (hits.length > 0) {
  2374. return hits[0];
  2375. }
  2376. // 页签的文案节点本身常常不可点,真正可点的是外层 FrameLayout
  2377. var frames = findByLabel(className('android.widget.FrameLayout'), function (node) {
  2378. return equalsAny(layout.desc(node) || layout.text(node), [name]) &&
  2379. topOf(node) > screenHeight() * 0.06 && topOf(node) < screenHeight() * 0.16;
  2380. });
  2381. return frames.length > 0 ? frames[0] : null;
  2382. }
  2383. function resultTabStripPresent() {
  2384. var found = 0;
  2385. for (var i = 0; i < RESULT_TABS.length; i += 1) {
  2386. if (resultTab(RESULT_TABS[i])) {
  2387. found += 1;
  2388. }
  2389. }
  2390. return found >= 3;
  2391. }
  2392. /**
  2393. * 从卡片文案锚点向上找"卡片容器"。
  2394. * 为什么不能直接点文案:文案节点多数不可点,而它的可点祖先可能是**整屏容器**——
  2395. * 点下去等于点在屏幕中间,什么也不会发生(实测踩过)。
  2396. * 因此要求容器高度落在合理的卡片区间内,否则认为锚点自己就是最佳目标。
  2397. */
  2398. function cardContainer(anchor) {
  2399. var height = screenHeight();
  2400. var cursor = layout.clickableAncestor(anchor);
  2401. var depth = 0;
  2402. while (cursor && depth < 6) {
  2403. var cardHeight = bottomOf(cursor) - topOf(cursor);
  2404. /* 上限从 0.8 放宽到 1.05(2026-10-28):卡片滚动到贴着屏幕上下边缘时,
  2405. * 容器会有一部分在屏幕外,`bounds()` 报的高度于是**被裁切得又大又怪**,
  2406. * 实测超过 80% 后被这里挡掉 → 整排卡片变成"找不到容器" → 上层误判
  2407. * "卡片用尽"。点击点由 `layout.center` 用 visibleRect 夹取,仍然安全。 */
  2408. if (cardHeight >= height * 0.18 && cardHeight <= height * 1.05) {
  2409. return cursor;
  2410. }
  2411. cursor = layout.clickableAncestor(cursor);
  2412. depth += 1;
  2413. }
  2414. return anchor;
  2415. }
  2416. /**
  2417. * 这个节点是不是在"结果网格"里(GridView / RecyclerView 的子孙)。
  2418. *
  2419. * 为什么需要:结果页上会浮出各种**不是视频卡但同样又宽又有文案**的东西 ——
  2420. * 授权弹窗的 `Customize Settings` / `Confirm My Choices`、商品卡的 `Shop now`、
  2421. * 「Users」分区的表头。它们能通过"宽 + 有文案"的判据,被当成卡片点开白等 20 秒。
  2422. * 而真正的结果卡片一定在滚动容器里,弹窗不出现在其中。
  2423. */
  2424. function insideResultGrid(node) {
  2425. var cursor = node;
  2426. for (var depth = 0; depth < 8; depth += 1) {
  2427. try {
  2428. cursor = cursor.parent();
  2429. } catch (ignored) {
  2430. return false;
  2431. }
  2432. if (!cursor) {
  2433. return false;
  2434. }
  2435. var name = '';
  2436. try {
  2437. name = String(cursor.className() || '');
  2438. } catch (ignoredToo) {
  2439. name = '';
  2440. }
  2441. if (name.indexOf('GridView') >= 0 || name.indexOf('RecyclerView') >= 0) {
  2442. return true;
  2443. }
  2444. }
  2445. return false;
  2446. }
  2447. /**
  2448. * 当前页面是"照片轮播帖"吗(不是视频)?
  2449. *
  2450. * 真机实测(2026-10-28):照片帖的信息行是「`Photo` · `1w ago`」两个独立 TextView,
  2451. * 视频帖是「`Video` · …」。照片帖**没有播放页**,`onPlayerPage()` 也认不出来,
  2452. * 于是 openCard 会白等满 20 秒(playerReadyMs)再失败。
  2453. *
  2454. * 判据限定文案**完全等于** `Photo` 且位于屏幕下半部分(信息行),
  2455. * 避免误伤说明文字里出现 Photo 的普通视频。
  2456. */
  2457. function photoPost() {
  2458. var height = screenHeight();
  2459. var hits = findByLabel(className('android.widget.TextView'), function (node) {
  2460. return (layout.text(node) || '') === 'Photo' && topOf(node) > height * 0.5;
  2461. });
  2462. return hits.length > 0 ? hits[0] : null;
  2463. }
  2464. /**
  2465. * 结果网格里当前可见的卡片(按屏幕从上到下)。
  2466. *
  2467. * 判据(2026-10-28 用 AutoX 无障碍树真机核对过):
  2468. * - 卡片文案是一条"宽"控件(≥35%W),据此把右侧竖排的点赞/评论/分享按钮排掉;
  2469. * - **必须在 GridView/RecyclerView 里**(排掉浮层/弹窗按钮,如 `Customize Settings`);
  2470. * - **必须能找到一个"卡片尺寸"的可点容器**(`cardContainer` 找不到时会退回节点
  2471. * 本身,那种情况说明它只是一个孤立按钮,不是卡片);
  2472. * - 可见性看**容器**,不看文案。
  2473. *
  2474. * ★ 为什么不再要求"文案在屏幕内"(这次最关键的修正):
  2475. * 卡片的文案 Button 在**卡片底部**,而卡片很高(实测一张 8,1242 → 536,1920+)。
  2476. * 结果页上只要多一条通栏广告或一行筛选 chips,网格就被推下去,文案跟着落到
  2477. * **屏幕外**(实测 y=2099 > 1920)。旧的 `bottomOf(node) <= height - 12` 于是把这些
  2478. * **明明看得见、点得着**的卡片全部丢掉 —— 表现成"只处理了 1 个就说卡片用尽"。
  2479. *
  2480. * 正确做法:文案照读(无障碍树里它在,只是不在可视区),能不能点由**容器**决定;
  2481. * 点击点由 `layout.center` 用 `visibleRect` 夹取,永远落在屏幕内。
  2482. */
  2483. function videoCards() {
  2484. var width = screenWidth();
  2485. var height = screenHeight();
  2486. var gridTop = height * ROWS_TOP_RATIO; /* 页签栏底部,网格从这里开始 */
  2487. var minVisible = height * 0.08; /* 露出来的太少就不点(点下去容易点到邻居) */
  2488. var cards = [];
  2489. var seen = {};
  2490. var hits = findByLabel(className('android.widget.Button'), function (node) {
  2491. return widthOf(node) >= width * 0.35 && insideResultGrid(node);
  2492. });
  2493. for (var i = 0; i < hits.length; i += 1) {
  2494. var caption = layout.text(hits[i]) || layout.desc(hits[i]);
  2495. if (!caption) {
  2496. continue;
  2497. }
  2498. var container = cardContainer(hits[i]);
  2499. if (!container || container === hits[i]) {
  2500. /* 找不到卡片尺寸的可点容器 → 这是个孤立按钮(弹窗按钮、分区表头等) */
  2501. continue;
  2502. }
  2503. var boxTop = topOf(container);
  2504. var boxBottom = bottomOf(container);
  2505. if (boxBottom <= gridTop) {
  2506. /* 整个容器都在页签栏之上 —— 不是结果卡片 */
  2507. continue;
  2508. }
  2509. if (boxTop >= height) {
  2510. /* 完全在屏幕下方,看不见也点不着 */
  2511. continue;
  2512. }
  2513. if (Math.min(boxBottom, height) - Math.max(boxTop, gridTop) < minVisible) {
  2514. /* 只露出一条缝,点下去很容易落到相邻卡片上 */
  2515. continue;
  2516. }
  2517. var key = caption + '@' + boxTop;
  2518. if (seen[key]) {
  2519. continue;
  2520. }
  2521. seen[key] = true;
  2522. cards.push({
  2523. anchor: hits[i],
  2524. container: container,
  2525. caption: caption,
  2526. top: boxTop
  2527. });
  2528. }
  2529. cards.sort(function (a, b) { return a.top - b.top; });
  2530. return cards;
  2531. }
  2532. // ---------------------------------------------------------------- 播放页
  2533. function commentButton() {
  2534. var hits = findByLabel(className('android.widget.Button'), function (node) {
  2535. return startsWithAny(layout.desc(node), COMMENT_DESC_KEYS);
  2536. });
  2537. return hits.length > 0 ? hits[0] : null;
  2538. }
  2539. function likeButton() {
  2540. var hits = findByLabel(className('android.widget.Button'), function (node) {
  2541. return startsWithAny(layout.desc(node), LIKE_DESC_KEYS);
  2542. });
  2543. return hits.length > 0 ? hits[0] : null;
  2544. }
  2545. /**
  2546. * 「已点赞」状态的点赞按钮。
  2547. *
  2548. * ★ 实测(2026-10-28,TikTok 47.1.4):点过赞之后同一个 `Button` 的 desc
  2549. * 从 `Like video. 2.4M likes` 变成 **`Video liked`**(不是 `Unlike`),
  2550. * 而里面那个 ImageView 的 `selected` 从 false 变成 true。
  2551. *
  2552. * 两个判据都要留:desc 受本地化影响,selected 是状态位不受文案影响 ——
  2553. * 任一命中就算已赞,比只认一个更耐改版。
  2554. */
  2555. function likedButton() {
  2556. var hits = findByLabel(className('android.widget.Button'), function (node) {
  2557. return startsWithAny(layout.desc(node), LIKED_DESC_KEYS);
  2558. });
  2559. return hits.length > 0 ? hits[0] : null;
  2560. }
  2561. /**
  2562. * 点赞按钮里的图标节点(用它读 `selected`)。
  2563. * 实测:未赞 selected=false,已赞 selected=true。
  2564. */
  2565. function likeIcon() {
  2566. var button = likeButton() || likedButton();
  2567. if (!button) {
  2568. return null;
  2569. }
  2570. var icons = button.find(className('android.widget.ImageView'));
  2571. return icons.size() > 0 ? icons.get(0) : null;
  2572. }
  2573. /** 'liked' | 'unliked' | 'unknown' */
  2574. function likeState() {
  2575. if (likedButton() !== null) {
  2576. return 'liked';
  2577. }
  2578. if (likeButton() !== null) {
  2579. return 'unliked';
  2580. }
  2581. /* 按钮本身找不到时,退回看图标状态位(有些版本用同一个 desc,只翻 selected) */
  2582. var icon = null;
  2583. try { icon = likeIcon(); } catch (ignored) { icon = null; }
  2584. if (icon !== null && icon.selected()) {
  2585. return 'liked';
  2586. }
  2587. return 'unknown';
  2588. }
  2589. /**
  2590. * 播放页的「关注作者」按钮。
  2591. *
  2592. * 实测 desc 形如 `Follow Marcgagilang`(`Follow` + 空格 + 作者名),
  2593. * 是 `android.widget.Button`。
  2594. * ⚠️ 首页顶部那个 `Following` 页签是 **FrameLayout**,不是 Button,
  2595. * 所以限定 className 就能把它排除掉,不必额外按坐标过滤。
  2596. */
  2597. function followButton() {
  2598. var hits = findByLabel(className('android.widget.Button'), function (node) {
  2599. var value = layout.desc(node);
  2600. if (!startsWithAny(value, FOLLOW_DESC_KEYS)) {
  2601. return false;
  2602. }
  2603. /* `Followers` 是粉丝列表的入口,不是关注按钮 —— 前缀匹配会误命中 */
  2604. return value.indexOf('Followers') !== 0;
  2605. });
  2606. return hits.length > 0 ? hits[0] : null;
  2607. }
  2608. /**
  2609. * 作者头像节点。用来区分「已关注(按钮消失)」和「页面还没渲染」。
  2610. * 实测 desc 形如 `Marcgagilang profile`。
  2611. */
  2612. function authorAvatar() {
  2613. var hits = findByLabel(className('android.widget.ImageView'), function (node) {
  2614. var value = layout.desc(node);
  2615. return value !== '' && / profile$/i.test(value);
  2616. });
  2617. return hits.length > 0 ? hits[0] : null;
  2618. }
  2619. /**
  2620. * 信息流当前视频的指纹 —— **只用来判断"上滑有没有真的换视频"**。
  2621. *
  2622. * ★ 它**不能**当业务上的"视频身份"用(判断是不是同一个视频):
  2623. * 点赞之后 `Like video. 340.2K likes` 会变成 `Video liked`,指纹必然变化。
  2624. * 所以它天生只适合回答"变了没有",不适合回答"这是不是同一个"。
  2625. *
  2626. * 「会不会重复操作同一个视频」不靠指纹,靠状态:
  2627. * · 已经点过赞 → `likeState() === 'liked'` → interact.like 自己会 skip
  2628. * · 已经关注 → `followState() === 'following'` → interact.follow 自己会 skip
  2629. * 那两条是地面真相,比任何拼出来的指纹都可靠。
  2630. *
  2631. * 信息流的树里**同时存在上/下两个视频**的节点(实测:上一个在 y=-702、
  2632. * 当前在 y=1149、下一个在 y=3069),所以这里只能用已经过 `isVisible` 的
  2633. * likeButton/authorAvatar/commentButton —— 它们只返回当前那个视频的节点。
  2634. */
  2635. function feedSignature() {
  2636. var parts = [];
  2637. var avatar = authorAvatar();
  2638. if (avatar) {
  2639. parts.push('a:' + layout.desc(avatar));
  2640. }
  2641. var like = likeButton() || likedButton();
  2642. if (like) {
  2643. parts.push('l:' + layout.desc(like));
  2644. }
  2645. var comment = commentButton();
  2646. if (comment) {
  2647. parts.push('c:' + layout.desc(comment));
  2648. }
  2649. return parts.join('|');
  2650. }
  2651. /**
  2652. * 'following' | 'not-following' | 'unknown'
  2653. *
  2654. * ★ 「已关注」的唯一表现是**按钮整个消失**(实测),所以不能只看"找不到按钮"
  2655. * —— 页面没渲染完也找不到。用作者头像是否存在来区分这两种情况:
  2656. * 头像在、按钮不在 = 已关注;两个都不在 = 页面还没好。
  2657. */
  2658. function followState() {
  2659. if (followButton() !== null) {
  2660. return 'not-following';
  2661. }
  2662. if (authorAvatar() !== null) {
  2663. return 'following';
  2664. }
  2665. return 'unknown';
  2666. }
  2667. /**
  2668. * 播放页判据。
  2669. *
  2670. * 教训(实测踩到):**信息流首页也有 "Like video." 和 "Read or add comments" 这一竖排**,
  2671. * 所以只看这两个会一路把首页判成播放页,整个流程直接跑歪。
  2672. * 真正能区分"从搜索结果点进来的播放页"的是:
  2673. * - 左上角有返回箭头(首页没有)
  2674. * - 没有底部导航栏(首页有 Home/Friends/Inbox/Profile)
  2675. * 四条同时成立才算播放页。
  2676. */
  2677. function onPlayerPage() {
  2678. return commentButton() !== null &&
  2679. likeButton() !== null &&
  2680. backButton() !== null &&
  2681. !bottomNavPresent();
  2682. }
  2683. function backButton() {
  2684. var hits = findByLabel(className('android.widget.ImageView'), function (node) {
  2685. return layout.desc(node) === 'Back';
  2686. });
  2687. return hits.length > 0 ? hits[0] : null;
  2688. }
  2689. /** 从 "Read or add comments. 150 comments" 里把 150 抠出来 */
  2690. function commentCount() {
  2691. var button = commentButton();
  2692. if (!button) {
  2693. return null;
  2694. }
  2695. var matched = layout.desc(button).match(/([\d][\d,.\s]*)\s*(?:comments?|条评论|评论)/i);
  2696. if (!matched) {
  2697. return null;
  2698. }
  2699. return parseInt(String(matched[1]).replace(/[^\d]/g, ''), 10);
  2700. }
  2701. /** 视频页底部的内联评论输入框(面板未打开时就是它) */
  2702. function inlineInput() {
  2703. var hits = findByLabel(className('android.widget.EditText'), function (node) {
  2704. return equalsAny(layout.text(node) || layout.desc(node), COMMENT_PLACEHOLDER) &&
  2705. topOf(node) > screenHeight() * 0.8;
  2706. });
  2707. return hits.length > 0 ? hits[0] : null;
  2708. }
  2709. /** 评论面板里的输入框(打开面板后会出现在屏幕中部) */
  2710. function panelInput() {
  2711. var screenH = screenHeight();
  2712. /* ① 经典位置:键盘弹起之后,输入框会被顶到屏幕中部。 */
  2713. var mid = findByLabel(className('android.widget.EditText'), function (node) {
  2714. var top = topOf(node);
  2715. return top > screenH * 0.45 && top < screenH * 0.85;
  2716. });
  2717. if (mid.length > 0) {
  2718. return mid[0];
  2719. }
  2720. /* ② 键盘**没弹起**时,输入框停在屏幕底部(实测 y=1816 @1080×1920,即 0.95H),
  2721. * 完全落不进上面那个区间。
  2722. *
  2723. * 这是首页信息流里特有的情形:点右侧评论按钮打开面板,输入框不会自动获得
  2724. * 焦点、键盘也就不弹;而播放页里点的是内联输入框,它天生带焦点、键盘必弹。
  2725. * 所以只按位置判的话,**信息流里每一次都会判成"面板没打开"** ——
  2726. * 而面板明明已经开了,输入框就在底部等着被点。
  2727. *
  2728. * 这时只能靠"评论区头部计数"来确认面板确实开着(那是面板独有的元素),
  2729. * 再把 EditText 认成输入框。视频链路上这条分支不会命中(① 已经返回了)。
  2730. */
  2731. if (listCommentCount() !== null) {
  2732. var any = findByLabel(className('android.widget.EditText'), function () {
  2733. return true;
  2734. });
  2735. if (any.length > 0) {
  2736. return any[0];
  2737. }
  2738. }
  2739. return null;
  2740. }
  2741. function toolRowAnchor() {
  2742. for (var i = 0; i < TOOL_ROW_ANCHORS.length; i += 1) {
  2743. var node = layout.findOnce(desc(TOOL_ROW_ANCHORS[i]));
  2744. if (node && layout.isVisible(node)) {
  2745. return node;
  2746. }
  2747. }
  2748. return null;
  2749. }
  2750. /**
  2751. * 评论发送按钮。
  2752. *
  2753. * 它没有可用的文案(实测其 content-desc 是一个无关的账号串),图标是粉色箭头,
  2754. * 所以只能靠**结构**定位:
  2755. * 面板里"工具行"(表情/提及/语音那一行)的同高区域里,
  2756. * 最靠右、且不属于右侧竖排动作栏(动作栏 right == 屏宽)的那个可点击按钮。
  2757. *
  2758. * 优先用 config.selectors.sendButtonId(若配置里给了 id),
  2759. * 这样 TikTok 改版时改配置即可,不用改代码。
  2760. */
  2761. /**
  2762. * 这个节点是不是右侧竖排动作栏(点赞/评论/收藏/分享/作者/音乐)里的按钮。
  2763. *
  2764. * ★ 为什么需要它(2026-10-28):评论面板一打开,输入框会上移到屏幕中部,
  2765. * 正好和那六个按钮**同高**,光靠坐标完全分不开 —— 实测按"输入框右端"
  2766. * 找到的是 `Follow xxx` 和 `Like video.`,点下去是关注/点赞,评论根本没发。
  2767. *
  2768. * 而这六个按钮每一个都有明确的 desc 前缀,发送键没有。这是唯一稳定的分界。
  2769. */
  2770. function isActionBarButton(node) {
  2771. var value = layout.desc(node);
  2772. if (!value) {
  2773. return false;
  2774. }
  2775. for (var i = 0; i < ACTION_BAR_DESC_PREFIXES.length; i += 1) {
  2776. if (value.indexOf(ACTION_BAR_DESC_PREFIXES[i]) === 0) {
  2777. return true;
  2778. }
  2779. }
  2780. return false;
  2781. }
  2782. /**
  2783. * 按"输入框那一行"找发送按钮。
  2784. *
  2785. * 判据只有两条,都落在几何事实上:
  2786. * ① 节点的**中心 y** 落在输入框上下边界之间(= 同一行)
  2787. * ② 节点的**右边界**贴着输入框右端(或更右)
  2788. *
  2789. * ★ 为什么不能用"输入框下方那一行"(2026-10-28 血泪):
  2790. * 面板打开后,**播放页那套右侧竖排动作栏(点赞/评论/收藏/分享)的无障碍节点
  2791. * 仍然留在树里**,y 范围(实测 1218~1822)与面板区域大幅重叠。
  2792. * 按"下方取最右"会稳定命中**点赞按钮**(id=`g8g`),后果是:
  2793. * 面板被收起 + 视频被点了个赞,而业务侧因为"面板关了"判定成评论发送成功。
  2794. * 假阳性比失败更糟 —— 用户以为发出去了。
  2795. *
  2796. * 而"中心 y 在输入框行内"这条能干净地排掉右侧竖排:
  2797. * 点赞按钮 1218~1338 的中心是 1278,落在输入框 1115~1222 之外。
  2798. *
  2799. * ★ 发送键**只在键盘弹起时才存在**。所以调用方必须先让输入框聚焦
  2800. * (`ui.typeInto` 里已经做了),否则这里返回 null 是正常的 ——
  2801. * 那种情况会如实报"定位不到发送按钮",不会乱点。
  2802. */
  2803. function sendButtonOnInputRow() {
  2804. var input = panelInput();
  2805. if (!input) {
  2806. logger.warn('comment', '没有面板输入框,无法按行定位发送按钮');
  2807. return null;
  2808. }
  2809. var ib = input.bounds();
  2810. var inputMidY = (ib.top + ib.bottom) / 2;
  2811. var screenW = screenWidth();
  2812. var best = null;
  2813. var bestDist = null;
  2814. var groups = ['android.widget.Button', 'android.widget.ImageView'];
  2815. for (var g = 0; g < groups.length; g += 1) {
  2816. var hits = findByLabel(className(groups[g]), function (node) {
  2817. /* ★ 先排掉右侧竖排动作栏(2026-10-28 血泪):
  2818. * 面板打开后输入框会上移到中部,正好和那六个按钮**同高**,
  2819. * 光靠几何分不开。但它们每一个都有明确的 desc 前缀,
  2820. * 而发送键没有 —— 这是唯一稳定可靠的分界。 */
  2821. if (isActionBarButton(node)) {
  2822. return false;
  2823. }
  2824. var nb = node.bounds();
  2825. var midY = (nb.top + nb.bottom) / 2;
  2826. if (midY < ib.top || midY > ib.bottom) {
  2827. return false; // 不在输入框这一行
  2828. }
  2829. if (nb.right < ib.right - 60) {
  2830. return false; // 不在输入框右端
  2831. }
  2832. if (nb.left < screenW * 0.5) {
  2833. return false; // 只看右半边
  2834. }
  2835. if (nb.bottom - nb.top < 20 || nb.right - nb.left < 20) {
  2836. return false; // 太小的装饰不收
  2837. }
  2838. try {
  2839. return node.clickable() === true;
  2840. } catch (ignored) {
  2841. return false;
  2842. }
  2843. });
  2844. /* 同一行里可能有多个(清空、表情…),取**离输入框中心最近**的那个。
  2845. * 用局部变量记距离 —— 绝不能往 AutoX 的 UiObject 上挂自定义属性:
  2846. * 那是 Java 宿主对象,赋值会在运行期抛异常,而且崩得很安静
  2847. * (界面只看到"未开始")。 */
  2848. for (var i = 0; i < hits.length; i += 1) {
  2849. var hb = hits[i].bounds();
  2850. var dist = Math.abs((hb.top + hb.bottom) / 2 - inputMidY);
  2851. if (bestDist === null || dist < bestDist) {
  2852. bestDist = dist;
  2853. best = hits[i];
  2854. }
  2855. }
  2856. if (best) {
  2857. break;
  2858. }
  2859. }
  2860. if (!best) {
  2861. /* 排障:输入框这一行右侧到底有什么 */
  2862. logger.warn('comment', '输入框 b=[' + ib.left + ',' + ib.top + ',' +
  2863. ib.right + ',' + ib.bottom + '] 这一行的右侧没有可点按钮(键盘没弹起?)');
  2864. var probes = [];
  2865. try {
  2866. var probeClasses = ['android.widget.Button', 'android.widget.ImageView',
  2867. 'android.view.View', 'android.widget.FrameLayout'];
  2868. for (var p = 0; p < probeClasses.length; p += 1) {
  2869. var cands = className(probeClasses[p]).find();
  2870. for (var c = 0; c < cands.size(); c += 1) {
  2871. var nb2 = cands.get(c).bounds();
  2872. var mid2 = (nb2.top + nb2.bottom) / 2;
  2873. if (mid2 < ib.top - 40 || mid2 > ib.bottom + 40) { continue; }
  2874. if (nb2.left < screenW * 0.5) { continue; }
  2875. probes.push(String(probeClasses[p]).split('.').pop() +
  2876. '[' + nb2.left + ',' + nb2.top + ',' + nb2.right + ',' + nb2.bottom +
  2877. ']' + (isActionBarButton(cands.get(c)) ? '(动作栏)' : ''));
  2878. }
  2879. }
  2880. } catch (ignoredProbe) { }
  2881. logger.warn('comment', '这一行右侧实际有:' + (probes.join(' ') || '(空)'));
  2882. return null;
  2883. }
  2884. logger.info('comment', '发送按钮:按输入框这一行定位 id=' + layout.shortId(best) +
  2885. ' desc=' + JSON.stringify(layout.desc(best)) +
  2886. ' b=' + JSON.stringify(best.bounds()));
  2887. return best;
  2888. }
  2889. function sendButton(config) {
  2890. var override = config && config.selectors && config.selectors.sendButtonId;
  2891. if (override) {
  2892. var byId = layout.findOnce(id(override));
  2893. if (byId && layout.isVisible(byId)) {
  2894. logger.info('comment', '发送按钮:使用配置 id ' + override);
  2895. return byId;
  2896. }
  2897. }
  2898. /* 先用几何判据(输入框那一行 + 右端)—— 它对"工具行 desc 变成 @资源id"
  2899. * 和"右侧动作栏节点残留在树里"这两个新版变化都免疫。 */
  2900. var rowButton = sendButtonOnInputRow();
  2901. if (rowButton) {
  2902. return rowButton;
  2903. }
  2904. var anchor = toolRowAnchor();
  2905. if (!anchor) {
  2906. return null;
  2907. }
  2908. var bandTop = topOf(anchor);
  2909. var bandBottom = bottomOf(anchor);
  2910. var width = screenWidth();
  2911. var best = null;
  2912. var hits = findByLabel(className('android.widget.Button'), function (node) {
  2913. var point = layout.center(node);
  2914. if (point === null) {
  2915. return false;
  2916. }
  2917. var box = node.bounds();
  2918. if (box.top < bandTop || box.top > bandBottom) {
  2919. return false; // 不在工具行高度带里
  2920. }
  2921. if (box.right > width - layout.SAFE_MARGIN) {
  2922. return false; // 属于右侧竖排动作栏
  2923. }
  2924. return box.left >= width * 0.5; // 必须是右半边
  2925. });
  2926. for (var i = 0; i < hits.length; i += 1) {
  2927. if (best === null || hits[i].bounds().left > best.bounds().left) {
  2928. best = hits[i];
  2929. }
  2930. }
  2931. if (best) {
  2932. logger.info('comment', '发送按钮:结构化定位 id=' +
  2933. layout.shortId(best) + ' b=' + JSON.stringify(best.bounds()));
  2934. }
  2935. return best;
  2936. }
  2937. // ---------------------------------------------------------------- 评论列表
  2938. /**
  2939. * 评论列表面板顶部的计数,形如 "150 comments"。
  2940. *
  2941. * 为什么需要它:实测发现**视频页那个评论角标不一定会立刻刷新**
  2942. * (发完评论后仍读回旧值),而评论列表头部的计数是即时的。
  2943. * 所以"计数自增"这条证据要用列表头部的那个,而不是角标的。
  2944. */
  2945. /**
  2946. * 从评论区头部的文案里取出评论数。**两种格式都要认**:
  2947. *
  2948. * · 旧版:`150 comments` / `1,203 comments` —— 数字在前
  2949. * · 新版:`Comments 785` —— 数字在后,而且与页签名在**同一个 TextView** 里
  2950. * (2026-10 实测:评论区拆成了 `Comments` / `Reviews` 两个页签)
  2951. *
  2952. * ★ 只认旧格式的后果非常隐蔽:首页信息流里点开评论面板后,
  2953. * `listCommentCount()` 返回 null → `panelInput()` 的第二条分支进不去 →
  2954. * 整个面板被判成"没打开",而屏幕上明明已经有评论列表和输入框。
  2955. * 日志里只会看到一句"面板没打开(该视频可能关闭了评论)",完全是误导。
  2956. */
  2957. function parseCommentCountLabel(value) {
  2958. var text = String(value || '').replace(/^\s+|\s+$/g, '');
  2959. var newer = text.match(/^Comments?\s+([\d][\d,.]*)$/i);
  2960. if (newer) {
  2961. return parseInt(String(newer[1]).replace(/[^\d]/g, ''), 10);
  2962. }
  2963. var legacy = text.match(/^([\d][\d,.]*)\s*(?:comments?|条评论)$/i);
  2964. if (legacy) {
  2965. return parseInt(String(legacy[1]).replace(/[^\d]/g, ''), 10);
  2966. }
  2967. return null;
  2968. }
  2969. function listCommentCount() {
  2970. var hits = findByLabel(className('android.widget.TextView'), function (node) {
  2971. return parseCommentCountLabel(layout.text(node)) !== null;
  2972. });
  2973. if (hits.length === 0) {
  2974. return null;
  2975. }
  2976. return parseCommentCountLabel(layout.text(hits[0]));
  2977. }
  2978. /**
  2979. * 评论面板是否处于打开状态。
  2980. * 注意:点内联输入框打开的是"快捷面板"(有中部输入框),
  2981. * 点评论角标打开的是"列表面板"(没有中部输入框,但有 "N comments" 头部)。
  2982. * 两种都要认,否则 back() 会漏按、面板关不掉。
  2983. */
  2984. function commentPanelOpen() {
  2985. return panelInput() !== null || listCommentCount() !== null;
  2986. }
  2987. /**
  2988. * 评论列表里是否出现了某条完全一致的文案。
  2989. *
  2990. * 这里刻意**不设数量上限**:早先为了省事写成"只看前 25 条可见文本",
  2991. * 结果遍历顺序是"先视频页、后评论面板",25 条全被视频页的文案占满了,
  2992. * 评论条目根本没轮到,于是最强的那条证据永远拿不到。
  2993. * 可见 TextView 也就几百个,全扫一遍的开销远小于"判错"的代价。
  2994. */
  2995. function commentListContains(value) {
  2996. var hits = findByLabel(className('android.widget.TextView'), function (node) {
  2997. return layout.text(node) === value;
  2998. });
  2999. return hits.length > 0;
  3000. }
  3001. /** 当前屏幕上所有可见的文本文案(按屏幕顺序) */
  3002. function visibleTexts(limit) {
  3003. var hits = findByLabel(className('android.widget.TextView'), function () { return true; });
  3004. var texts = [];
  3005. for (var i = 0; i < hits.length; i += 1) {
  3006. var value = layout.text(hits[i]);
  3007. if (value && value.length >= 2) {
  3008. texts.push(value);
  3009. }
  3010. if (limit && texts.length >= limit) {
  3011. break;
  3012. }
  3013. }
  3014. return texts;
  3015. }
  3016. // ============================================================ ID 提取
  3017. /*
  3018. * 下面这一整段是 2026-10-20 在真机上逐页 dump 出来的锚点
  3019. * (Pixel 5a / 1080x1920 / TikTok 47.1.4 / 英文界面)。**每条都是实测值**,
  3020. * 不是猜的 —— 猜出来的选择器在这个项目里从来没有一次是对的。
  3021. *
  3022. * ★ 一个重要发现:这一片的**资源 id 是语义化的**(`tv_username` / `txt_desc` /
  3023. * `user_avatar`),与信息流那批混淆 id(`e4g`/`cn6`)完全不同。
  3024. * 原因是这些页面来自 TikTok 的搜索/社交业务模块,用的是另一套布局。
  3025. * 所以这里优先按 id 定位,文案只作为兜底(改版时 id 比文案更稳)。
  3026. *
  3027. * 实测到的三处文本陷阱:
  3028. * ① 用户名被 **FID 隔离符** 包裹:显示 `⁨ajman_fashion_week⁩`(U+2068/U+2069),
  3029. * 肉眼看不见,`layout.sanitize` 也不剥(那不是零宽字符)→ 统一走 `parse.username`;
  3030. * ② 搜索用户列表里 **`tv_aweme_id` 装的是昵称**(id 名违反直觉),`tv_username` 才是 uid;
  3031. * ③ 粉丝列表与搜索列表的同义控件**id 不同**:
  3032. * 搜索列表 `tv_username`/`tv_desc`,粉丝列表 `txt_desc`(uid)/`txt_user_name`(昵称)。
  3033. */
  3034. /** 是否在用户主页:有一个文案形如 "@xxx" 的可点 Button,且下面跟着三组数字 */
  3035. function profileUsernameNode() {
  3036. var hits = findByLabel(className('android.widget.Button'), function (node) {
  3037. var value = layout.text(node);
  3038. return value.charAt(0) === '@' && value.length > 1 &&
  3039. value.length < 40 && topOf(node) < screenHeight() * 0.3;
  3040. });
  3041. return hits.length > 0 ? hits[0] : null;
  3042. }
  3043. /** 用户主页的昵称(在 @xxx 上面那一行) */
  3044. function profileNicknameNode() {
  3045. var userNode = profileUsernameNode();
  3046. if (!userNode) {
  3047. return null;
  3048. }
  3049. var userTop = topOf(userNode);
  3050. var hits = findByLabel(className('android.widget.Button'), function (node) {
  3051. var box = node.bounds();
  3052. return box.top < userTop && box.bottom > userTop - screenHeight() * 0.12 &&
  3053. layout.text(node) !== '' && layout.text(node).charAt(0) !== '@';
  3054. });
  3055. return hits.length > 0 ? hits[0] : null;
  3056. }
  3057. /** 用户页顶部三组数字的标签文案。英文界面实测为这三个词。 */
  3058. var STAT_LABELS = {
  3059. 'Following': 'follow',
  3060. 'Followers': 'fans',
  3061. 'Likes': 'likes',
  3062. '关注': 'follow',
  3063. '粉丝': 'fans',
  3064. '获赞': 'likes',
  3065. 'Abonnements': 'follow',
  3066. 'Abonnés': 'fans',
  3067. "J'aime": 'likes'
  3068. };
  3069. /**
  3070. * 用户页顶部三组数字:`{ follow: 343, fans: 594, likes: 2491 }`。
  3071. *
  3072. * 结构(实测):每个数字是一个 LinearLayout,里面两个 TextView ——
  3073. * 数字在上、标签在下。所以按"标签找容器、再取容器里的数字"读。
  3074. * 注意**没有视频数**:这个页面只显示 关注/粉丝/获赞 三组,这是平台行为。
  3075. */
  3076. function statNumbers() {
  3077. var out = { follow: null, fans: null, likes: null };
  3078. var parse = TikTokAuto.parse;
  3079. var labels = findByLabel(className('android.widget.TextView'), function () { return true; });
  3080. for (var i = 0; i < labels.length; i += 1) {
  3081. var labelText = layout.text(labels[i]);
  3082. var key = STAT_LABELS[labelText];
  3083. if (!key) {
  3084. continue;
  3085. }
  3086. /* 标签必须在页面上部(三组数字区),否则可能是别处长文本里恰好含这个词 */
  3087. if (topOf(labels[i]) > screenHeight() * 0.25) {
  3088. continue;
  3089. }
  3090. var container = parentOf(labels[i]);
  3091. var value = container ? numberInside(container) : null;
  3092. if (value === null) {
  3093. value = parse.count(labelText);
  3094. }
  3095. if (out[key] === null) {
  3096. out[key] = value;
  3097. }
  3098. }
  3099. return out;
  3100. }
  3101. function parentOf(node) {
  3102. try {
  3103. return node.parent();
  3104. } catch (ignored) {
  3105. return null;
  3106. }
  3107. }
  3108. /** 在一个容器里找那个"纯数字"的 TextView,解析成整数 */
  3109. function numberInside(container) {
  3110. var parse = TikTokAuto.parse;
  3111. var found = null;
  3112. var walk = [container];
  3113. var guard = 0;
  3114. while (walk.length > 0 && guard < 30) {
  3115. guard += 1;
  3116. var node = walk.shift();
  3117. if (!node) {
  3118. continue;
  3119. }
  3120. var value = layout.text(node);
  3121. if (value !== '' && /^[\d,.\s]+[kKmMbBwW万萬亿億]?$/.test(value)) {
  3122. var parsed = parse.count(value);
  3123. if (parsed !== null && (found === null || parsed > found)) {
  3124. found = parsed;
  3125. }
  3126. }
  3127. var count = 0;
  3128. try { count = node.childCount(); } catch (ignored) { count = 0; }
  3129. for (var i = 0; i < count; i += 1) {
  3130. try { walk.push(node.child(i)); } catch (ignoredToo) { /* 遍历中失效就跳过 */ }
  3131. }
  3132. }
  3133. return found;
  3134. }
  3135. /** 用户主页的个签:页面中上部一段较长的可点文本(实测是个 Button,不是 TextView) */
  3136. function profileSignatureNode() {
  3137. var hits = findByLabel(className('android.widget.Button'), function (node) {
  3138. var value = layout.text(node);
  3139. var box = node.bounds();
  3140. return value.length >= 4 && value.charAt(0) !== '@' &&
  3141. box.top > screenHeight() * 0.22 && box.top < screenHeight() * 0.34 &&
  3142. (box.right - box.left) > screenWidth() * 0.4;
  3143. });
  3144. return hits.length > 0 ? hits[0] : null;
  3145. }
  3146. /**
  3147. * 私密账号提示。
  3148. * 实测:私密账号的主页会在视频区位置显示一行提示("This account is private" 等)。
  3149. * 这里只在**视频网格明显不存在**时才认这条,避免把正常账号的某条视频文案误判成提示。
  3150. */
  3151. function privateHint() {
  3152. var keys = ['private', "isn't available", 'no videos', 'No videos'];
  3153. var texts = visibleTexts(0);
  3154. for (var i = 0; i < texts.length; i += 1) {
  3155. for (var j = 0; j < keys.length; j += 1) {
  3156. if (String(texts[i]).toLowerCase().indexOf(String(keys[j]).toLowerCase()) >= 0) {
  3157. return texts[i];
  3158. }
  3159. }
  3160. }
  3161. return '';
  3162. }
  3163. /** 用户主页的视频网格格子数(判断"有没有内容"用,不用于视频量门槛) */
  3164. function videoGridCount() {
  3165. var hits = findByLabel(className('android.widget.FrameLayout'), function (node) {
  3166. var point = layout.center(node);
  3167. return point !== null && point.y > screenHeight() * 0.33;
  3168. });
  3169. return hits.length;
  3170. }
  3171. /** 用户主页上的「关注/粉丝」入口(点它打开列表页) */
  3172. function statEntry(which) {
  3173. var wanted = which === 'following' ? 'follow' : 'fans';
  3174. var labels = findByLabel(className('android.widget.TextView'), function () { return true; });
  3175. for (var i = 0; i < labels.length; i += 1) {
  3176. if (STAT_LABELS[layout.text(labels[i])] !== wanted) {
  3177. continue;
  3178. }
  3179. if (topOf(labels[i]) > screenHeight() * 0.25) {
  3180. continue;
  3181. }
  3182. /* 可点的是外层容器,标签自己不可点 */
  3183. return layout.clickableAncestor(labels[i]) || parentOf(labels[i]);
  3184. }
  3185. return null;
  3186. }
  3187. /**
  3188. * 粉丝/关注列表页的页签("Following 343" / "Followers 594" / "Suggested")。
  3189. *
  3190. * 实测这个列表是**从用户页点进来的一张覆盖页**,顶部有三个页签,
  3191. * 可以直接在两个页签之间切换 —— 所以"先收粉丝再收关注"不必来回退。
  3192. */
  3193. function userListTabs() {
  3194. var out = [];
  3195. var hits = findByLabel(className('android.widget.TextView'), function (node) {
  3196. var value = layout.text(node);
  3197. return /^(Following|Followers|Suggested|关注|粉丝|推荐)\b/.test(value) &&
  3198. topOf(node) < screenHeight() * 0.16;
  3199. });
  3200. for (var i = 0; i < hits.length; i += 1) {
  3201. var value = layout.text(hits[i]);
  3202. var key = value.indexOf('Follower') === 0 || value.indexOf('粉丝') === 0 ? 'fans'
  3203. : (value.indexOf('Suggested') === 0 || value.indexOf('推荐') === 0 ? 'suggested' : 'following');
  3204. out.push({
  3205. key: key,
  3206. label: value,
  3207. node: layout.clickableAncestor(hits[i]) || hits[i]
  3208. });
  3209. }
  3210. return out;
  3211. }
  3212. /** 当前列表页选中的是哪个页签(用 selected 属性判,实测可用) */
  3213. function selectedListTab() {
  3214. var tabs = userListTabs();
  3215. for (var i = 0; i < tabs.length; i += 1) {
  3216. var selected = false;
  3217. try {
  3218. selected = tabs[i].node.selected() === true || tabs[i].node.isSelected() === true;
  3219. } catch (ignored) {
  3220. selected = false;
  3221. }
  3222. if (selected) {
  3223. return tabs[i].key;
  3224. }
  3225. }
  3226. /* selected 读不到时退回文案判断:选中态的页签文案与未选中态无差异,
  3227. * 所以这里只能返回 unknown,由调用方用"点完再看内容变没变"确认。 */
  3228. return 'unknown';
  3229. }
  3230. /**
  3231. * 粉丝/关注列表里的每一行。
  3232. *
  3233. * 实测结构:`LinearLayout`(整行可点)> `TextView txt_user_name`(昵称)
  3234. * + `TextView txt_desc`(**uid**)+ `Button ubp`(Follow)。
  3235. * 注意与搜索用户列表**id 不同**(那边是 tv_username/tv_desc)。
  3236. *
  3237. * 返回的 `node` 是行容器,点它进用户页;`x` 是"安全点击的横向位置"——
  3238. * 靠左,避开右侧的 Follow 按钮(实测按钮左边界 x≈872)。
  3239. */
  3240. function userListRows() {
  3241. var parse = TikTokAuto.parse;
  3242. var rows = [];
  3243. var uidNodes = findByLabel(className('android.widget.TextView'), function (node) {
  3244. return resourceIdOf(node).indexOf('txt_desc') >= 0 && layout.text(node) !== '';
  3245. });
  3246. /* 有的版本 uid 放在 txt_user_name、昵称放在 txt_desc,所以按"哪个像 uid"再兜一层 */
  3247. if (uidNodes.length === 0) {
  3248. uidNodes = findByLabel(className('android.widget.TextView'), function (node) {
  3249. return resourceIdOf(node).indexOf('txt_user_name') >= 0 &&
  3250. parse.looksLikeUsername(layout.text(node));
  3251. });
  3252. }
  3253. for (var i = 0; i < uidNodes.length; i += 1) {
  3254. var uidNode = uidNodes[i];
  3255. var uid = parse.username(layout.text(uidNode));
  3256. if (!parse.looksLikeUsername(uid)) {
  3257. continue;
  3258. }
  3259. var row = findRowContainer(uidNode);
  3260. var box = row ? row.bounds() : uidNode.bounds();
  3261. rows.push({
  3262. uid: uid,
  3263. nickname: nicknameOf(uidNode, row),
  3264. node: row || uidNode,
  3265. top: box.top,
  3266. x: Math.round(box.left + (box.right - box.left) * 0.3),
  3267. y: Math.round((box.top + box.bottom) / 2)
  3268. });
  3269. }
  3270. rows.sort(function (a, b) { return a.top - b.top; });
  3271. return rows;
  3272. }
  3273. function resourceIdOf(node) {
  3274. return layout.resourceId(node);
  3275. }
  3276. /** 从 uid 文本往上找"整行"容器(高度落在一行的高度区间内) */
  3277. function findRowContainer(uidNode) {
  3278. var cursor = uidNode;
  3279. var depth = 0;
  3280. while (cursor && depth < 5) {
  3281. var box = cursor.bounds();
  3282. var rowHeight = box.bottom - box.top;
  3283. if (layout.isClickable(cursor) && rowHeight >= screenHeight() * 0.05 &&
  3284. rowHeight <= screenHeight() * 0.12) {
  3285. return cursor;
  3286. }
  3287. cursor = parentOf(cursor);
  3288. depth += 1;
  3289. }
  3290. return layout.clickableAncestor(uidNode) || parentOf(uidNode);
  3291. }
  3292. /** 行里的昵称:找同一容器内的另一个 TextView(不是 uid 那个) */
  3293. function nicknameOf(uidNode, row) {
  3294. if (!row) {
  3295. return '';
  3296. }
  3297. var found = '';
  3298. var walk = [row];
  3299. var guard = 0;
  3300. while (walk.length > 0 && guard < 40) {
  3301. guard += 1;
  3302. var node = walk.shift();
  3303. if (!node) {
  3304. continue;
  3305. }
  3306. var id = resourceIdOf(node);
  3307. var value = layout.text(node);
  3308. if (value !== '' && (id.indexOf('txt_user_name') >= 0 || id.indexOf('tv_aweme_id') >= 0)) {
  3309. found = value;
  3310. break;
  3311. }
  3312. var count = 0;
  3313. try { count = node.childCount(); } catch (ignored) { count = 0; }
  3314. for (var i = 0; i < count; i += 1) {
  3315. try { walk.push(node.child(i)); } catch (ignoredToo) { /* 跳过 */ }
  3316. }
  3317. }
  3318. return found;
  3319. }
  3320. /**
  3321. * 搜索结果的「用户」列表行。
  3322. *
  3323. * 实测结构:`Button`(整行可点)> `TextView tv_username`(uid)
  3324. * + `TextView tv_aweme_id`(**昵称**,id 名违反直觉)+ `TextView tv_desc`("594 followers · 2.5K likes")。
  3325. *
  3326. * 额外好处:`tv_desc` 里就有粉丝数与获赞数 —— 所以按数量筛选时
  3327. * **这一条来源不必逐个打开用户页**(其余来源没有这个信息)。
  3328. */
  3329. function usersResultRows() {
  3330. var parse = TikTokAuto.parse;
  3331. var rows = [];
  3332. var uidNodes = findByLabel(className('android.widget.TextView'), function (node) {
  3333. return resourceIdOf(node).indexOf('tv_username') >= 0 && layout.text(node) !== '';
  3334. });
  3335. for (var i = 0; i < uidNodes.length; i += 1) {
  3336. var uid = parse.username(layout.text(uidNodes[i]));
  3337. if (!parse.looksLikeUsername(uid)) {
  3338. continue;
  3339. }
  3340. var box = uidNodes[i].bounds();
  3341. var desc = '';
  3342. var nickname = '';
  3343. var rowNode = null;
  3344. /* 向上两层找行容器,再在容器里读昵称与计数 */
  3345. var cursor = uidNodes[i];
  3346. for (var up = 0; up < 4 && cursor; up += 1) {
  3347. cursor = parentOf(cursor);
  3348. if (cursor && layout.isClickable(cursor)) {
  3349. rowNode = cursor;
  3350. break;
  3351. }
  3352. }
  3353. if (rowNode) {
  3354. var children = findByLabelInside(rowNode, className('android.widget.TextView'));
  3355. for (var j = 0; j < children.length; j += 1) {
  3356. var id = resourceIdOf(children[j]);
  3357. var value = layout.text(children[j]);
  3358. if (id.indexOf('tv_aweme_id') >= 0) {
  3359. nickname = value;
  3360. } else if (id.indexOf('tv_desc') >= 0) {
  3361. desc = value;
  3362. }
  3363. }
  3364. var rowBox = rowNode.bounds();
  3365. box = { left: rowBox.left, top: rowBox.top, right: rowBox.right, bottom: rowBox.bottom };
  3366. }
  3367. rows.push({
  3368. uid: uid,
  3369. nickname: nickname,
  3370. node: rowNode || uidNodes[i],
  3371. top: box.top,
  3372. fans: parseFollowerLine(desc, 'followers'),
  3373. likes: parseFollowerLine(desc, 'likes'),
  3374. x: Math.round(box.left + (box.right - box.left) * 0.3),
  3375. y: Math.round((box.top + box.bottom) / 2)
  3376. });
  3377. }
  3378. rows.sort(function (a, b) { return a.top - b.top; });
  3379. return rows;
  3380. }
  3381. /** 在一个节点内部找符合条件的子孙(不含自身之外的范围) */
  3382. function findByLabelInside(rootNode, selector) {
  3383. var out = [];
  3384. var collection = selector.find();
  3385. for (var i = 0; i < collection.size(); i += 1) {
  3386. var node = collection.get(i);
  3387. var cursor = node;
  3388. var depth = 0;
  3389. var inside = false;
  3390. while (cursor && depth < 12) {
  3391. if (cursor === rootNode) {
  3392. inside = true;
  3393. break;
  3394. }
  3395. cursor = parentOf(cursor);
  3396. depth += 1;
  3397. }
  3398. if (inside) {
  3399. out.push(node);
  3400. }
  3401. }
  3402. return out;
  3403. }
  3404. /**
  3405. * 从 "594 followers · 2.5K likes" 里分别抠出两个数。
  3406. * 分隔符实测是 " · "(中点),但不同语言/版本可能不同,所以按**关键词**切而不是按分隔符。
  3407. */
  3408. function parseFollowerLine(text, which) {
  3409. var parse = TikTokAuto.parse;
  3410. if (!text) {
  3411. return null;
  3412. }
  3413. var parts = String(text).split(/[·•|]/);
  3414. for (var i = 0; i < parts.length; i += 1) {
  3415. var part = parts[i];
  3416. var lower = part.toLowerCase();
  3417. if (which === 'followers' && (lower.indexOf('follower') >= 0 || lower.indexOf('粉丝') >= 0)) {
  3418. return parse.count(part);
  3419. }
  3420. if (which === 'likes' && (lower.indexOf('like') >= 0 || lower.indexOf('获赞') >= 0)) {
  3421. return parse.count(part);
  3422. }
  3423. }
  3424. return null;
  3425. }
  3426. /** 播放页底部的作者 uid(实测:`Button l2m` 的 text = "@2131823375") */
  3427. function playerAuthorUid() {
  3428. var parse = TikTokAuto.parse;
  3429. var hits = findByLabel(className('android.widget.Button'), function (node) {
  3430. var value = layout.text(node);
  3431. return value.charAt(0) === '@' && parse.looksLikeUsername(value);
  3432. });
  3433. return hits.length > 0 ? parse.username(layout.text(hits[0])) : '';
  3434. }
  3435. /**
  3436. * 评论区里的每一条:**只有昵称,没有 uid**(实测 2026-10-20)。
  3437. *
  3438. * 所以"从评论区取评论者"必须逐个点头像/昵称进用户页 —— 成本比列表高一个数量级。
  3439. * 这里返回每个人可点的节点(头像 ImageView `blm`)与昵称,供上层决定怎么进。
  3440. */
  3441. function commentRowers() {
  3442. var out = [];
  3443. var avatars = findByLabel(className('android.widget.ImageView'), function (node) {
  3444. return resourceIdOf(node).indexOf('blm') >= 0;
  3445. });
  3446. for (var i = 0; i < avatars.length; i += 1) {
  3447. var box = avatars[i].bounds();
  3448. /* 头像必须在评论列表区域内(屏幕中下部),不是视频右上角的作者头像 */
  3449. if (box.top < screenHeight() * 0.38) {
  3450. continue;
  3451. }
  3452. out.push({
  3453. node: avatars[i],
  3454. top: box.top,
  3455. x: Math.round((box.left + box.right) / 2),
  3456. y: Math.round((box.top + box.bottom) / 2)
  3457. });
  3458. }
  3459. out.sort(function (a, b) { return a.top - b.top; });
  3460. return out;
  3461. }
  3462. /** 结果页的某个页签当前是否选中(Users 页签用) */
  3463. function resultTabSelected(name) {
  3464. var node = resultTab(name);
  3465. if (!node) {
  3466. return false;
  3467. }
  3468. try {
  3469. return node.selected() === true || node.isSelected() === true;
  3470. } catch (ignored) {
  3471. return false;
  3472. }
  3473. }
  3474. /**
  3475. * ID 提取这条链路自己在用的页面判定。
  3476. *
  3477. * **刻意与 pageState() 分开**:pageState 是视频链路的判据,
  3478. * 往里加分支就有可能把"播放页 vs 结果页"的老判定带歪(方案 §12 明确要求回归)。
  3479. * 新开一个函数,视频链路一行都不用动。
  3480. *
  3481. * @returns 'profile' | 'user-list' | 'users-results' | 'comment-panel' | 'search' | 'feed' | 'other'
  3482. */
  3483. function idPageState() {
  3484. if (commentPanelOpen()) {
  3485. return 'comment-panel';
  3486. }
  3487. if (profileUsernameNode() !== null) {
  3488. return 'profile';
  3489. }
  3490. if (userListTabs().length >= 2 && userListRows().length > 0) {
  3491. return 'user-list';
  3492. }
  3493. if (usersResultRows().length > 0) {
  3494. return 'users-results';
  3495. }
  3496. if (searchInput() !== null) {
  3497. return 'search';
  3498. }
  3499. if (bottomNavPresent()) {
  3500. return 'feed';
  3501. }
  3502. return 'other';
  3503. }
  3504. // ---------------------------------------------------------------- 页面状态
  3505. /**
  3506. * 判定当前处于哪个页面。
  3507. * 顺序即优先级:播放页最特征化先判,信息流最宽松最后判。
  3508. */
  3509. function pageState() {
  3510. // 评论面板要最先判:面板一盖上来,播放页的返回箭头就被遮住了,
  3511. // 于是 onPlayerPage() 会变 false,整个页面掉进 unknown 再也出不来
  3512. // (实测踩到:脚本自己打开评论列表校验后没关掉,下一轮就彻底迷路)。
  3513. if (commentPanelOpen()) {
  3514. return 'comment-panel';
  3515. }
  3516. if (onPlayerPage()) {
  3517. return 'player';
  3518. }
  3519. if (resultTabStripPresent()) {
  3520. return 'results';
  3521. }
  3522. // 搜索页判据:顶部有输入框,且**没有**结果页的页签条。
  3523. // 早先还要求存在搜索历史行,结果"历史为空 / 已被清空"时整页被判成 unknown,
  3524. // 于是 ensureFeed 一路僵住。页签条的有无已经足够区分搜索页和结果页。
  3525. if (searchInput() !== null && !resultTabStripPresent() && !bottomNavPresent()) {
  3526. return 'search';
  3527. }
  3528. if (searchEntry() !== null && bottomNavPresent()) {
  3529. return 'feed';
  3530. }
  3531. return 'unknown';
  3532. }
  3533. /** 排障用:把当前可见的关键节点打出来 */
  3534. function describeScreen(tag) {
  3535. var nodes = [];
  3536. var classes = ['android.widget.Button', 'android.widget.EditText', 'android.widget.ImageView', 'android.widget.TextView'];
  3537. for (var i = 0; i < classes.length; i += 1) {
  3538. var collection = className(classes[i]).find();
  3539. for (var j = 0; j < collection.size(); j += 1) {
  3540. if (layout.isVisible(collection.get(j))) {
  3541. nodes.push(collection.get(j));
  3542. }
  3543. }
  3544. }
  3545. logger.info(tag || 'screen', layout.describe(nodes, 40));
  3546. }
  3547. return {
  3548. PKG: PKG,
  3549. pageState: pageState,
  3550. searchEntry: searchEntry,
  3551. searchInput: searchInput,
  3552. searchSubmit: searchSubmit,
  3553. searchHistoryPresent: searchHistoryPresent,
  3554. bottomNavPresent: bottomNavPresent,
  3555. profileNav: profileNav,
  3556. resultTab: resultTab,
  3557. resultTabStripPresent: resultTabStripPresent,
  3558. videoCards: videoCards,
  3559. commentButton: commentButton,
  3560. likeButton: likeButton,
  3561. likedButton: likedButton,
  3562. likeIcon: likeIcon,
  3563. likeState: likeState,
  3564. followButton: followButton,
  3565. authorAvatar: authorAvatar,
  3566. feedSignature: feedSignature,
  3567. parseCommentCountLabel: parseCommentCountLabel,
  3568. followState: followState,
  3569. onPlayerPage: onPlayerPage,
  3570. photoPost: photoPost,
  3571. backButton: backButton,
  3572. commentCount: commentCount,
  3573. inlineInput: inlineInput,
  3574. panelInput: panelInput,
  3575. toolRowAnchor: toolRowAnchor,
  3576. sendButton: sendButton,
  3577. listCommentCount: listCommentCount,
  3578. commentPanelOpen: commentPanelOpen,
  3579. commentListContains: commentListContains,
  3580. visibleTexts: visibleTexts,
  3581. describeScreen: describeScreen,
  3582. /* —— ID 提取(2026-10-20 真机实测锚点,见上面的长注释) —— */
  3583. profileUsernameNode: profileUsernameNode,
  3584. profileNicknameNode: profileNicknameNode,
  3585. profileSignatureNode: profileSignatureNode,
  3586. statNumbers: statNumbers,
  3587. statEntry: statEntry,
  3588. privateHint: privateHint,
  3589. videoGridCount: videoGridCount,
  3590. userListTabs: userListTabs,
  3591. selectedListTab: selectedListTab,
  3592. userListRows: userListRows,
  3593. usersResultRows: usersResultRows,
  3594. playerAuthorUid: playerAuthorUid,
  3595. commentRowers: commentRowers,
  3596. resultTabSelected: resultTabSelected,
  3597. idPageState: idPageState
  3598. };
  3599. }());
  3600. /* ============================== src/player.js ============================== */
  3601. /**
  3602. * player.js — 「看视频」这件事:播放存活探测 + 有界观看
  3603. *
  3604. * 需求原话是"随机观看 10-35 秒,且不单靠 sleep"。
  3605. * 为什么裸 sleep 不行:sleep(20000) 只保证**墙上时钟**走了 20 秒,
  3606. * 它不关心这 20 秒里视频是否卡在加载、是否弹了窗、是否被切走。
  3607. * 网络一抖,sleep 出来的"观看"就是假的。
  3608. *
  3609. * 这里的做法:把"观看"实现成一个**带守卫的截止时间循环**——
  3610. * 1. 先等"播放真的开始"(有界等待,而不是假定它已经开始);
  3611. * 2. 观看期间持续做两件事:
  3612. * a) 校验"还在播放页"(页面身份漂移就立刻记账,连续漂移就放弃并上报);
  3613. * b) 按固定节拍采样**播放存活证据**,连续多次无证据即判定卡顿,尝试一次唤醒;
  3614. * 3. 循环里那个 sleep 是**轮询节拍**(多久检查一次),不是"等待多少秒"。
  3615. *
  3616. * 播放存活证据(按可用性排序,可配置):
  3617. * - useAudio : AudioManager.isMusicActive() —— 不依赖截图权限,实测可用
  3618. * - usePixels: captureScreen() 两点像素差 —— 最强,但 Android 14 需要 MediaProjection 授权
  3619. * 两者都不可用时,脚本**不会假装成功**:会退化为"页面守卫",并在结果里
  3620. * 明确标记 probeMode='page-guard-only',让上层知道这次的"观看"可信度较低。
  3621. *
  3622. * 依赖:TikTokAuto.logger、TikTokAuto.layout、TikTokAuto.tiktok、TikTokAuto.wait、TikTokAuto.ui
  3623. */
  3624. TikTokAuto.player = (function () {
  3625. 'use strict';
  3626. var PIXEL_SAMPLE_POINTS = [
  3627. [0.5, 0.35], [0.5, 0.5], [0.5, 0.65], [0.25, 0.45], [0.75, 0.45]
  3628. ];
  3629. // ------------------------------------------------------------------ 证据源
  3630. function createAudioProbe() {
  3631. var manager = null;
  3632. try {
  3633. if (typeof context !== 'undefined' && context) {
  3634. manager = context.getSystemService('audio');
  3635. }
  3636. } catch (error) {
  3637. manager = null;
  3638. }
  3639. if (!manager) {
  3640. return null;
  3641. }
  3642. return {
  3643. name: 'audio',
  3644. read: function () {
  3645. try {
  3646. return manager.isMusicActive() === true;
  3647. } catch (error) {
  3648. return false;
  3649. }
  3650. }
  3651. };
  3652. }
  3653. function createPixelProbe() {
  3654. var first = null;
  3655. function grab() {
  3656. var image = captureScreen();
  3657. if (!image) {
  3658. return null;
  3659. }
  3660. var width = image.getWidth();
  3661. var height = image.getHeight();
  3662. var samples = [];
  3663. for (var i = 0; i < PIXEL_SAMPLE_POINTS.length; i += 1) {
  3664. var x = Math.floor(width * PIXEL_SAMPLE_POINTS[i][0]);
  3665. var y = Math.floor(height * PIXEL_SAMPLE_POINTS[i][1]);
  3666. samples.push(image.pixel(x, y));
  3667. }
  3668. return samples.join(',');
  3669. }
  3670. try {
  3671. first = grab();
  3672. } catch (error) {
  3673. return null; // 没有 MediaProjection 授权,直接放弃这条证据源
  3674. }
  3675. if (first === null) {
  3676. return null;
  3677. }
  3678. return {
  3679. name: 'pixels',
  3680. read: function () {
  3681. try {
  3682. var now = grab();
  3683. return now !== null && now !== first;
  3684. } catch (error) {
  3685. return false;
  3686. }
  3687. }
  3688. };
  3689. }
  3690. /**
  3691. * 组装可用的探测源。
  3692. * 返回的 probeMode 会一路带到最终结果里——**不要**把降级悄悄藏起来。
  3693. */
  3694. function createProbe(config) {
  3695. var sources = [];
  3696. if (config.probe.useAudio) {
  3697. var audio = createAudioProbe();
  3698. if (audio) {
  3699. sources.push(audio);
  3700. }
  3701. }
  3702. if (config.probe.usePixels) {
  3703. var pixels = createPixelProbe();
  3704. if (pixels) {
  3705. sources.push(pixels);
  3706. }
  3707. }
  3708. return {
  3709. mode: sources.length === 0 ? 'page-guard-only' : sources.map(function (s) { return s.name; }).join('+'),
  3710. degraded: sources.length === 0,
  3711. /** 任一证据源报"在动"就算活着 */
  3712. alive: function () {
  3713. for (var i = 0; i < sources.length; i += 1) {
  3714. if (sources[i].read()) {
  3715. return true;
  3716. }
  3717. }
  3718. return false;
  3719. }
  3720. };
  3721. }
  3722. // ------------------------------------------------------------------ 等待播放
  3723. /** 等播放真正开始(而不是假定点开就在播) */
  3724. function waitPlaybackStart(config, step) {
  3725. var logger = TikTokAuto.logger;
  3726. var probe = createProbe(config);
  3727. if (probe.degraded) {
  3728. logger.warn(step, '没有可用的播放证据源(无音频/无截图权限),退化为页面守卫模式');
  3729. return { started: false, probe: probe };
  3730. }
  3731. var started = TikTokAuto.wait.until(function () {
  3732. return probe.alive() ? true : null;
  3733. }, config.timeouts.playbackStartMs, config.probe.pollTickMs);
  3734. if (started) {
  3735. logger.info(step, '检测到播放已开始(证据源 ' + probe.mode + ')');
  3736. } else {
  3737. logger.warn(step, '等待 ' + config.timeouts.playbackStartMs + 'ms 未检测到播放证据,仍继续(可能是静音视频)');
  3738. }
  3739. return { started: started === true, probe: probe };
  3740. }
  3741. // ------------------------------------------------------------------ 观看
  3742. function randomInt(min, max) {
  3743. return Math.floor(Math.random() * (max - min + 1)) + min;
  3744. }
  3745. /**
  3746. * 观看视频。
  3747. *
  3748. * @param config 生效配置
  3749. * @param options {
  3750. * step,
  3751. * guard, // 页面守卫,默认"还在播放页"(见下)
  3752. * minMs/maxMs // 覆盖 config.watch 的观看时长区间
  3753. * }
  3754. * @returns 观看报告(对象,不是布尔——上层需要知道"为什么/可信度如何")
  3755. *
  3756. * ★ 为什么 `guard` 必须可注入(2026-10-28 真机实测):
  3757. * 默认守卫是 `onPlayerPage()`,而它的判据里要求**有返回箭头、没有底部导航栏**——
  3758. * 这两条在首页信息流上正好相反(没有返回箭头、有 Home/Profile 导航)。
  3759. * 所以在信息流里跑 watch 时,默认守卫会恒为 false,3 次采样(约 3 秒)就
  3760. * 以 `left-player-page` 提前退出,一个视频都看不完。
  3761. * 养号传 `pageState() === 'feed'` 进来即可,视频链路不传、行为完全不变。
  3762. */
  3763. function watch(config, options) {
  3764. var settings = options || {};
  3765. var step = settings.step || 'watch';
  3766. var logger = TikTokAuto.logger;
  3767. var tiktok = TikTokAuto.tiktok;
  3768. var guard = settings.guard || function () { return tiktok.onPlayerPage(); };
  3769. var minMs = settings.minMs === undefined ? config.watch.minMs : settings.minMs;
  3770. var maxMs = settings.maxMs === undefined ? config.watch.maxMs : settings.maxMs;
  3771. var targetMs = randomInt(minMs, maxMs);
  3772. var start = waitPlaybackStart(config, step);
  3773. var probe = start.probe;
  3774. var beganAt = Date.now();
  3775. var deadline = beganAt + targetMs;
  3776. var tickMs = config.probe.pollTickMs;
  3777. var nextLivenessAt = beganAt + config.probe.livenessIntervalMs;
  3778. var stalls = 0;
  3779. var offPage = 0;
  3780. var maxOffPage = 3;
  3781. var nudges = 0;
  3782. var aborted = null;
  3783. logger.info(step, '开始观看,计划 ' + targetMs + 'ms(区间 ' +
  3784. minMs + '-' + maxMs + 'ms),证据源=' + probe.mode);
  3785. while (Date.now() < deadline) {
  3786. // (a) 页面身份守卫:不在播放页就不是"在看视频"
  3787. if (!guard()) {
  3788. offPage += 1;
  3789. logger.warn(step, '已离开播放页(第 ' + offPage + ' 次检测)');
  3790. if (offPage >= maxOffPage) {
  3791. aborted = 'left-player-page';
  3792. break;
  3793. }
  3794. } else {
  3795. offPage = 0;
  3796. }
  3797. // (b) 播放存活采样
  3798. if (Date.now() >= nextLivenessAt) {
  3799. nextLivenessAt = Date.now() + config.probe.livenessIntervalMs;
  3800. if (!probe.degraded) {
  3801. if (probe.alive()) {
  3802. stalls = 0;
  3803. } else {
  3804. stalls += 1;
  3805. logger.warn(step, '未检测到播放证据(连续 ' + stalls + '/' + config.probe.stallTolerance + ')');
  3806. if (stalls >= config.probe.stallTolerance) {
  3807. // 只尝试一次唤醒,不无限戳屏幕——那会把页面点出别的东西
  3808. if (nudges < 1) {
  3809. nudge(step);
  3810. nudges += 1;
  3811. stalls = 0;
  3812. } else {
  3813. logger.warn(step, '判定卡顿且唤醒无效,提前结束观看');
  3814. aborted = 'stalled';
  3815. break;
  3816. }
  3817. }
  3818. }
  3819. }
  3820. }
  3821. TikTokAuto.wait.tick(tickMs);
  3822. }
  3823. var actualMs = Date.now() - beganAt;
  3824. var report = {
  3825. requestedMs: targetMs,
  3826. actualMs: actualMs,
  3827. probeMode: probe.mode,
  3828. degraded: probe.degraded,
  3829. nudges: nudges,
  3830. aborted: aborted,
  3831. completed: aborted === null && actualMs >= targetMs - tickMs
  3832. };
  3833. if (report.completed) {
  3834. logger.info(step, '观看完成 ' + actualMs + 'ms / 目标 ' + targetMs + 'ms');
  3835. } else {
  3836. logger.warn(step, '观看未按计划完成:' + JSON.stringify(report));
  3837. }
  3838. return report;
  3839. }
  3840. /** 轻点视频区域一次,尝试恢复播放(不会点到右侧动作栏) */
  3841. function nudge(step) {
  3842. var layout = TikTokAuto.layout;
  3843. var point = { x: Math.floor(layout.screen().width * 0.5), y: Math.floor(layout.screen().height * 0.4) };
  3844. TikTokAuto.logger.info(step, '尝试轻点画面恢复播放 @(' + point.x + ',' + point.y + ')');
  3845. TikTokAuto.ui.tapPoint(point.x, point.y);
  3846. TikTokAuto.wait.tick(300);
  3847. }
  3848. /** 等播放页就绪(点开卡片之后、开始计时之前) */
  3849. function waitPlayerReady(config, step) {
  3850. var logger = TikTokAuto.logger;
  3851. var tiktok = TikTokAuto.tiktok;
  3852. /* 先花最多 4 秒分辨"打开的是不是视频"。
  3853. *
  3854. * 搜索结果里混着照片轮播帖,它的信息行是 `Photo · 1w ago`,**永远不会有播放页**,
  3855. * 而 `onPlayerPage()` 也认不出来 —— 不提前判掉就要白等满 playerReadyMs(20 秒)。
  3856. * 真机实测:一轮 4 个目标里撞到 1 个照片帖,就白白浪费掉 20 秒。
  3857. *
  3858. * 认出是视频(或两种都不是)之后仍走原来的等待,不改变正常路径的行为。
  3859. */
  3860. var kind = TikTokAuto.wait.waitFor(step + ':kind', function () {
  3861. if (tiktok.onPlayerPage()) {
  3862. return 'player';
  3863. }
  3864. if (tiktok.photoPost()) {
  3865. return 'photo';
  3866. }
  3867. return null;
  3868. }, 4000, config.probe.pollTickMs);
  3869. if (kind === 'photo') {
  3870. logger.info(step, '打开的是照片帖(没有播放页),跳过');
  3871. return false;
  3872. }
  3873. var ready = TikTokAuto.wait.waitFor(step, function () {
  3874. return tiktok.onPlayerPage() ? true : null;
  3875. }, config.timeouts.playerReadyMs, config.probe.pollTickMs);
  3876. if (ready) {
  3877. TikTokAuto.wait.waitQuiet(function () {
  3878. return TikTokAuto.layout.signature();
  3879. }, { maxMs: 3000, tickMs: 250, stableTimes: 3 });
  3880. }
  3881. return ready === true;
  3882. }
  3883. return {
  3884. createProbe: createProbe,
  3885. waitPlaybackStart: waitPlaybackStart,
  3886. waitPlayerReady: waitPlayerReady,
  3887. watch: watch,
  3888. randomInt: randomInt
  3889. };
  3890. }());
  3891. /* ============================== src/comment.js ============================== */
  3892. /**
  3893. * comment.js — 评论:挑文案 → 打开面板 → 输入 → 发送 → **验证真的发出去了**
  3894. *
  3895. * 关键设计:发送后必须**验证**,而且要"多条证据同时成立"才敢说成功。
  3896. * 只看着"面板关掉了"就宣布成功,是这类脚本最典型的假阳性来源
  3897. * (面板也可能因为别的原因关掉,而评论被平台拒了)。
  3898. *
  3899. * 实测有效的两条证据:
  3900. * 1. 评论计数自增: "Read or add comments. 149 comments" → "150 comments"
  3901. * 2. 输入框被清空并恢复占位符 "Add comment...",且面板收起
  3902. * 默认以"计数自增"为强证据;计数有延迟时退回到第 2 条(可用
  3903. * comments.strictPostCheck 强制只认第 1 条)。
  3904. *
  3905. * 依赖:TikTokAuto.logger、TikTokAuto.layout、TikTokAuto.ui、TikTokAuto.wait、TikTokAuto.tiktok
  3906. */
  3907. TikTokAuto.comment = (function () {
  3908. 'use strict';
  3909. var PLACEHOLDER = 'Add comment...';
  3910. /** 从评论池里随机挑一条,尽量不重复(池子用完了就允许重复并告警) */
  3911. function pickText(config, used, step) {
  3912. var pool = config.comments.pool;
  3913. var candidates = [];
  3914. for (var i = 0; i < pool.length; i += 1) {
  3915. if (!config.comments.avoidRepeatWithinRun || used.indexOf(pool[i]) === -1) {
  3916. candidates.push(pool[i]);
  3917. }
  3918. }
  3919. if (candidates.length === 0) {
  3920. TikTokAuto.logger.warn(step || 'comment', '评论池已用尽,本轮允许重复');
  3921. candidates = pool;
  3922. }
  3923. return candidates[Math.floor(Math.random() * candidates.length)];
  3924. }
  3925. /** 打开评论面板:点内联输入框 / 点评论按钮 → 等面板输入框出现 */
  3926. function openPanel(config, step) {
  3927. var story = step || 'openCommentPanel';
  3928. var logger = TikTokAuto.logger;
  3929. var tiktok = TikTokAuto.tiktok;
  3930. if (tiktok.panelInput() !== null) {
  3931. logger.info(story, '评论面板已经是打开状态');
  3932. return true;
  3933. }
  3934. /* 两个入口,按页面形态选:
  3935. * · 播放页(从搜索结果/主页点进来的)底部有一条内联输入框 "Add comment..."
  3936. * · **首页信息流底部是导航栏**(Home/Friends/Inbox/Profile),没有内联输入框
  3937. * —— 只能点右侧那个 "Read or add comments. N comments" 按钮。
  3938. * 以前只有第一种,于是养号在信息流里发评论会直接报"找不到评论入口"。
  3939. */
  3940. var entry = tiktok.inlineInput();
  3941. var via = 'inline-input';
  3942. if (!entry) {
  3943. entry = tiktok.commentButton();
  3944. via = 'comment-button';
  3945. }
  3946. if (!entry) {
  3947. logger.error(story, '找不到评论入口(内联输入框与评论按钮都没有)');
  3948. return false;
  3949. }
  3950. logger.info(story, '打开评论面板(入口=' + via + ')');
  3951. var ready = false;
  3952. if (via === 'comment-button') {
  3953. ready = openByCommentButton(config, entry, story);
  3954. if (!ready) {
  3955. /* 换一种点击手段再试 —— 入口节点可能已经失效(页面重排过) */
  3956. entry = tiktok.commentButton();
  3957. if (entry) {
  3958. TikTokAuto.ui.clickNode(entry, story);
  3959. }
  3960. }
  3961. } else {
  3962. TikTokAuto.ui.clickNode(entry, story);
  3963. }
  3964. if (ready) {
  3965. return true;
  3966. }
  3967. var opened = TikTokAuto.wait.waitFor(story, function () {
  3968. return tiktok.panelInput() !== null ? true : null;
  3969. }, config.timeouts.panelReadyMs, config.probe.pollTickMs);
  3970. if (!opened) {
  3971. /* 把当时所有输入框的位置记下来。"点了没反应"和"面板开了但认不出输入框"
  3972. * 是两种完全不同的故障,而在日志里它们长得一模一样 —— 没有这行就只能靠猜。 */
  3973. var seen = [];
  3974. try {
  3975. var ets = className('android.widget.EditText').find();
  3976. for (var e = 0; e < ets.size(); e += 1) {
  3977. var box = ets.get(e).bounds();
  3978. seen.push(box.top + '~' + box.bottom);
  3979. }
  3980. logger.warn(story, '面板没打开;此刻 EditText ' + ets.size() +
  3981. ' 个 @ ' + seen.join(', ') + '(判据区间 ' +
  3982. Math.floor(TikTokAuto.layout.screen().height * 0.45) + '~' +
  3983. Math.floor(TikTokAuto.layout.screen().height * 0.85) + ')');
  3984. } catch (ignored) {
  3985. logger.warn(story, '面板没打开;连输入框都列举失败');
  3986. }
  3987. /* 有的视频关闭了评论 / 点到了别的地方。把话说清楚,
  3988. * 否则上层只看到"评论发不出去"却不知道为什么。 */
  3989. logger.warn(story, '点了入口但面板没打开(该视频可能关闭了评论)');
  3990. }
  3991. return opened === true;
  3992. }
  3993. /**
  3994. * 通过点右侧那个 "Read or add comments" 按钮打开面板(首页信息流里唯一的入口)。
  3995. *
  3996. * ★ 两个都必须做,缺一个就会"点击成功但面板死活不出来"(2026-10-28 真机实测):
  3997. *
  3998. * ① **点之前先等页面安静**。watch 刚结束、或者面板刚收起时,这一下点击会被吞掉。
  3999. * 症状极其迷惑:点击返回成功、`panelInput()` 却 8 秒都等不到;而隔一秒手动点
  4000. * 同一个坐标,400ms 就出面板。这和 `verifyInCommentList` 里"发布后立刻点角标
  4001. * 会被吞"是同一类问题,处理手法也一样(settle + 重试)。
  4002. *
  4003. * ② **不能用 node.click()**。那个无障碍 ACTION_CLICK 返回 true,但 TikTok 不响应。
  4004. * 这一竖条按钮又窄(宽 128px)位置又固定,坐标点击才可靠。
  4005. */
  4006. function openByCommentButton(config, entry, story) {
  4007. var logger = TikTokAuto.logger;
  4008. var tiktok = TikTokAuto.tiktok;
  4009. var attempts = 0;
  4010. var maxAttempts = 2;
  4011. var ready = false;
  4012. while (!ready && attempts < maxAttempts) {
  4013. attempts += 1;
  4014. TikTokAuto.wait.waitQuiet(function () {
  4015. return TikTokAuto.layout.signature();
  4016. }, { maxMs: 1500, tickMs: 200, stableTimes: 2 });
  4017. var point = TikTokAuto.layout.center(entry);
  4018. if (point) {
  4019. logger.info(story, '第 ' + attempts + ' 次坐标点击评论入口 @(' +
  4020. point.x + ',' + point.y + ')');
  4021. TikTokAuto.ui.tapPoint(point.x, point.y);
  4022. } else {
  4023. logger.warn(story, '算不出评论入口的中心点,改用无障碍点击');
  4024. TikTokAuto.ui.clickNode(entry, story);
  4025. }
  4026. ready = TikTokAuto.wait.waitFor(story, function () {
  4027. return tiktok.panelInput() !== null ? true : null;
  4028. }, 4000, config.probe.pollTickMs) === true;
  4029. if (!ready) {
  4030. /* 页面可能重排过,旧节点已失效 —— 重新找一个再试 */
  4031. entry = tiktok.commentButton();
  4032. if (!entry) {
  4033. logger.warn(story, '重试时找不到评论入口了');
  4034. break;
  4035. }
  4036. }
  4037. }
  4038. return ready;
  4039. }
  4040. /** 收起面板(只在确认面板处于打开状态时调用,绝不无脑 back) */
  4041. function closePanel(step) {
  4042. var tiktok = TikTokAuto.tiktok;
  4043. if (!tiktok.commentPanelOpen()) {
  4044. return false;
  4045. }
  4046. TikTokAuto.logger.info(step || 'closeCommentPanel', '收起评论面板');
  4047. back();
  4048. TikTokAuto.wait.until(function () {
  4049. return tiktok.commentPanelOpen() ? null : true;
  4050. }, 4000, 200);
  4051. return true;
  4052. }
  4053. /**
  4054. * 发送一条评论。
  4055. *
  4056. * @returns {ok, via, text, countBefore, countAfter, reason}
  4057. */
  4058. function post(config, text, step) {
  4059. var story = step || 'postComment';
  4060. var logger = TikTokAuto.logger;
  4061. var tiktok = TikTokAuto.tiktok;
  4062. var countBefore = tiktok.commentCount();
  4063. var before = { countBefore: countBefore };
  4064. if (!openPanel(config, story)) {
  4065. return { ok: false, via: 'panel-not-opened', text: text, reason: '评论面板未打开' };
  4066. }
  4067. var input = tiktok.panelInput();
  4068. var typed = TikTokAuto.ui.typeInto(input, text, story);
  4069. if (!typed.ok) {
  4070. return { ok: false, via: 'type-failed', text: text, reason: '输入框回读不一致' };
  4071. }
  4072. if (config.runtime.dryRun) {
  4073. logger.warn(story, 'dryRun 模式:已输入但**不发送**,清空并收起面板。待发文案=' + JSON.stringify(text));
  4074. var restore = tiktok.panelInput();
  4075. if (restore) {
  4076. restore.setText('');
  4077. }
  4078. TikTokAuto.wait.tick(300);
  4079. closePanel(story);
  4080. return { ok: true, via: 'dry-run', text: text, countBefore: countBefore, countAfter: countBefore, skipped: true };
  4081. }
  4082. var button = tiktok.sendButton(config);
  4083. if (!button) {
  4084. return { ok: false, via: 'send-button-not-found', text: text, reason: '定位不到发送按钮' };
  4085. }
  4086. /* ★ 发送键必须用**坐标点击**(2026-10-28 实测):
  4087. * 它是个纯图标按钮,无障碍 ACTION_CLICK 打上去会落到祖先容器 ——
  4088. * 实测点到了屏幕正中 (540,1465),那是评论列表的内容区,等于什么都没发。
  4089. * 而它形状固定、位置刚从输入框推出来,坐标点最准。 */
  4090. var sendPoint = TikTokAuto.layout.center(button);
  4091. if (sendPoint) {
  4092. logger.info(story, '坐标点击发送键 @(' + sendPoint.x + ',' + sendPoint.y + ')');
  4093. TikTokAuto.ui.tapPoint(sendPoint.x, sendPoint.y);
  4094. } else {
  4095. TikTokAuto.ui.clickNode(button, story);
  4096. }
  4097. return awaitOutcome(config, text, before, story);
  4098. }
  4099. /**
  4100. * 等待并判定"评论是否真的发出去了",输出**带置信度**的结论。
  4101. *
  4102. * 证据分三级,从强到弱:
  4103. * 高 comment-list —— 评论列表里真的出现了这条文案(地面真相)
  4104. * 中 comment-count —— 列表头部计数自增
  4105. * 低 panel-closed —— 面板收起 + 输入框复位为占位符
  4106. *
  4107. * 为什么不能只用计数(实测踩到):视频页那个评论角标不一定会立刻刷新,
  4108. * 明明发成功却读回旧值。反过来,只看"面板关了"又可能被平台拒评骗过。
  4109. * 所以用 waitFor 逐级升级证据,并把最终置信度**如实**报给上层。
  4110. */
  4111. function awaitOutcome(config, text, before, step) {
  4112. var logger = TikTokAuto.logger;
  4113. var tiktok = TikTokAuto.tiktok;
  4114. // 必要条件:面板收起且输入框复位
  4115. var closed = TikTokAuto.wait.waitFor(step, function () {
  4116. if (tiktok.commentPanelOpen()) {
  4117. return null;
  4118. }
  4119. /* 播放页要等内联输入框真的回来 —— 这比"面板关了"更严(说明回到了视频页)。
  4120. * 但**信息流底部是导航栏、本来就没有内联输入框**:那里再等它就永远等不到,
  4121. * 明明发成功了也会被判成"面板没有收起"。所以那种形态只认"回到了 feed"。
  4122. */
  4123. if (tiktok.inlineInput() !== null) {
  4124. return true;
  4125. }
  4126. return tiktok.pageState() === 'feed' ? true : null;
  4127. }, config.timeouts.postMs, config.probe.pollTickMs);
  4128. if (closed === null) {
  4129. logger.error(step, '发送后面板未收起,疑似未发布成功');
  4130. closePanel(step);
  4131. return { ok: false, via: 'panel-still-open', text: text, reason: '发送后面板没有收起' };
  4132. }
  4133. // 中级证据:评论数自增。
  4134. // **必须"等"而不是"看一眼"**——实测角标有延迟:发布返回后立刻读还是旧值,
  4135. // 大约 1~2 秒后才 +1。看一眼就下结论的话,明明发成功了也会被判成弱证据。
  4136. var counted = TikTokAuto.wait.waitFor(step, function () {
  4137. var after = tiktok.commentCount();
  4138. if (after === null || before.countBefore === null) {
  4139. return null;
  4140. }
  4141. return after !== before.countBefore ? after : null;
  4142. }, config.timeouts.countRefreshMs, 400);
  4143. if (counted !== null) {
  4144. logger.info(step, '评论已发布(计数自增)' + before.countBefore + '→' + counted + ' ' + JSON.stringify(text));
  4145. return {
  4146. ok: true, via: 'comment-count', confidence: 'medium', text: text,
  4147. countBefore: before.countBefore, countAfter: counted
  4148. };
  4149. }
  4150. // 高级证据:去评论列表里找这条文案(地面真相)
  4151. if (config.comments.verifyInList) {
  4152. var inList = verifyInCommentList(config, text, before, step);
  4153. if (inList) {
  4154. logger.info(step, '评论已发布(列表确认 via=' + inList.via + ')内容=' +
  4155. JSON.stringify(text) + ' 计数=' + inList.count);
  4156. return {
  4157. ok: true, via: 'comment-list', confidence: 'high', text: text,
  4158. countBefore: before.countBefore, countAfter: inList.count
  4159. };
  4160. }
  4161. if (config.comments.strictPostCheck) {
  4162. logger.error(step, '严格校验开启但评论列表里找不到该文案,判定为失败');
  4163. return {
  4164. ok: false, via: 'not-found-in-list', text: text,
  4165. countBefore: before.countBefore, countAfter: tiktok.commentCount(),
  4166. reason: '评论列表里找不到刚发布的文案'
  4167. };
  4168. }
  4169. }
  4170. logger.warn(step, '评论已发布,但只有弱证据(面板收起)。计数=' +
  4171. before.countBefore + '→' + tiktok.commentCount() + ' 内容=' + JSON.stringify(text));
  4172. return {
  4173. ok: true, via: 'panel-closed-and-input-reset', confidence: 'low', text: text,
  4174. countBefore: before.countBefore, countAfter: tiktok.commentCount()
  4175. };
  4176. }
  4177. /**
  4178. * 打开评论列表,找这条评论是否在列表里。
  4179. * 返回 {count} 或 null。无论成败都会把面板收回去,不污染后续步骤。
  4180. *
  4181. * 为什么要"重试 + 等页面安静":紧跟着发布之后点评论角标,面板还在收起动画里,
  4182. * 这一下点击会被吞掉(实测:列表死活不出来,等到超时)。
  4183. * 所以每次点击前先等签名稳定,并且允许最多点三次。
  4184. */
  4185. function verifyInCommentList(config, text, before, step) {
  4186. var tiktok = TikTokAuto.tiktok;
  4187. var logger = TikTokAuto.logger;
  4188. var clickAttempts = 0;
  4189. var maxClicks = 3;
  4190. var deadline = Date.now() + config.timeouts.panelReadyMs;
  4191. while (Date.now() < deadline) {
  4192. var headerCount = tiktok.listCommentCount();
  4193. if (headerCount !== null) {
  4194. // 最强:列表里真的出现了这条文案
  4195. if (tiktok.commentListContains(text)) {
  4196. closePanel(step);
  4197. return { via: 'text-in-list', count: headerCount };
  4198. }
  4199. // 次强:列表头部计数变了(说明确实多了一条)
  4200. if (before.countBefore !== null && headerCount !== before.countBefore) {
  4201. closePanel(step);
  4202. return { via: 'list-count', count: headerCount };
  4203. }
  4204. } else if (clickAttempts < maxClicks) {
  4205. settle();
  4206. var button = tiktok.commentButton();
  4207. if (!button) {
  4208. logger.warn(step, '找不到评论角标,跳过列表校验');
  4209. return null;
  4210. }
  4211. clickAttempts += 1;
  4212. logger.info(step, '第 ' + clickAttempts + ' 次打开评论列表');
  4213. TikTokAuto.ui.clickNode(button, step);
  4214. }
  4215. TikTokAuto.wait.tick(400);
  4216. }
  4217. logger.warn(step, '评论列表校验未通过(点了 ' + clickAttempts + ' 次,列表计数=' +
  4218. tiktok.listCommentCount() + ',发布前计数=' + before.countBefore + ')');
  4219. closePanel(step);
  4220. return null;
  4221. }
  4222. /** 等页面签名稳定下来(收起动画结束),再去做下一次点击 */
  4223. function settle() {
  4224. TikTokAuto.wait.waitQuiet(function () {
  4225. return TikTokAuto.layout.signature();
  4226. }, { maxMs: 1500, tickMs: 200, stableTimes: 2 });
  4227. }
  4228. return {
  4229. PLACEHOLDER: PLACEHOLDER,
  4230. pickText: pickText,
  4231. openPanel: openPanel,
  4232. closePanel: closePanel,
  4233. post: post,
  4234. awaitOutcome: awaitOutcome,
  4235. verifyInCommentList: verifyInCommentList
  4236. };
  4237. }());
  4238. /* ============================== src/interact.js ============================== */
  4239. /**
  4240. * interact.js — 播放页的两个轻量互动动作:**点赞** 与 **关注作者**
  4241. *
  4242. * ## 为什么单独一个模块
  4243. * 它们和评论一样是"在播放页上点一下、然后验证真的生效",但比评论简单得多:
  4244. * 不用开面板、不用输入、不用回读。所以不塞进 comment.js(那是评论发布的地盘)。
  4245. *
  4246. * ## 实测锚点(2026-10-28 / TikTok 47.1.4 / 1080×1920)
  4247. *
  4248. * | 元素 | 类 | content-desc | 备注 |
  4249. * |---|---|---|---|
  4250. * | 点赞(未赞) | Button | `Like video. 2.4M likes` | 里面的 ImageView selected=false |
  4251. * | 点赞(已赞) | Button | **`Video liked`** | ImageView selected=**true** |
  4252. * | 关注 | Button | `Follow <作者名>` | 已关注时**整个按钮消失** |
  4253. * | 作者头像 | ImageView | `<作者名> profile` | 用来区分"已关注"和"页面没渲染" |
  4254. *
  4255. * 点赞按钮和分享按钮**共用同一个 resource-id**(`g8g`),所以只能靠 desc 区分,
  4256. * 不要试图用 id 定位。
  4257. *
  4258. * ## 两条安全原则
  4259. *
  4260. * ① **状态不明就不点。** 读不到"当前是否已赞"时,点一下可能把已赞的取消掉 ——
  4261. * 和用户意图正好相反。宁可跳过并如实记录。
  4262. * ② **点了必须验。** 点击成功不代表生效(可能被弹窗挡、可能点偏)。
  4263. * 只有状态真的变了才算数,否则记失败,不虚报。
  4264. *
  4265. * 依赖:TikTokAuto.tiktok / ui / wait / logger
  4266. */
  4267. TikTokAuto.interact = (function () {
  4268. 'use strict';
  4269. function logger() { return TikTokAuto.logger; }
  4270. /**
  4271. * 给当前视频点赞。
  4272. *
  4273. * @returns { ok, skipped?, via, reason? }
  4274. */
  4275. function like(step) {
  4276. var tiktok = TikTokAuto.tiktok;
  4277. var tag = step || 'like';
  4278. var state = tiktok.likeState();
  4279. if (state === 'liked') {
  4280. return { ok: true, skipped: true, via: 'already-liked', reason: '这个视频已经点过赞' };
  4281. }
  4282. if (state !== 'unliked') {
  4283. return {
  4284. ok: false, via: 'state-unknown',
  4285. reason: '读不到点赞状态,跳过(不盲点:可能是已赞,点一下会变成取消)'
  4286. };
  4287. }
  4288. var button = tiktok.likeButton();
  4289. if (!button) {
  4290. return { ok: false, via: 'button-missing', reason: '找不到点赞按钮' };
  4291. }
  4292. TikTokAuto.ui.clickNode(button, tag);
  4293. /* 验证:desc 从 `Like video.` 变成 `Video liked`(或图标 selected 翻转) */
  4294. var done = TikTokAuto.wait.waitFor(tag + ':verify', function () {
  4295. return tiktok.likeState() === 'liked' ? true : null;
  4296. }, 6000, 300);
  4297. if (done === true) {
  4298. logger().info(tag, '点赞成功(状态已变为已赞)');
  4299. return { ok: true, via: 'state-changed' };
  4300. }
  4301. logger().warn(tag, '点赞后状态没有变化(可能被弹窗挡住或点偏了)');
  4302. return { ok: false, via: 'not-verified', reason: '点击后没有变成已赞' };
  4303. }
  4304. /**
  4305. * 关注当前视频的作者。
  4306. *
  4307. * @returns { ok, skipped?, via, reason? }
  4308. */
  4309. function follow(step) {
  4310. var tiktok = TikTokAuto.tiktok;
  4311. var tag = step || 'follow';
  4312. var state = tiktok.followState();
  4313. if (state === 'following') {
  4314. return { ok: true, skipped: true, via: 'already-following', reason: '已经关注过这个作者' };
  4315. }
  4316. if (state !== 'not-following') {
  4317. /* 头像和按钮都读不到 = 页面还没渲染好。
  4318. * 这时不点:点到空白处可能触发别的动作(播放/暂停),没有意义。 */
  4319. return {
  4320. ok: false, via: 'page-not-ready',
  4321. reason: '作者头像与关注按钮都读不到,页面可能还没渲染完'
  4322. };
  4323. }
  4324. var button = tiktok.followButton();
  4325. if (!button) {
  4326. return { ok: false, via: 'button-missing', reason: '找不到关注按钮' };
  4327. }
  4328. TikTokAuto.ui.clickNode(button, tag);
  4329. /* 验证:关注按钮**整个消失**(实测就是这种表现,不是改成 Following) */
  4330. var done = TikTokAuto.wait.waitFor(tag + ':verify', function () {
  4331. return tiktok.followState() === 'following' ? true : null;
  4332. }, 6000, 300);
  4333. if (done === true) {
  4334. logger().info(tag, '关注成功(关注按钮已消失)');
  4335. return { ok: true, via: 'button-gone' };
  4336. }
  4337. logger().warn(tag, '关注后按钮仍在,可能没生效');
  4338. return { ok: false, via: 'not-verified', reason: '点击后关注按钮仍然存在' };
  4339. }
  4340. return {
  4341. like: like,
  4342. follow: follow
  4343. };
  4344. }());
  4345. /* ============================== src/flow.js ============================== */
  4346. /**
  4347. * flow.js — 业务流程编排
  4348. *
  4349. * 这里只写"先做什么、再做什么、失败了怎么办",不写"怎么点"(ui.js)
  4350. * 也不写"TikTok 长什么样"(tiktok.js)。
  4351. * 每一段都是一次"进入某页面 → 等它真的就绪 → 做事 → 校验"的小闭环,
  4352. * 因此任何一步卡住都能从日志里立刻看出卡在哪。
  4353. *
  4354. * 收敛原则(《代码整洁之道》第 3 章):每个函数只做一件事,且**只在一个抽象层级上**。
  4355. * 所以下面几乎没有嵌套循环和条件,读起来就是需求本身。
  4356. *
  4357. * 依赖:TikTokAuto.logger / tiktok / player / comment / ui / wait / popups
  4358. */
  4359. TikTokAuto.flow = (function () {
  4360. 'use strict';
  4361. function logger() { return TikTokAuto.logger; }
  4362. // ------------------------------------------------------------ 启动与就位
  4363. function launchApp(config) {
  4364. var step = 'launch';
  4365. var pkg = config.target.packageName;
  4366. /* ★ 已经在前台就**绝对别动它**。
  4367. *
  4368. * `app.launchPackage()` 不是"切到前台",而是"重新打开这个 app" ——
  4369. * 即使 app 已经在前台,它也会再发一次 startActivity,把叠在主界面上的
  4370. * 页面(视频播放页、评论面板、用户列表)清掉,退回首页。
  4371. *
  4372. * 对「当前屏幕」类取材这是致命的:用户手动把 TikTok 停在评论区,
  4373. * 那正是这个取材源的全部前提,而引擎一启动就把它退成了 feed,
  4374. * 然后再报"取材设置为当前屏幕,但当前不在播放页"。
  4375. * 实测(2026-10-22):日志里 `launch 就绪(56ms)` 看着毫秒级完成、
  4376. * 什么都没干,实际上页面已经被重置了 —— 时间是 56ms,副作用却不可逆。
  4377. */
  4378. if (TikTokAuto.layout.foregroundPackage() === pkg) {
  4379. logger().info(step, '已在前台,保持当前页面不动');
  4380. return true;
  4381. }
  4382. logger().step(step, '启动 ' + pkg);
  4383. app.launchPackage(pkg);
  4384. var ready = TikTokAuto.wait.waitFor(step, function () {
  4385. return TikTokAuto.layout.foregroundPackage() === pkg ? true : null;
  4386. }, config.timeouts.appLaunchMs, config.probe.pollTickMs);
  4387. return ready === true;
  4388. }
  4389. /**
  4390. * 保证停在信息流首页。
  4391. *
  4392. * 早先的写法有两个坑(实测踩到):
  4393. * 1. 一上来就 `waitFor(feed, 15s)` —— 明明已经在播放页,却先白等 15 秒;
  4394. * 2. 循环里最后按完 back 不回头复查,4 次退完刚好差一步,然后误判"退不回去"去重启。
  4395. * 现在改成:**先看再动**,每退一步都等页面真的换了(按签名变化判断,不是固定 sleep),
  4396. * 并且只在确认当前不是首页时才按返回——避免把正常页面退掉或退出 App。
  4397. */
  4398. function ensureFeed(config) {
  4399. var step = 'ensureFeed';
  4400. var log = logger();
  4401. var tiktok = TikTokAuto.tiktok;
  4402. var tiktokLayout = TikTokAuto.layout;
  4403. var deadline = Date.now() + 25000;
  4404. var unknownRounds = 0;
  4405. while (Date.now() < deadline) {
  4406. var state = tiktok.pageState();
  4407. if (state === 'feed') {
  4408. log.info(step, '已处于信息流首页');
  4409. return true;
  4410. }
  4411. if (state === 'unknown') {
  4412. // 页面可能还在渲染。但连续多轮都认不出来,说明有东西盖在上面
  4413. // (弹窗、半透明遮罩、异常页),这时**必须**试着按返回脱离,
  4414. // 否则就是原地等到超时、然后"重启大法"。
  4415. unknownRounds += 1;
  4416. if (unknownRounds >= 4) {
  4417. log.warn(step, '页面状态连续 ' + unknownRounds + ' 轮未知,按返回尝试脱离');
  4418. back();
  4419. TikTokAuto.wait.waitQuiet(function () {
  4420. return tiktokLayout.signature();
  4421. }, { maxMs: 2000, tickMs: 200, stableTimes: 2 });
  4422. unknownRounds = 0;
  4423. } else {
  4424. TikTokAuto.wait.tick(700);
  4425. }
  4426. continue;
  4427. }
  4428. unknownRounds = 0;
  4429. log.info(step, '当前在 ' + state + ',返回上一页');
  4430. back();
  4431. TikTokAuto.wait.waitQuiet(function () {
  4432. return tiktokLayout.signature();
  4433. }, { maxMs: 2500, tickMs: 200, stableTimes: 2 });
  4434. }
  4435. log.warn(step, '多次返回仍未到首页,重新启动应用');
  4436. launchApp(config);
  4437. return TikTokAuto.wait.waitFor(step, function () {
  4438. return tiktok.pageState() === 'feed' ? true : null;
  4439. }, config.timeouts.pageReadyMs, config.probe.pollTickMs) === true;
  4440. }
  4441. // ------------------------------------------------------------ 搜索
  4442. function openSearchPage(config) {
  4443. var step = 'openSearch';
  4444. var entry = TikTokAuto.tiktok.searchEntry();
  4445. if (!entry) {
  4446. logger().error(step, '首页找不到搜索入口');
  4447. return false;
  4448. }
  4449. TikTokAuto.ui.clickNode(entry, step);
  4450. return TikTokAuto.wait.waitFor(step, function () {
  4451. if (TikTokAuto.tiktok.pageState() === 'search') {
  4452. return true;
  4453. }
  4454. // 结果页也算"搜索这条链走通了",直接复用
  4455. return TikTokAuto.tiktok.pageState() === 'results' ? true : null;
  4456. }, config.timeouts.pageReadyMs, config.probe.pollTickMs) === true;
  4457. }
  4458. function submitKeyword(config) {
  4459. var step = 'submitKeyword';
  4460. var tiktok = TikTokAuto.tiktok;
  4461. if (tiktok.pageState() === 'results') {
  4462. logger().info(step, '已经在结果页,跳过输入');
  4463. return true;
  4464. }
  4465. var input = tiktok.searchInput();
  4466. if (!input) {
  4467. logger().error(step, '找不到搜索输入框');
  4468. return false;
  4469. }
  4470. TikTokAuto.ui.clickNode(input, step);
  4471. TikTokAuto.wait.tick(250);
  4472. var typed = TikTokAuto.ui.typeInto(tiktok.searchInput() || input, config.target.keyword, step);
  4473. if (!typed.ok) {
  4474. return false;
  4475. }
  4476. var submit = tiktok.searchSubmit();
  4477. if (!submit) {
  4478. logger().error(step, '找不到搜索提交按钮');
  4479. return false;
  4480. }
  4481. TikTokAuto.ui.clickNode(submit, step);
  4482. return TikTokAuto.wait.waitFor(step, function () {
  4483. return tiktok.pageState() === 'results' ? true : null;
  4484. }, config.timeouts.searchResultsMs, config.probe.pollTickMs) === true;
  4485. }
  4486. /**
  4487. * 等结果网格真的渲染出卡片(而不是页面框架出来了就算完)。
  4488. *
  4489. * @param timeoutMs 可选。不传用 `searchResultsMs`(20 秒,够首屏/切页签重刷)。
  4490. * 主循环里"滚动之前"的那次只值几秒 —— 到底时不该白等 20 秒。
  4491. * @returns 卡片数组(成功)或 null(超时)
  4492. */
  4493. function waitForCards(config, timeoutMs) {
  4494. var step = 'waitForCards';
  4495. return TikTokAuto.wait.waitFor(step, function () {
  4496. var cards = TikTokAuto.tiktok.videoCards();
  4497. return cards.length > 0 ? cards : null;
  4498. }, timeoutMs || config.timeouts.searchResultsMs, config.probe.pollTickMs);
  4499. }
  4500. function selectResultTab(config) {
  4501. var step = 'selectResultTab';
  4502. var name = config.target.resultTab;
  4503. if (!name) {
  4504. return false;
  4505. }
  4506. var tiktok = TikTokAuto.tiktok;
  4507. /* ★ 已经是目标页签就**不要点**(2026-10-28 实测)。
  4508. *
  4509. * 「Top」是结果页的默认页签,而我们默认配置的 resultTab 也是 Top ——
  4510. * 等于每次运行都会点一次当前页签。点已选中的页签会让 TikTok 重刷整个网格:
  4511. * 卡片先消失、再重新渲染,中间那段时间 videoCards() 返回 0。
  4512. * 后面 `waitQuiet` 又判断不出"列表正在重刷"(签名和点之前一样),
  4513. * 于是直接进了主循环 → 读到 0 张卡 → 滚一下 → 还是 0 → 报「已无更多卡片」。
  4514. * 整个流程零报错地失败,看着像"这个关键词没有视频"。
  4515. */
  4516. if (tiktok.resultTabSelected(name)) {
  4517. logger().info(step, '页签已经是 ' + name + ',跳过点击(点了会重刷列表)');
  4518. return true;
  4519. }
  4520. var tab = tiktok.resultTab(name);
  4521. if (!tab) {
  4522. logger().warn(step, '找不到页签 ' + name + ',沿用当前页签');
  4523. return false;
  4524. }
  4525. logger().info(step, '切换到页签 ' + name);
  4526. TikTokAuto.ui.clickNode(tab, step);
  4527. /* 切页签一定会重刷网格 → 判据必须是"卡片真的重新出现了",
  4528. * 不能再用 waitQuiet(它会把"还在加载"当成"已经安静")。 */
  4529. if (!waitForCards(config)) {
  4530. logger().warn(step, '切到 ' + name + ' 后没等到卡片');
  4531. return false;
  4532. }
  4533. return true;
  4534. }
  4535. /** 结果页滚动加载更多卡片 */
  4536. function scrollResults(config) {
  4537. var layout = TikTokAuto.layout;
  4538. var screen = layout.screen();
  4539. logger().info('scrollResults', '结果页下拉加载更多');
  4540. TikTokAuto.ui.dragScreen(
  4541. Math.floor(screen.width * 0.25), Math.floor(screen.height * 0.8),
  4542. Math.floor(screen.width * 0.25), Math.floor(screen.height * 0.35),
  4543. 400
  4544. );
  4545. TikTokAuto.wait.waitQuiet(function () {
  4546. return TikTokAuto.layout.signature();
  4547. }, { maxMs: 4000, tickMs: 250, stableTimes: 3 });
  4548. }
  4549. // ------------------------------------------------------------ 选片与观看
  4550. /**
  4551. * 从当前屏的卡片里挑一张没试过的。
  4552. *
  4553. * @param visited 已处理过的 caption 列表(用于跨屏去重)
  4554. * @param tried 已尝试过的**容器矩形**列表
  4555. *
  4556. * ★ 为什么还要按矩形去重(2026-10-28 修):
  4557. * 同一张卡片的文案在不同时刻会读到**不同的截断**:一次是 `swagger #fashion …`,
  4558. * 另一次是 `… #fashion #outfitinspo #fyp`(前面被省略号截掉)。按 caption 去重认不出
  4559. * 是同一张,于是"点开→不是视频→跳过→又点到它"循环 11 次才被 guard 拦住。
  4560. * 容器矩形在没滚动时是稳定的,用它兜住这一层。
  4561. *
  4562. * 也去掉了"全都试过就退回全量再挑一遍"的兜底 —— 那个兜底正是死循环的入口。
  4563. * 全试过时返回 null,由调用方决定是滚动还是收工。
  4564. */
  4565. function chooseCard(config, cards, visited, tried) {
  4566. var fresh = [];
  4567. for (var i = 0; i < cards.length; i += 1) {
  4568. if (visited.indexOf(cards[i].caption) !== -1) {
  4569. continue;
  4570. }
  4571. if (cardAlreadyTried(cards[i], tried)) {
  4572. continue;
  4573. }
  4574. fresh.push(cards[i]);
  4575. }
  4576. if (fresh.length === 0) {
  4577. return null;
  4578. }
  4579. if (config.target.pickStrategy === 'first') {
  4580. return fresh[0];
  4581. }
  4582. return fresh[Math.floor(Math.random() * fresh.length)];
  4583. }
  4584. /** 容器矩形是否已在"试过"名单里(容差 40px,容忍布局微抖) */
  4585. function cardAlreadyTried(card, tried) {
  4586. if (!tried || tried.length === 0) {
  4587. return false;
  4588. }
  4589. var box = null;
  4590. try {
  4591. box = card.container.bounds();
  4592. } catch (ignored) {
  4593. return false;
  4594. }
  4595. if (!box) {
  4596. return false;
  4597. }
  4598. for (var i = 0; i < tried.length; i += 1) {
  4599. var one = tried[i];
  4600. if (Math.abs(one.left - box.left) <= 40 &&
  4601. Math.abs(one.top - box.top) <= 40 &&
  4602. Math.abs(one.right - box.right) <= 40) {
  4603. return true;
  4604. }
  4605. }
  4606. return false;
  4607. }
  4608. /** 把一张卡的容器矩形记进"试过"名单 */
  4609. function rememberCard(card, tried) {
  4610. try {
  4611. var box = card.container.bounds();
  4612. if (box) {
  4613. tried.push({ left: box.left, top: box.top, right: box.right });
  4614. }
  4615. } catch (ignored) {
  4616. /* 记不上就记不上,caption 那一层去重仍在 */
  4617. }
  4618. }
  4619. function openCard(config, card) {
  4620. var step = 'openCard';
  4621. logger().step(step, '打开视频:' + JSON.stringify(String(card.caption).slice(0, 40)));
  4622. TikTokAuto.ui.clickNode(card.container, step);
  4623. return TikTokAuto.player.waitPlayerReady(config, step);
  4624. }
  4625. /**
  4626. * 从播放页回到结果页。
  4627. *
  4628. * 必须允许连按多次:校验评论时会把评论列表打开,此时状态是 comment-panel,
  4629. * 一次 back 只够收起面板,得再退一次才真正回到结果页。
  4630. */
  4631. function returnToResults(config) {
  4632. var step = 'returnToResults';
  4633. var log = logger();
  4634. var tiktok = TikTokAuto.tiktok;
  4635. var tiktokLayout = TikTokAuto.layout;
  4636. var deadline = Date.now() + config.timeouts.pageReadyMs;
  4637. var backs = 0;
  4638. var unknownStreak = 0;
  4639. while (Date.now() < deadline && backs < 6) {
  4640. var state = tiktok.pageState();
  4641. if (state === 'results') {
  4642. return true;
  4643. }
  4644. if (state === 'unknown') {
  4645. /* ★ 连续 unknown 也要按返回(2026-10-28 修)。
  4646. *
  4647. * 原来是"unknown 就只等 500ms、绝不按返回",于是卡在
  4648. * **照片轮播帖 / 商品页**这类 pageState 认不出的页面时,
  4649. * 15 秒预算会被 500ms 的等待耗完,一次返回都没按过,
  4650. * 最后报「未能回到结果页,跳过后续取片」——整个循环就此中断。
  4651. *
  4652. * 页面刚切换的头 1.5 秒确实是 unknown(还在渲染),所以先让两次;
  4653. * 之后就必须动。
  4654. */
  4655. unknownStreak += 1;
  4656. if (unknownStreak < 3) {
  4657. TikTokAuto.wait.tick(500);
  4658. continue;
  4659. }
  4660. } else {
  4661. unknownStreak = 0;
  4662. }
  4663. backs += 1;
  4664. log.info(step, '当前在 ' + state + ',返回上一页');
  4665. back();
  4666. TikTokAuto.wait.waitQuiet(function () {
  4667. return tiktokLayout.signature();
  4668. }, { maxMs: 2500, tickMs: 200, stableTimes: 2 });
  4669. }
  4670. log.warn(step, '未能回到结果页,跳过后续取片');
  4671. return false;
  4672. }
  4673. // ------------------------------------------------------------ 主流程
  4674. /**
  4675. * 上报进度给界面宿主(未接宿主时是空操作,见 bridge.js)。
  4676. * 只在这里包一层是为了让下面的流程读起来仍是"业务语言",
  4677. * 不必到处重复 status:'running' 这种传输细节。
  4678. */
  4679. function progress(phase, done, total) {
  4680. TikTokAuto.bridge.report({
  4681. status: 'running',
  4682. phase: phase,
  4683. done: done,
  4684. total: total
  4685. });
  4686. }
  4687. /**
  4688. * 跑一轮完整会话:启动 → 搜索 → 逐个视频「看 → 评」。
  4689. * @returns 结构化结果,会被 main.js 打印成一行 RESULT 供 PC 侧断言
  4690. */
  4691. function runSession(config) {
  4692. var summary = {
  4693. ok: false,
  4694. keyword: config.target.keyword,
  4695. requestedVideos: config.target.maxVideos,
  4696. attemptedVideos: 0,
  4697. postedComments: 0,
  4698. /* 四个动作各自的成功计数:只有"真的做了"才加,跳过不算 */
  4699. likedVideos: 0,
  4700. followedAuthors: 0,
  4701. /* 用户是否勾了这两个动作 —— 用来决定结果文案里要不要提它们
  4702. * (勾了但全是"已赞"时显示 0 是有意义的信息;没勾就干脆不提) */
  4703. likesRequested: config.likes && config.likes.enabled === true,
  4704. followsRequested: config.follows && config.follows.enabled === true,
  4705. details: [],
  4706. /* 不算失败但值得说的事(例:搜索结果里混着商品卡,点开不是视频就跳过)。
  4707. * 必须在这里初始化 —— 后面直接 push,不初始化就是运行期 TypeError,
  4708. * 而 AutoX 上这种异常不弹窗、不报错,只表现成"流程莫名停住"。 */
  4709. warnings: [],
  4710. errors: []
  4711. };
  4712. var total = config.target.maxVideos;
  4713. progress('\u542f\u52a8\u5e94\u7528', 0, total);
  4714. if (!launchApp(config)) {
  4715. summary.errors.push('启动应用失败');
  4716. return summary;
  4717. }
  4718. progress('\u8fdb\u5165\u4fe1\u606f\u6d41', 0, total);
  4719. if (!ensureFeed(config)) {
  4720. summary.errors.push('未能进入信息流首页');
  4721. return summary;
  4722. }
  4723. progress('\u641c\u7d22 ' + config.target.keyword, 0, total);
  4724. if (!openSearchPage(config) || !submitKeyword(config)) {
  4725. summary.errors.push('搜索流程失败');
  4726. return summary;
  4727. }
  4728. if (!waitForCards(config)) {
  4729. summary.errors.push('搜索结果里没有可用卡片');
  4730. return summary;
  4731. }
  4732. selectResultTab(config);
  4733. var visited = [];
  4734. var tried = [];
  4735. var usedComments = [];
  4736. var guard = 0;
  4737. while (summary.attemptedVideos < config.target.maxVideos && guard < config.target.maxVideos * 3) {
  4738. guard += 1;
  4739. progress('\u6b63\u5728\u5904\u7406\u7b2c ' + (summary.attemptedVideos + 1) + '/' + total + ' \u4e2a\u89c6\u9891',
  4740. summary.attemptedVideos, total);
  4741. var cards = TikTokAuto.tiktok.videoCards();
  4742. if (cards.length === 0) {
  4743. /* ★ 先"等"再"滚"(2026-10-28 修)。
  4744. *
  4745. * 读到 0 张卡有两种可能:① 真的没有更多了 ② 列表正在重排/重刷
  4746. * (切页签、刚翻过一页、图片加载中)。直接滚会把 ② 当成 ① 而收工。
  4747. * 这里只给 5 秒 —— 真的到底时不该白等满 20 秒。
  4748. */
  4749. cards = waitForCards(config, 5000) || [];
  4750. }
  4751. if (cards.length === 0) {
  4752. scrollResults(config);
  4753. cards = waitForCards(config) || [];
  4754. if (cards.length === 0) {
  4755. /* ★ 卡片用尽不是"错误"(2026-10-28 改)。
  4756. *
  4757. * 搜索结果少的时候(例:关键词 cat 只有 3~4 张视频卡,
  4758. * 中间还夹着 1 张商品卡)会正常用完。以前这里 push 一条 errors,
  4759. * 于是"处理了 3 个视频、目标 4 个"看起来像出了故障。
  4760. *
  4761. * 真正该报错的是**一个都没收到** —— 那说明选词/解析真有问题。
  4762. */
  4763. if (summary.attemptedVideos === 0) {
  4764. summary.errors.push('结果页没有可用的视频卡片');
  4765. } else {
  4766. summary.warnings.push('结果页卡片已用尽:本轮处理 ' +
  4767. summary.attemptedVideos + ' 个(目标 ' + total +
  4768. ' 个),换个关键词或调小目标数量');
  4769. }
  4770. break;
  4771. }
  4772. }
  4773. var card = chooseCard(config, cards, visited, tried);
  4774. /* 当前屏的卡片全试过了(含"试过但打不开"的)→ 滚一屏再看。
  4775. * 绝不能像旧版那样"全都试过就退回全量再挑" —— 那是死循环的入口:
  4776. * 同一张照片帖会被反复点开、反复跳过,直到 guard 用完。 */
  4777. if (!card) {
  4778. scrollResults(config);
  4779. cards = waitForCards(config) || [];
  4780. card = chooseCard(config, cards, visited, tried);
  4781. if (!card) {
  4782. if (summary.attemptedVideos === 0) {
  4783. summary.errors.push('结果页没有可用的视频卡片');
  4784. } else {
  4785. summary.warnings.push('结果页卡片已用尽:本轮处理 ' +
  4786. summary.attemptedVideos + ' 个(目标 ' + total +
  4787. ' 个),换个关键词或调小目标数量');
  4788. }
  4789. break;
  4790. }
  4791. }
  4792. visited.push(card.caption);
  4793. rememberCard(card, tried);
  4794. if (!openCard(config, card)) {
  4795. /* ★ 打不开 ≠ 出错(2026-10-28 实测)。
  4796. *
  4797. * 搜索结果里混着两种"长得像视频卡"的东西:
  4798. * · 广告**视频** —— 点进去是正常播放页(有 Ad 标记 + Shop now),能正常看
  4799. * · 照片轮播帖 / 商品卡 —— 根本没有播放页
  4800. * (照片帖约 2 秒就能判出来,见 player.waitPlayerReady 的 :kind 预判)
  4801. *
  4802. * 以前这里 push 一条 errors,于是"4 个视频里有 1 张照片帖"会让整轮
  4803. * 记录看起来像出了故障。现在改成:记一条 warning、跳过、继续下一张。
  4804. */
  4805. summary.warnings.push('跳过非视频卡片:' + String(card.caption).slice(0, 30));
  4806. logger().info('flow', '点开后没有播放页,当作非视频卡片跳过:' +
  4807. String(card.caption).slice(0, 30));
  4808. returnToResults(config);
  4809. continue;
  4810. }
  4811. var detail = { caption: String(card.caption).slice(0, 60) };
  4812. detail.watch = TikTokAuto.player.watch(config, { step: 'watch' });
  4813. /* 点赞 / 关注:都在播放页上点一下就好,不必跳转。
  4814. * 顺序放在评论**之前** —— 评论会打开面板盖住动作栏,
  4815. * 之后再点赞就得先把面板收掉,多一步且更容易出错。 */
  4816. if (config.likes && config.likes.enabled) {
  4817. detail.like = TikTokAuto.interact.like('like');
  4818. if (detail.like.ok && !detail.like.skipped) {
  4819. summary.likedVideos += 1;
  4820. }
  4821. } else {
  4822. detail.like = { ok: true, skipped: true, reason: '本次未启用点赞动作' };
  4823. }
  4824. if (config.follows && config.follows.enabled) {
  4825. detail.follow = TikTokAuto.interact.follow('follow');
  4826. if (detail.follow.ok && !detail.follow.skipped) {
  4827. summary.followedAuthors += 1;
  4828. }
  4829. } else {
  4830. detail.follow = { ok: true, skipped: true, reason: '本次未启用关注动作' };
  4831. }
  4832. /* 没勾「评论」时只浏览:这不是失败,是一种合法的运行模式
  4833. * (界面上的「执行操作」没勾评论就是这个意思)。 */
  4834. if (config.comments.enabled) {
  4835. var text = TikTokAuto.comment.pickText(config, usedComments, 'comment');
  4836. detail.comment = TikTokAuto.comment.post(config, text, 'comment');
  4837. if (detail.comment.ok && !detail.comment.skipped) {
  4838. usedComments.push(text);
  4839. summary.postedComments += 1;
  4840. }
  4841. } else {
  4842. detail.comment = { ok: true, skipped: true, reason: '本次未启用评论动作' };
  4843. }
  4844. summary.attemptedVideos += 1;
  4845. summary.details.push(detail);
  4846. progress('\u5df2\u5b8c\u6210 ' + summary.attemptedVideos + '/' + total,
  4847. summary.attemptedVideos, total);
  4848. returnToResults(config);
  4849. }
  4850. summary.ok = summary.attemptedVideos > 0;
  4851. return summary;
  4852. }
  4853. return {
  4854. launchApp: launchApp,
  4855. ensureFeed: ensureFeed,
  4856. openSearchPage: openSearchPage,
  4857. submitKeyword: submitKeyword,
  4858. waitForCards: waitForCards,
  4859. selectResultTab: selectResultTab,
  4860. scrollResults: scrollResults,
  4861. chooseCard: chooseCard,
  4862. openCard: openCard,
  4863. returnToResults: returnToResults,
  4864. runSession: runSession
  4865. };
  4866. }());
  4867. /* ============================== src/nurture.js ============================== */
  4868. /**
  4869. * nurture.js — 养号:在首页信息流里随机浏览 + 按概率点赞 / 关注 / 评论
  4870. *
  4871. * 与视频链路(flow.js)的区别只有一句话:**没有目标**。
  4872. * 视频链路是"搜关键词 → 点进指定数量的视频",养号是"打开信息流 → 一个接一个往下刷"。
  4873. * 所以这里复用了 flow 的应用级动作(`launchApp` / `ensureFeed`)与
  4874. * player / interact / comment 三个能力模块,自己只负责**编排节奏**。
  4875. *
  4876. * ## 双约束:轮数与时长,谁先到谁停
  4877. *
  4878. * 界面给的是「单次操作 10~30」(轮数)与「浏览时长 3~8 分钟」。两者**各自独立**,
  4879. * 不是"用一项算出另一项":
  4880. * · 轮数先满 —— 真人式收工,正常结束
  4881. * · 时长先到 —— 可能刚好卡在某个视频中间,如实说明"做了 N 轮,没用满轮数"
  4882. * 所以结果里记 `stoppedBy`,不假装两个都达标。
  4883. *
  4884. * ## 为什么每轮都要重新确认"还在信息流"
  4885. *
  4886. * 实测信息流里会滑到这些东西:**照片轮播帖**(没有播放)、**带货卡**
  4887. * (`Creator earns commission`)、以及可能的**直播**。前两种滑走就好,
  4888. * 直播会让 `pageState()` 变成别的值、上滑也未必切得动。
  4889. * 每轮开工前确认一次,不对就交回 `ensureFeed` 拉回(它自己会按返回,多次失败会重启应用)。
  4890. *
  4891. * 依赖:TikTokAuto.flow / player / interact / comment / tiktok / wait / bridge
  4892. */
  4893. TikTokAuto.nurture = (function () {
  4894. 'use strict';
  4895. /** 连续这么多次上滑都换不了视频,就认为卡死了 */
  4896. var SWIPE_FAIL_LIMIT = 3;
  4897. function logger() { return TikTokAuto.logger; }
  4898. /** 概率命中。rate 是 0~1;0 或非法值一律不命中(宁可不做,不可乱做) */
  4899. function hit(rate) {
  4900. var value = Number(rate);
  4901. if (!(value > 0)) {
  4902. return false;
  4903. }
  4904. return Math.random() < value;
  4905. }
  4906. function progress(phase, done, total) {
  4907. TikTokAuto.bridge.report({
  4908. status: 'running',
  4909. phase: phase,
  4910. done: done,
  4911. total: total
  4912. });
  4913. }
  4914. /**
  4915. * 上滑换下一个视频。
  4916. *
  4917. * 判据是"**新内容真的出现了**",不是"滑动手势发出去了"。
  4918. * 手势会被吞:网络慢、正在加载、恰好卡在上一帧,都会出现"滑了但没动"。
  4919. */
  4920. function nextVideo() {
  4921. var tiktok = TikTokAuto.tiktok;
  4922. var view = TikTokAuto.layout.screen();
  4923. var before = tiktok.feedSignature();
  4924. TikTokAuto.ui.dragScreen(
  4925. Math.floor(view.width * 0.5),
  4926. Math.floor(view.height * 0.78),
  4927. Math.floor(view.width * 0.5),
  4928. Math.floor(view.height * 0.28),
  4929. 280
  4930. );
  4931. var changed = TikTokAuto.wait.waitFor('nextVideo', function () {
  4932. var now = tiktok.feedSignature();
  4933. if (now === '' || now === before) {
  4934. return null;
  4935. }
  4936. return true;
  4937. }, 6000, 250);
  4938. return changed === true;
  4939. }
  4940. /** 做完一个动作后随机停顿一下(真人不会点完立刻滑) */
  4941. function pause(config) {
  4942. var lo = config.nurture.pause.minMs;
  4943. var hi = config.nurture.pause.maxMs;
  4944. TikTokAuto.wait.tick(TikTokAuto.player.randomInt(lo, hi));
  4945. }
  4946. /**
  4947. * 一轮:看一个视频 + 按概率互动。
  4948. *
  4949. * @returns detail(结构与 video 链路的 details[] 同族,便于记录页复用)
  4950. */
  4951. function oneRound(config, index, usedComments, summary) {
  4952. var tiktok = TikTokAuto.tiktok;
  4953. var settings = config.nurture;
  4954. var detail = { index: index + 1 };
  4955. /* 照片帖没有播放页,也没有"播放中"这回事 —— 拿 watch 去等
  4956. * `playbackStartMs`(12 秒)纯属白等。短停留即可,点赞/关注照样能做。 */
  4957. var photo = tiktok.photoPost() !== null;
  4958. detail.photoPost = photo;
  4959. if (photo) {
  4960. var dwell = TikTokAuto.player.randomInt(1500, 4000);
  4961. TikTokAuto.wait.tick(dwell);
  4962. detail.watch = { completed: true, actualMs: dwell, probeMode: 'photo-post' };
  4963. summary.photoPosts += 1;
  4964. } else {
  4965. detail.watch = TikTokAuto.player.watch(config, {
  4966. step: 'watch',
  4967. /* 信息流没有返回箭头、有底部导航 —— `onPlayerPage()` 在这里恒为 false,
  4968. * 不注入这个守卫的话 watch 会在 3 秒内以 left-player-page 自杀。 */
  4969. guard: function () { return tiktok.pageState() === 'feed'; },
  4970. minMs: settings.watch.minMs,
  4971. maxMs: settings.watch.maxMs
  4972. });
  4973. }
  4974. summary.watchedMs += detail.watch.actualMs || 0;
  4975. /* 动作顺序:点赞 → 关注 → **评论放最后**。
  4976. * 评论会打开面板盖住右侧动作栏,之后再点赞就得多一步收面板。 */
  4977. if (hit(settings.likeRate)) {
  4978. summary.likeHits += 1;
  4979. detail.like = TikTokAuto.interact.like('like');
  4980. if (detail.like.ok && !detail.like.skipped) {
  4981. summary.liked += 1;
  4982. }
  4983. pause(config);
  4984. }
  4985. if (hit(settings.followRate)) {
  4986. summary.followHits += 1;
  4987. detail.follow = TikTokAuto.interact.follow('follow');
  4988. if (detail.follow.ok && !detail.follow.skipped) {
  4989. summary.followed += 1;
  4990. }
  4991. pause(config);
  4992. }
  4993. if (config.comments.enabled && hit(settings.commentRate)) {
  4994. summary.commentHits += 1;
  4995. var text = TikTokAuto.comment.pickText(config, usedComments, 'comment');
  4996. detail.comment = TikTokAuto.comment.post(config, text, 'comment');
  4997. if (detail.comment.ok && !detail.comment.skipped) {
  4998. usedComments.push(text);
  4999. summary.commented += 1;
  5000. } else {
  5001. /* 失败了**必须把面板收掉**:面板盖在信息流上时,
  5002. * 后面的上滑会变成"在评论列表里滚动",整个流程就歪了。 */
  5003. TikTokAuto.comment.closePanel('comment:cleanup');
  5004. if (!detail.comment.ok) {
  5005. summary.warnings.push('评论没发出去:' +
  5006. (detail.comment.reason || detail.comment.via || '未知'));
  5007. }
  5008. }
  5009. pause(config);
  5010. }
  5011. return detail;
  5012. }
  5013. /**
  5014. * @param config 生效配置(含 `config.nurture` 与顶层 `config.comments`)
  5015. * @returns summary
  5016. */
  5017. function runSession(config) {
  5018. var log = logger();
  5019. var settings = config.nurture;
  5020. var summary = {
  5021. ok: false,
  5022. task: 'nurture',
  5023. /* 实际做完的轮数 / 本轮随机出来的目标轮数 */
  5024. rounds: 0,
  5025. targetRounds: 0,
  5026. /** 真正在看视频上花掉的毫秒数(不含动作与停顿) */
  5027. watchedMs: 0,
  5028. /* 真的做成功的个数:跳过(已赞/已关注)不计入 */
  5029. liked: 0,
  5030. followed: 0,
  5031. commented: 0,
  5032. /* 概率命中的次数:用来区分"没打算做"和"想做但失败了" */
  5033. likeHits: 0,
  5034. followHits: 0,
  5035. commentHits: 0,
  5036. photoPosts: 0,
  5037. /** 'rounds' | 'time' | 'aborted' —— 是哪个约束先到 */
  5038. stoppedBy: '',
  5039. details: [],
  5040. /* 不算失败但值得说的事。必须在这里初始化:后面直接 push,
  5041. * 不初始化就是运行期 TypeError,而 AutoX 上这种异常不弹窗、只表现成"流程停住"。 */
  5042. warnings: [],
  5043. errors: []
  5044. };
  5045. progress('启动应用', 0, 0);
  5046. if (!TikTokAuto.flow.launchApp(config)) {
  5047. summary.errors.push('启动应用失败');
  5048. return summary;
  5049. }
  5050. progress('进入信息流', 0, 0);
  5051. if (!TikTokAuto.flow.ensureFeed(config)) {
  5052. summary.errors.push('未能进入信息流首页');
  5053. return summary;
  5054. }
  5055. var targetRounds = TikTokAuto.player.randomInt(settings.rounds.min, settings.rounds.max);
  5056. summary.targetRounds = targetRounds;
  5057. var deadline = Date.now() + settings.browse.maxMs;
  5058. var beganAt = Date.now();
  5059. log.step('nurture', '养号开始:目标 ' + targetRounds + ' 轮 / 最长 ' +
  5060. Math.round(settings.browse.maxMs / 60000) + ' 分钟;概率 点赞 ' +
  5061. Math.round(settings.likeRate * 100) + '% / 关注 ' +
  5062. Math.round(settings.followRate * 100) + '% / 评论 ' +
  5063. Math.round(settings.commentRate * 100) + '%');
  5064. var usedComments = [];
  5065. var swipeFailures = 0;
  5066. while (summary.rounds < targetRounds && Date.now() < deadline) {
  5067. var state = TikTokAuto.tiktok.pageState();
  5068. if (state !== 'feed') {
  5069. /* 滑进了直播 / 广告落地页 / 评论面板,或者页面还在加载。
  5070. * 交回 ensureFeed 拉回信息流 —— 别在这里自己写一套"返回几次"的逻辑。 */
  5071. log.warn('nurture', '当前不在信息流(' + state + '),尝试回到信息流');
  5072. if (!TikTokAuto.flow.ensureFeed(config)) {
  5073. summary.warnings.push('中途离开信息流且回不去,提前结束(做了 ' +
  5074. summary.rounds + ' 轮)');
  5075. summary.stoppedBy = 'aborted';
  5076. break;
  5077. }
  5078. }
  5079. progress('第 ' + (summary.rounds + 1) + ' / ' + targetRounds + ' 轮',
  5080. summary.rounds, targetRounds);
  5081. summary.details.push(oneRound(config, summary.rounds, usedComments, summary));
  5082. summary.rounds += 1;
  5083. if (!nextVideo()) {
  5084. swipeFailures += 1;
  5085. log.warn('nurture', '上滑后内容没变(连续第 ' + swipeFailures + ' 次)');
  5086. if (swipeFailures >= SWIPE_FAIL_LIMIT) {
  5087. summary.warnings.push('连续 ' + swipeFailures +
  5088. ' 次上滑都换不了视频,提前结束(做了 ' + summary.rounds + ' 轮)');
  5089. summary.stoppedBy = 'aborted';
  5090. break;
  5091. }
  5092. } else {
  5093. swipeFailures = 0;
  5094. }
  5095. }
  5096. /* 如实说明是哪个约束先到 —— 不要笼统说"完成"。
  5097. * 轮数先满 vs 时长先到,对用户下一步该调哪个参数完全是指向不同。 */
  5098. if (!summary.stoppedBy) {
  5099. var byTime = Date.now() >= deadline;
  5100. var byRounds = summary.rounds >= targetRounds;
  5101. summary.stoppedBy = byTime && byRounds ? 'rounds' : (byRounds ? 'rounds' : 'time');
  5102. }
  5103. if (summary.stoppedBy === 'time') {
  5104. summary.warnings.push('浏览时长到了(' +
  5105. Math.round(settings.browse.maxMs / 60000) + ' 分钟),只做了 ' +
  5106. summary.rounds + '/' + targetRounds + ' 轮 —— 想多做几轮请把时长上限调大');
  5107. }
  5108. summary.ok = summary.rounds > 0;
  5109. if (!summary.ok && summary.errors.length === 0) {
  5110. summary.errors.push('一轮都没能完成(信息流没有加载出来?)');
  5111. }
  5112. log.step('nurture', '养号结束:' + summary.rounds + ' 轮,用时 ' +
  5113. Math.round((Date.now() - beganAt) / 1000) + ' 秒,真正浏览 ' +
  5114. Math.round(summary.watchedMs / 1000) + ' 秒;点赞 ' + summary.liked +
  5115. ' / 关注 ' + summary.followed + ' / 评论 ' + summary.commented +
  5116. '(' + summary.stoppedBy + ' 先到)');
  5117. return summary;
  5118. }
  5119. return {
  5120. runSession: runSession,
  5121. nextVideo: nextVideo,
  5122. hit: hit,
  5123. SWIPE_FAIL_LIMIT: SWIPE_FAIL_LIMIT
  5124. };
  5125. }());
  5126. /* ============================== src/parse.js ============================== */
  5127. /**
  5128. * parse.js — 文本 → 数值 / 语言 / 区间的**纯函数**解析
  5129. *
  5130. * ## 为什么单独一个模块
  5131. * 这三件事都是「界面上的设置真的会拦人」这一主张的证据链:
  5132. * · 数量门槛要先把 "1.6M" 变成 1600000 才谈得上比较;
  5133. * · 语言区域要靠昵称/个签里的字符构成来判;
  5134. * · 区间判定要能表达"不限"和"读不到"这两种不同的"没有值"。
  5135. * 它们全部与界面无关,所以能 100% 离线自检 —— 这是整个 ID 提取里
  5136. * **唯一一处可以用断言证明"逻辑对了"** 的部分。
  5137. *
  5138. * ## 关于数字缩写(真机实测)
  5139. * TikTok 的计数一律是缩写形态,实测到的写法:
  5140. * "594"、"2,491"、"1.6M"、"253.9M"、"245K"、"371.7K"
  5141. * 中文界面下会是 "1.2万" / "1.5亿"。所以后缀要同时认 K/M/B 与 万/亿。
  5142. *
  5143. * 依赖:无(纯函数,不取任何模块引用)
  5144. */
  5145. TikTokAuto.parse = (function () {
  5146. 'use strict';
  5147. /* ---------------------------------------------------------- 数字 */
  5148. /** 后缀倍数。键统一小写;`二`的两种写法(万/萬)都认。 */
  5149. var SUFFIX = {
  5150. '': 1,
  5151. 'k': 1000,
  5152. 'm': 1000000,
  5153. 'b': 1000000000,
  5154. 'w': 10000, /* 万:中文界面 "1.2万" */
  5155. '万': 10000,
  5156. '萬': 10000,
  5157. '亿': 100000000,
  5158. '億': 100000000
  5159. };
  5160. /**
  5161. * 把一个计数字符串解析成整数。
  5162. *
  5163. * 返回 **null** 表示"读不到",与 **0** 严格区分:
  5164. * "0 followers" 是真实的值(一个粉丝都没有),"读不到"是另一回事 ——
  5165. * 数量门槛的判定要靠这个区别决定是"拦下"还是"放行"。
  5166. *
  5167. * @param raw 原始文本,如 "1.6M followers" / "2,491" / "1.2万"
  5168. * @returns {number|null}
  5169. */
  5170. function count(raw) {
  5171. if (raw === null || raw === undefined) {
  5172. return null;
  5173. }
  5174. /* 统一全角数字与全角逗号:某些语言/输入法会给出全角 */
  5175. var text = String(raw)
  5176. .replace(/[0-9]/g, function (ch) {
  5177. return String.fromCharCode(ch.charCodeAt(0) - 0xFEE0);
  5178. })
  5179. .replace(/[,]/g, ',');
  5180. /* 抓「数字 + 可选小数 + 可选后缀」。前面的自由文本("1.6M followers")不影响。
  5181. * 后缀只认白名单里的字符,避免把 "594 followers" 的 f 当成后缀。 */
  5182. var matched = text.match(/(\d[\d,]*(?:\.\d+)?)\s*([kKmMbBwW万萬亿億]?)/);
  5183. if (!matched) {
  5184. return null;
  5185. }
  5186. var number = Number(String(matched[1]).replace(/,/g, ''));
  5187. if (isNaN(number)) {
  5188. return null;
  5189. }
  5190. var suffix = String(matched[2] || '').toLowerCase();
  5191. var factor = SUFFIX[suffix];
  5192. if (factor === undefined) {
  5193. /* 后缀不在白名单(例如 "594 followers" 的 f 已不会被捕获)→ 按无后缀处理 */
  5194. factor = 1;
  5195. }
  5196. return Math.round(number * factor);
  5197. }
  5198. /* ---------------------------------------------------------- 语言 */
  5199. /** CJK 汉字(含扩展 A 与兼容区)与假名 —— 判定"中文内容"用 */
  5200. var CJK = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\u3040-\u30ff]/;
  5201. var LATIN = /[A-Za-z\u00c0-\u024f]/; /* 含带音标的拉丁字母(西/法/德用户昵称很常见) */
  5202. /**
  5203. * 估算一段文本的语种。只返回三类,够用且不猜。
  5204. *
  5205. * 判定规则(不是拍脑袋,理由在下面):
  5206. * · **只统计字母类字符**,忽略数字/标点/emoji —— 昵称里 emoji 占比极高,
  5207. * 算进分母会把"中文昵称 + 三个 emoji"判成"非中文"。
  5208. * · 中文门槛 **30%**:中文用户常混英文单词("小美的Fashion"),
  5209. * 门槛设成 50% 会把这类人漏掉。
  5210. * · 英文门槛 **70%**:反过来,英文用户极少混中文,
  5211. * 所以"仅英文"要更严格 —— 否则一个 "Fashion 小美" 会同时进两个桶。
  5212. *
  5213. * @returns {string} "zh" | "en" | "mixed" | "none"
  5214. */
  5215. function language(raw) {
  5216. var text = String(raw === null || raw === undefined ? '' : raw);
  5217. if (text === '') {
  5218. return 'none';
  5219. }
  5220. var cjk = 0;
  5221. var latin = 0;
  5222. for (var i = 0; i < text.length; i += 1) {
  5223. var ch = text.charAt(i);
  5224. if (CJK.test(ch)) {
  5225. cjk += 1;
  5226. } else if (LATIN.test(ch)) {
  5227. latin += 1;
  5228. }
  5229. }
  5230. var total = cjk + latin;
  5231. if (total === 0) {
  5232. return 'none'; /* 纯 emoji / 纯数字 / 阿拉伯文等 */
  5233. }
  5234. var zhRatio = cjk / total;
  5235. var enRatio = latin / total;
  5236. if (zhRatio >= 0.3) {
  5237. return 'zh';
  5238. }
  5239. if (enRatio >= 0.7) {
  5240. return 'en';
  5241. }
  5242. return 'mixed';
  5243. }
  5244. /**
  5245. * 语言是否匹配"仅中文 / 仅英文 / 通杀"。
  5246. *
  5247. * `mixed` 在两边都不算匹配 —— 说不清是哪一边的,就不该被任何一边选中。
  5248. * 但 `none`(纯 emoji 昵称)在"通杀"下通过:通杀本来就不筛。
  5249. */
  5250. function languageMatches(want, actual) {
  5251. if (!want || want === 'any') {
  5252. return true;
  5253. }
  5254. if (want === 'zh') {
  5255. return actual === 'zh';
  5256. }
  5257. if (want === 'en') {
  5258. return actual === 'en';
  5259. }
  5260. return true;
  5261. }
  5262. /* ---------------------------------------------------------- 区间 */
  5263. /**
  5264. * 区间判定。
  5265. *
  5266. * 三种状态必须分开表达,这是这个函数存在的理由:
  5267. * · **不限**(min/max 都是 0)→ 永远通过,连值都不用读;
  5268. * · **有值** → 落在 [min, max] 内(0 表示该侧不设限);
  5269. * · **读不到**(value === null)→ **不通过**。
  5270. *
  5271. * 为什么读不到要拦:用户明确设了门槛,说明他在意这个条件;
  5272. * 读不到就无法证明符合,宁可少收并在日志里说清楚 ——
  5273. * 若这里放行,"过滤"就是形同虚设,而用户以为它生效了。
  5274. *
  5275. * @returns {boolean}
  5276. */
  5277. function inRange(value, min, max) {
  5278. var low = Number(min) || 0;
  5279. var high = Number(max) || 0;
  5280. if (low <= 0 && high <= 0) {
  5281. return true;
  5282. }
  5283. if (value === null || value === undefined) {
  5284. return false;
  5285. }
  5286. var number = Number(value);
  5287. if (isNaN(number)) {
  5288. return false;
  5289. }
  5290. if (low > 0 && number < low) {
  5291. return false;
  5292. }
  5293. if (high > 0 && number > high) {
  5294. return false;
  5295. }
  5296. return true;
  5297. }
  5298. /** 该区间是否"什么都没设"(用来判断要不要为此打开用户页) */
  5299. function isRangeOpen(min, max) {
  5300. return (Number(min) || 0) <= 0 && (Number(max) || 0) <= 0;
  5301. }
  5302. /* ---------------------------------------------------------- 用户名 */
  5303. /**
  5304. * 把 "@username" 归一成 "username"。
  5305. *
  5306. * 实测发现用户名文本被 **FID 隔离符** 包裹(U+2068 / U+2069):
  5307. * 显示为 `⁨ajman_fashion_week⁩`,肉眼完全看不出,但直接当文件名/比对键就会出错。
  5308. * `layout.sanitize` 处理了 U+200b-200f 等,**没有**处理 2068/2069(它们不是零宽字符,
  5309. * 是双向文本隔离符),所以这里单独剥一次。
  5310. *
  5311. * 同时剥掉全角 @ 与首尾空白。
  5312. */
  5313. function username(raw) {
  5314. if (raw === null || raw === undefined) {
  5315. return '';
  5316. }
  5317. return String(raw)
  5318. .replace(/[\u2066-\u2069\u202a-\u202e]/g, '')
  5319. .replace(/\s+/g, '')
  5320. .replace(/^[@@]+/, '');
  5321. }
  5322. /**
  5323. * 一个字符串是否像 TikTok 用户名(字母数字下划线点,长度 2~30)。
  5324. *
  5325. * 注意**不能**先走 username() —— 那个函数会去掉所有空白(清洗用途),
  5326. * 于是 "This is a caption" 会被压成 "Thisisacaption" 而"看起来像用户名"。
  5327. * 判定必须**保留原始空白**:真正的用户名里不会有空格。
  5328. */
  5329. function looksLikeUsername(raw) {
  5330. if (raw === null || raw === undefined) {
  5331. return false;
  5332. }
  5333. var name = String(raw)
  5334. .replace(/[\u2066-\u2069\u202a-\u202e]/g, '')
  5335. .replace(/^[\s@@]+|[\s]+$/g, '');
  5336. if (name.length < 2 || name.length > 30) {
  5337. return false;
  5338. }
  5339. if (/\s/.test(name)) {
  5340. return false; /* 中间有空白 → 是文案,不是用户名 */
  5341. }
  5342. return /^[A-Za-z0-9_.]+$/.test(name);
  5343. }
  5344. /* ---------------------------------------------------------- 文件名 */
  5345. /** 两位补零 */
  5346. function pad2(value) {
  5347. return value < 10 ? '0' + value : String(value);
  5348. }
  5349. /**
  5350. * 结果文件名:`前缀_YYYYMMDD_HHmm.txt`
  5351. *
  5352. * 方案 §6.1 是对规格书的有意识偏离:规格书只有日期(`uid_20261013.txt`),
  5353. * 同一天跑两次会互相覆盖 —— 而"同一天调两次参数"恰恰是最常见的用法。
  5354. */
  5355. function timestampName(prefix, date) {
  5356. var d = date || new Date();
  5357. return prefix + '_' + d.getFullYear() + pad2(d.getMonth() + 1) + pad2(d.getDate()) +
  5358. '_' + pad2(d.getHours()) + pad2(d.getMinutes()) + '.txt';
  5359. }
  5360. return {
  5361. count: count,
  5362. language: language,
  5363. languageMatches: languageMatches,
  5364. inRange: inRange,
  5365. isRangeOpen: isRangeOpen,
  5366. username: username,
  5367. looksLikeUsername: looksLikeUsername,
  5368. timestampName: timestampName
  5369. };
  5370. }());
  5371. /* ============================== src/uidset.js ============================== */
  5372. /**
  5373. * uidset.js — 去重集合与结果文件
  5374. *
  5375. * ## 三种"已经见过"
  5376. * 1. **本轮已收**:这次运行里已经写进产物文件的;
  5377. * 2. **历史已收**:之前各轮跑过的(`uid_extracted.txt`,只追加不删除);
  5378. * 3. **输入里已有**:文本取材那份文件本身就列着的("裂变"时,"输入里的人"不该再算新收获)。
  5379. * 三者合成一个集合,`add` 之后就不再重复。区别只在**装载来源**,判定逻辑是同一个。
  5380. *
  5381. * ## 为什么落盘是"追加"而不是"最后一次性写"
  5382. * 用户点了停止、或脚本被系统杀掉时,已经抓到的部分不能全丢。
  5383. * 所以每收 N 个就 append 一次 —— 代价是几条多余的系统调用,收益是"停在哪都留着"。
  5384. *
  5385. * ## 为什么不用 files 的删除接口
  5386. * 引擎产物要过 MCP 的危险调用黑名单(`build.js` 的 `DANGEROUS_RULES` 里有一条按
  5387. * 词边界匹配 `remove` 加左括号),命中后每次下发都要求人工确认。
  5388. * 所以这里**只追加不删除** —— 需要"重来"时由用户自己在文件管理器里删。
  5389. *
  5390. * 依赖:TikTokAuto.logger(调用时取)
  5391. */
  5392. TikTokAuto.uidset = (function () {
  5393. 'use strict';
  5394. /** 每收这么多个就 append 一次。20 是"丢掉的最多几个"与"写盘频率"的折中。 */
  5395. var FLUSH_EVERY = 20;
  5396. /**
  5397. * 去重集合。
  5398. *
  5399. * 内部用普通对象而不是 Set:Rhino 对 ES6 Set 的支持在各版本不一致,
  5400. * 而对象字面量的键查找在所有版本上都是稳的。uid 只会是字母数字下划线点,不会撞 `__proto__`。
  5401. */
  5402. function create() {
  5403. var seen = {};
  5404. var count = 0;
  5405. return {
  5406. /** 见过没有 */
  5407. has: function (uid) {
  5408. return seen[uid] === true;
  5409. },
  5410. /** 记一笔;返回 true 表示这是新加的(之前没见过) */
  5411. add: function (uid) {
  5412. if (seen[uid] === true) {
  5413. return false;
  5414. }
  5415. seen[uid] = true;
  5416. count += 1;
  5417. return true;
  5418. },
  5419. /** 已记多少(含历史装载的) */
  5420. size: function () {
  5421. return count;
  5422. }
  5423. };
  5424. }
  5425. /**
  5426. * 读一批文本行,返回去重后的 uid 数组。
  5427. * 跳过空行、`#` 注释行、以及带 @ 的行(有些用户会直接粘贴 "@name",我们容忍这种输入)。
  5428. */
  5429. function readUidFile(path) {
  5430. var out = [];
  5431. try {
  5432. if (!path || !files.exists(path)) {
  5433. return out;
  5434. }
  5435. var lines = String(files.read(path)).split(/\r?\n/);
  5436. for (var i = 0; i < lines.length; i += 1) {
  5437. var uid = TikTokAuto.parse.username(lines[i]);
  5438. if (uid !== '' && uid.charAt(0) !== '#') {
  5439. out.push(uid);
  5440. }
  5441. }
  5442. } catch (error) {
  5443. TikTokAuto.logger.warn('uidset', '读取 ' + path + ' 失败:' + error);
  5444. }
  5445. return out;
  5446. }
  5447. /**
  5448. * 从历史文件装载去重集合。
  5449. * 文件不存在 = 第一次跑,不是错误。
  5450. */
  5451. function loadHistory(set, path) {
  5452. var list = readUidFile(path);
  5453. for (var i = 0; i < list.length; i += 1) {
  5454. set.add(list[i]);
  5455. }
  5456. TikTokAuto.logger.info('uidset', '历史去重装载 ' + list.length + ' 条(' + path + ')');
  5457. return list.length;
  5458. }
  5459. /**
  5460. * 结果写入器。
  5461. *
  5462. * 两个文件同时写:
  5463. * · `本次`:这一轮新收的(新建,给人看/给下游吃);
  5464. * · `累计`:所有轮次的(追加,跨轮去重的基准)。
  5465. *
  5466. * @param options { runPath, historyPath, dryRun, historyEnabled }
  5467. */
  5468. function createWriter(options) {
  5469. var pending = [];
  5470. var collected = [];
  5471. var written = 0;
  5472. var flushFailed = false;
  5473. function flush() {
  5474. if (pending.length === 0) {
  5475. return;
  5476. }
  5477. var block = pending.join('\n') + '\n';
  5478. pending = [];
  5479. if (options.dryRun) {
  5480. return; /* 演练模式:不碰任何文件 */
  5481. }
  5482. try {
  5483. files.append(options.runPath, block);
  5484. if (options.historyEnabled) {
  5485. files.append(options.historyPath, block);
  5486. }
  5487. } catch (error) {
  5488. /* 写盘失败要留痕但不能中断 —— 用户更需要"继续抓"而不是"因为一个文件写不进去全停"。
  5489. * 只报一次,避免几百条一模一样的警告把日志刷满。 */
  5490. if (!flushFailed) {
  5491. flushFailed = true;
  5492. TikTokAuto.logger.error('uidset', '写结果文件失败:' + error);
  5493. }
  5494. }
  5495. }
  5496. return {
  5497. /** 收一个 uid。调用方保证已经过去重。 */
  5498. write: function (uid) {
  5499. pending.push(uid);
  5500. /* 同时留一份在内存里:收尾阶段要拿它去换数字 uid(见 resolve.js)。
  5501. * 从文件读一遍也行,但那样得处理"文件还没 flush 完"的时序问题,
  5502. * 而这里的量级(几十到几千)远没到需要省内存的地步。 */
  5503. collected.push(uid);
  5504. written += 1;
  5505. if (pending.length >= FLUSH_EVERY) {
  5506. flush();
  5507. }
  5508. },
  5509. /** 本轮收到的全部 uid(按收取顺序) */
  5510. list: function () {
  5511. return collected.slice(0);
  5512. },
  5513. /** 已写多少个 */
  5514. count: function () {
  5515. return written;
  5516. },
  5517. /** 收尾:把剩下的刷出去。**收到停止信号时也必须调它**,否则"停了但文件是空的"。 */
  5518. close: function () {
  5519. flush();
  5520. return written;
  5521. },
  5522. /** 是否发生过写盘失败(给 summary 用) */
  5523. hadFailure: function () {
  5524. return flushFailed;
  5525. }
  5526. };
  5527. }
  5528. /** 确保目录存在(结果文件与累计文件同目录) */
  5529. function ensureDir(dir) {
  5530. try {
  5531. files.ensureDir(dir + '/');
  5532. return true;
  5533. } catch (error) {
  5534. TikTokAuto.logger.error('uidset', '创建目录失败 ' + dir + ':' + error);
  5535. return false;
  5536. }
  5537. }
  5538. return {
  5539. FLUSH_EVERY: FLUSH_EVERY,
  5540. create: create,
  5541. readUidFile: readUidFile,
  5542. loadHistory: loadHistory,
  5543. createWriter: createWriter,
  5544. ensureDir: ensureDir
  5545. };
  5546. }());
  5547. /* ============================== src/fingerprint.js ============================== */
  5548. /**
  5549. * fingerprint.js — 配置指纹(判断"断点还有效吗")
  5550. *
  5551. * ## 为什么要它
  5552. * 断点续跑的前提是"配置没变过"。取材换了关键词、过滤条件调了门槛,
  5553. * 上次"已完成"的判定就不再成立 —— 接着跑会得到口径不一致的数据,而用户看不出来。
  5554. *
  5555. * ## 为什么不用 hash 库
  5556. * 项目里没有任何现成的 hash / 稳定序列化工具,而 AutoX 环境里能用的
  5557. * 也就 Rhino 那点东西(没有 `Math.imul`、没有 ES6 Map/Set 的稳定行为)。
  5558. * 所以自己写两个小函数:**稳定序列化 + djb2**。
  5559. *
  5560. * ## 覆盖哪些字段(这是设计决策,不是随手挑的)
  5561. *
  5562. * | 覆盖 | 为什么 |
  5563. * |---|---|
  5564. * | `sources[]` 的全部取值字段(**顺序敏感**) | 换账号/关键词/深度/删条目都改变"取哪些人" |
  5565. * | `filter` 的 private/noAvatar/langRegion/nicknameCheck/follow/fans/likes | 直接决定谁被拦掉 |
  5566. * | `output` 的 dir/historyPath/dedupeHistory | dir 决定产物位置;后两个决定 `seen` 的初始内容 |
  5567. *
  5568. * | 不覆盖 | 为什么 |
  5569. * |---|---|
  5570. * | `output.limit` | 约束"跑多少个"而不是"取哪些人";续跑仍按累计数生效 |
  5571. * | `output.resolveUid` | 只影响收尾是否多产一份映射文件 |
  5572. * | `output.dryRun` | 演练不写产物,**调用方直接禁续跑**,不必进指纹 |
  5573. * | `filter.videos` | `filters.judge` 只对它出 soft 提示,不参与判定 |
  5574. * | `guard.*` / `timeouts` / `probe` / `runtime` | 与"取哪些人"无关 |
  5575. *
  5576. * ## ★ 这个文件在界面侧有一份逐字节相同的实现
  5577. * `tiktok-app/src/fingerprint.js`。**改这里必须同步改那边**,
  5578. * 否则两侧算出的指纹不同 → 界面说"可以续跑"、引擎说"配置变了"。
  5579. * `tools/pc-selftest-id.js` 里有一组断言专门比对两份实现的结果。
  5580. */
  5581. TikTokAuto.fingerprint = (function () {
  5582. 'use strict';
  5583. var VERSION = 1;
  5584. /**
  5585. * 是不是数组。
  5586. *
  5587. * 不用 `v instanceof Array`:跨 realm(PC 自检把 Node 造的对象喂进 vm 沙箱)时
  5588. * `instanceof` 会返回 false,那会让"数组"被当成普通对象序列化,两边算出的指纹就不一样了。
  5589. *
  5590. * 也不用 `toString` 配 `Function.prototype.call` 的写法:那个关键字加左括号
  5591. * 命中 MCP 危险调用黑名单(`tools/build.js` 的 `DANGEROUS_RULES`),会让每次下发都要人工确认。
  5592. * 优先 `Array.isArray`(ES5,AutoX 的 Rhino 支持),没有时退回 `.apply`。
  5593. */
  5594. function isArray(v) {
  5595. if (typeof Array.isArray === 'function') {
  5596. return Array.isArray(v);
  5597. }
  5598. return Object.prototype.toString.apply(v, []) === '[object Array]';
  5599. }
  5600. /** 自有键,排序后返回(键序无关 → 同一份配置换个键序不会让断点失效) */
  5601. function ownKeys(v) {
  5602. var keys = [];
  5603. for (var k in v) {
  5604. if (Object.prototype.hasOwnProperty.apply(v, [k]) === true) {
  5605. keys.push(k);
  5606. }
  5607. }
  5608. keys.sort();
  5609. return keys;
  5610. }
  5611. /**
  5612. * 稳定序列化:对象按键名排序、数组保持顺序、每个值带类型前缀。
  5613. *
  5614. * 类型前缀是必须的 —— 否则 `"1"`(字符串)和 `1`(数字)会序列化成同一个串,
  5615. * 而界面上"粉丝下限"这类字段确实可能一个是空串一个是 0。
  5616. */
  5617. function canon(v) {
  5618. if (v === null || v === undefined) {
  5619. return 'n';
  5620. }
  5621. if (isArray(v)) {
  5622. var parts = [];
  5623. for (var i = 0; i < v.length; i += 1) {
  5624. parts.push(canon(v[i]));
  5625. }
  5626. return '[' + parts.join(',') + ']';
  5627. }
  5628. if (typeof v === 'object') {
  5629. var keys = ownKeys(v);
  5630. var out = [];
  5631. for (var j = 0; j < keys.length; j += 1) {
  5632. out.push(keys[j] + ':' + canon(v[keys[j]]));
  5633. }
  5634. return '{' + out.join(',') + '}';
  5635. }
  5636. if (typeof v === 'number') {
  5637. return 'd' + String(v);
  5638. }
  5639. if (typeof v === 'boolean') {
  5640. return v ? 'T' : 'F';
  5641. }
  5642. return 's' + JSON.stringify(String(v));
  5643. }
  5644. /**
  5645. * djb2-xor 摘要。
  5646. *
  5647. * 末尾拼上原文长度:32 位散列碰撞概率本来就不高,加个长度等于多一维区分,
  5648. * 而且是"免费"的(长度本来就要算)。输出是 base36,比十六进制短。
  5649. */
  5650. function digest(text) {
  5651. var s = String(text);
  5652. var h = 5381;
  5653. for (var i = 0; i < s.length; i += 1) {
  5654. h = (((h << 5) + h) ^ s.charCodeAt(i)) | 0; /* |0 截成 32 位,避免精度丢失 */
  5655. }
  5656. return (h >>> 0).toString(36) + '-' + s.length.toString(36);
  5657. }
  5658. /** 把一条取材归一化成"只含参与指纹的字段",缺项补默认 */
  5659. function ofSource(one, index) {
  5660. var s = one || {};
  5661. return {
  5662. key: s.key || ('i' + index + ':' + (s.kind || '')),
  5663. kind: s.kind || '',
  5664. anchors: s.anchors || '',
  5665. lists: isArray(s.lists) ? s.lists : [],
  5666. searchType: s.searchType || '',
  5667. keyword: s.keyword || '',
  5668. videoSource: s.videoSource || '',
  5669. videoKeyword: s.videoKeyword || '',
  5670. path: s.path || '',
  5671. expand: s.expand || ''
  5672. };
  5673. }
  5674. /**
  5675. * 算 `id` 段的指纹。
  5676. *
  5677. * **分项存**(sources / filter / output 各一份)而不只存一个总串:
  5678. * 界面要能说清"是**取材**改过了"而不是含糊的"配置变了",用户才知道该改回去什么。
  5679. *
  5680. * @param id `config.id` 整段(界面传 `Engine.buildIdConfig().config.id`,
  5681. * 引擎传解析后的 `config.id` —— 两者是同一份数据)
  5682. */
  5683. function ofIdSection(id) {
  5684. var section = id || {};
  5685. var list = isArray(section.sources) ? section.sources : [];
  5686. var sources = [];
  5687. for (var i = 0; i < list.length; i += 1) {
  5688. sources.push(ofSource(list[i], i));
  5689. }
  5690. var f = section.filter || {};
  5691. var o = section.output || {};
  5692. var sourcesHash = digest(canon(sources));
  5693. var filterHash = digest(canon({
  5694. private: f.private === true,
  5695. noAvatar: f.noAvatar === true,
  5696. langRegion: f.langRegion || 'any',
  5697. nicknameCheck: f.nicknameCheck || 'any',
  5698. follow: f.follow || {},
  5699. fans: f.fans || {},
  5700. likes: f.likes || {}
  5701. }));
  5702. var outputHash = digest(canon({
  5703. dir: o.dir || '',
  5704. historyPath: o.historyPath || '',
  5705. dedupeHistory: o.dedupeHistory !== false
  5706. }));
  5707. return {
  5708. v: VERSION,
  5709. sources: sourcesHash,
  5710. filter: filterHash,
  5711. output: outputHash,
  5712. all: digest(sourcesHash + filterHash + outputHash)
  5713. };
  5714. }
  5715. /**
  5716. * 比两份指纹。
  5717. * @returns { same, changed: ['sources'|'filter'|'output'] }
  5718. */
  5719. function compare(a, b) {
  5720. if (!a || !b || a.v !== VERSION || b.v !== VERSION) {
  5721. return { same: false, changed: ['sources', 'filter', 'output'] };
  5722. }
  5723. var changed = [];
  5724. if (a.sources !== b.sources) {
  5725. changed.push('sources');
  5726. }
  5727. if (a.filter !== b.filter) {
  5728. changed.push('filter');
  5729. }
  5730. if (a.output !== b.output) {
  5731. changed.push('output');
  5732. }
  5733. return { same: changed.length === 0, changed: changed };
  5734. }
  5735. return {
  5736. VERSION: VERSION,
  5737. canon: canon,
  5738. digest: digest,
  5739. ofIdSection: ofIdSection,
  5740. compare: compare
  5741. };
  5742. }());
  5743. /* ============================== src/checkpoint.js ============================== */
  5744. /**
  5745. * checkpoint.js — ID 提取的断点("跑到哪了"的唯一真相)
  5746. *
  5747. * ## 粒度:取材条目级,不是屏/行级
  5748. *
  5749. * TikTok 的列表内容会变、滚动位置无法复原。记"第几屏第几行"只会造成
  5750. * 漏掉别人或重复劳动;而「哪条取材已经走完」是稳定事实。
  5751. * 所以:**跳过已完成的取材;未完成的那条从头重走,靠已收集合跳人**。
  5752. *
  5753. * ## 文件
  5754. * `<id.output.dir>/id_progress.json`,与产物同目录(用户清产物时看得见)。
  5755. * 全局只保留一份(全局单跑,多份会让"哪份有效"变成判断题)。
  5756. *
  5757. * ## 几条硬约束(都踩过或差点踩)
  5758. *
  5759. * 1. **只能覆盖写,不能删文件** —— `files.remove` 命中 MCP 危险调用黑名单
  5760. * (见 `tools/build.js` 的 `DANGEROUS_RULES` 与 `uidset.js` 文件头)。
  5761. * 「清空断点」= 覆盖写成 `state:"none"`。
  5762. * 2. **read 必须容错** —— 写到一半被杀会留下半截 JSON。读不懂就当"没有断点",
  5763. * 绝不抛异常:产物还在,丢的只是"能不能接着跑"。
  5764. * 3. **不存已收 uid 列表** —— 结果文件本身就是那份清单,重复存只会出现两份不一致。
  5765. * 4. **写盘不追每一条 uid** —— 粒度是取材条目级,每收 1 个就写只换来"少重走 19 个人"。
  5766. * 节奏是:取材边界 + 每 20 个(与产物刷盘同拍)+ 收尾。
  5767. *
  5768. * 依赖:TikTokAuto.logger、TikTokAuto.fingerprint(仅 compare 用)
  5769. */
  5770. TikTokAuto.checkpoint = (function () {
  5771. 'use strict';
  5772. /** 文件名固定:不放用户配置里,避免"换个名字就找不到断点" */
  5773. var DEFAULT_NAME = 'id_progress.json';
  5774. var VERSION = 1;
  5775. function logger() { return TikTokAuto.logger; }
  5776. /**
  5777. * 是不是数组。优先 `Array.isArray`;退回用 `.apply` 而不是 `Function.prototype.call`
  5778. * —— 后者那个关键字加左括号会命中 MCP 危险调用黑名单(见 `tools/build.js`)。
  5779. */
  5780. function isArray(v) {
  5781. if (typeof Array.isArray === 'function') {
  5782. return Array.isArray(v);
  5783. }
  5784. return Object.prototype.toString.apply(v, []) === '[object Array]';
  5785. }
  5786. function dirOf(path) {
  5787. var s = String(path || '');
  5788. var i = s.lastIndexOf('/');
  5789. return i > 0 ? s.substring(0, i + 1) : '';
  5790. }
  5791. /** 断点文件路径(由结果目录派生) */
  5792. function pathOf(output) {
  5793. var dir = (output && output.dir) || '/sdcard/Download/tiktok_app';
  5794. return dir + '/' + DEFAULT_NAME;
  5795. }
  5796. /**
  5797. * 新建一份断点。
  5798. *
  5799. * @param runId 当前这次运行(界面靠它把记录与断点精确对上)
  5800. * @param sig 指纹(`fingerprint.ofIdSection(config.id)`)
  5801. * @param runFile 结果文件绝对路径(续跑时复用它,不新开文件)
  5802. * @param sources `[{key, kind, label}]`,顺序与 config.id.sources 一致
  5803. */
  5804. function empty(runId, sig, runFile, sources) {
  5805. var list = [];
  5806. for (var i = 0; i < (sources || []).length; i += 1) {
  5807. var s = sources[i] || {};
  5808. list.push({
  5809. key: s.key || ('i' + i + ':' + (s.kind || '')),
  5810. kind: s.kind || '',
  5811. label: s.label || '',
  5812. status: 'pending', /* pending | running | done */
  5813. screens: 0,
  5814. handled: 0,
  5815. added: 0
  5816. });
  5817. }
  5818. var now = Date.now();
  5819. return {
  5820. v: VERSION,
  5821. state: 'running', /* running | stopped | done | none */
  5822. runId: runId || '',
  5823. beganAt: now,
  5824. updatedAt: now,
  5825. sig: sig || null,
  5826. runFile: runFile || '',
  5827. mapFile: '',
  5828. limitBase: 0,
  5829. counts: { extracted: 0, skipped: 0, duplicates: 0, openedProfiles: 0 },
  5830. sources: list,
  5831. expand: { queue: [], done: [] }
  5832. };
  5833. }
  5834. /** 读断点。**任何解析问题都返回 null**(当作没有断点),绝不抛 */
  5835. function read(path) {
  5836. try {
  5837. if (!path || !files.exists(path)) {
  5838. return null;
  5839. }
  5840. var text = String(files.read(path) || '');
  5841. if (text.replace(/\s/g, '') === '') {
  5842. return null;
  5843. }
  5844. var cp = JSON.parse(text);
  5845. if (!cp || cp.v !== VERSION) {
  5846. return null;
  5847. }
  5848. /* 半截文件可能缺字段:补齐,别让调用方到处判空 */
  5849. if (!isArray(cp.sources)) {
  5850. cp.sources = [];
  5851. }
  5852. if (!cp.expand || typeof cp.expand !== 'object') {
  5853. cp.expand = { queue: [], done: [] };
  5854. }
  5855. if (!isArray(cp.expand.queue)) {
  5856. cp.expand.queue = [];
  5857. }
  5858. if (!isArray(cp.expand.done)) {
  5859. cp.expand.done = [];
  5860. }
  5861. return cp;
  5862. } catch (error) {
  5863. logger().warn('checkpoint', '断点文件读不懂(当作没有断点):' + error);
  5864. return null;
  5865. }
  5866. }
  5867. /** 覆盖写整份断点。返回是否写成功(失败只记 warn,不影响主流程) */
  5868. function save(path, cp) {
  5869. if (!cp) {
  5870. return false;
  5871. }
  5872. try {
  5873. var dir = dirOf(path);
  5874. if (dir) {
  5875. files.ensureDir(dir);
  5876. }
  5877. cp.updatedAt = Date.now();
  5878. files.write(path, JSON.stringify(cp));
  5879. return true;
  5880. } catch (error) {
  5881. logger().warn('checkpoint', '断点写盘失败(不影响已抓到的结果):' + error);
  5882. return false;
  5883. }
  5884. }
  5885. /** 「清空断点」= 覆盖成空壳。**不删文件**(`files.remove` 是危险调用) */
  5886. function clearStub() {
  5887. return { v: VERSION, state: 'none', updatedAt: Date.now() };
  5888. }
  5889. /** 指纹不符时给用户一句人话,说清是哪一段变了 */
  5890. function nameOf(key) {
  5891. if (key === 'sources') { return '取材'; }
  5892. if (key === 'filter') { return '过滤条件'; }
  5893. if (key === 'output') { return '输出设置(目录 / 跨轮去重)'; }
  5894. return key;
  5895. }
  5896. /**
  5897. * 能不能续跑?—— 顺序很重要,每一步的失败都要给可操作的话。
  5898. *
  5899. * 全部判定都在**启动 App 之前**做完:拒绝时零成本返回,不折腾用户手机。
  5900. *
  5901. * @param prev 上一次读到的断点(或 null)
  5902. * @param sig 当前配置指纹
  5903. * @param runFileExists 上次的结果文件还在不在
  5904. * @param opts { dryRun }
  5905. * @returns { mode:'resume'|'fresh'|'reject', reason, message, changed }
  5906. */
  5907. function planResume(prev, sig, runFileExists, opts) {
  5908. var options = opts || {};
  5909. if (!prev || prev.v !== VERSION || prev.state === 'none') {
  5910. return { mode: 'fresh', reason: 'no-checkpoint', message: '', changed: [] };
  5911. }
  5912. if (prev.state === 'done') {
  5913. return { mode: 'fresh', reason: 'last-complete', message: '', changed: [] };
  5914. }
  5915. if (options.dryRun) {
  5916. return {
  5917. mode: 'reject', reason: 'dry-run', changed: [],
  5918. message: '断点失效:演练模式不写产物,无法续跑'
  5919. };
  5920. }
  5921. var cmp = TikTokAuto.fingerprint.compare(prev.sig, sig);
  5922. if (!cmp.same) {
  5923. var names = [];
  5924. for (var i = 0; i < cmp.changed.length; i += 1) {
  5925. names.push(nameOf(cmp.changed[i]));
  5926. }
  5927. return {
  5928. mode: 'reject', reason: 'config-changed', changed: cmp.changed,
  5929. message: '断点失效:' + names.join('、') + '已改动,只能重新开始'
  5930. };
  5931. }
  5932. if (!runFileExists) {
  5933. return {
  5934. mode: 'reject', reason: 'run-file-missing', changed: [],
  5935. message: '断点失效:上次的结果文件已不存在(' + (prev.runFile || '') + ')'
  5936. };
  5937. }
  5938. return { mode: 'resume', reason: 'ok', message: '', changed: [] };
  5939. }
  5940. /* ------------------------------------------------------------ 取材条目 */
  5941. function findSource(cp, key) {
  5942. if (!cp || !isArray(cp.sources)) {
  5943. return null;
  5944. }
  5945. for (var i = 0; i < cp.sources.length; i += 1) {
  5946. if (cp.sources[i].key === key) {
  5947. return cp.sources[i];
  5948. }
  5949. }
  5950. return null;
  5951. }
  5952. function sourceDone(cp, key) {
  5953. var one = findSource(cp, key);
  5954. return !!(one && one.status === 'done');
  5955. }
  5956. /**
  5957. * 更新一条取材的状态。
  5958. * @param extra 可选 { screens, handled, added }
  5959. */
  5960. function markSource(cp, key, status, extra) {
  5961. var one = findSource(cp, key);
  5962. if (!one) {
  5963. return null;
  5964. }
  5965. one.status = status;
  5966. if (extra) {
  5967. if (extra.screens !== undefined) { one.screens = extra.screens; }
  5968. if (extra.handled !== undefined) { one.handled = extra.handled; }
  5969. if (extra.added !== undefined) { one.added = extra.added; }
  5970. }
  5971. return one;
  5972. }
  5973. /** 已完成 / 共几条(给界面写提示条用) */
  5974. function sourceProgress(cp) {
  5975. var done = 0;
  5976. var total = cp && isArray(cp.sources) ? cp.sources.length : 0;
  5977. for (var i = 0; i < total; i += 1) {
  5978. if (cp.sources[i].status === 'done') {
  5979. done += 1;
  5980. }
  5981. }
  5982. return { done: done, total: total };
  5983. }
  5984. /** 上次停在哪儿(提示条上那句"停在「xxx」") */
  5985. function stoppedAt(cp) {
  5986. if (!cp || !isArray(cp.sources)) {
  5987. return '';
  5988. }
  5989. for (var i = 0; i < cp.sources.length; i += 1) {
  5990. if (cp.sources[i].status !== 'done') {
  5991. return cp.sources[i].label || '';
  5992. }
  5993. }
  5994. return '';
  5995. }
  5996. /**
  5997. * 取一条取材的记录(找不到返回 null)。
  5998. *
  5999. * 续跑时要用它上次翻了多少屏来**放大** `maxScreens` 预算:
  6000. * 从头重放旧人也要翻屏,不放大就会"还没走到新人就撞上 60 屏硬顶"。
  6001. */
  6002. function sourceOf(cp, key) {
  6003. return findSource(cp, key);
  6004. }
  6005. /** 上次这条取材翻了多少屏(没记录就 0) */
  6006. function screensOf(cp, key) {
  6007. var one = findSource(cp, key);
  6008. return one ? (one.screens || 0) : 0;
  6009. }
  6010. /* ------------------------------------------------------------ 深度展开 */
  6011. function expandKey(uid, which) {
  6012. return String(uid) + '|' + String(which);
  6013. }
  6014. function enqueueExpand(cp, uid, which) {
  6015. if (!cp || !uid) {
  6016. return false;
  6017. }
  6018. var key = expandKey(uid, which);
  6019. if (cp.expand.done.indexOf(key) >= 0) {
  6020. return false; /* 上次已经展开过,别重复排 */
  6021. }
  6022. for (var i = 0; i < cp.expand.queue.length; i += 1) {
  6023. if (expandKey(cp.expand.queue[i].uid, cp.expand.queue[i].which) === key) {
  6024. return false;
  6025. }
  6026. }
  6027. cp.expand.queue.push({ uid: String(uid), which: String(which) });
  6028. return true;
  6029. }
  6030. function expandDone(cp, uid, which) {
  6031. return !!cp && cp.expand && cp.expand.done.indexOf(expandKey(uid, which)) >= 0;
  6032. }
  6033. function markExpandDone(cp, uid, which) {
  6034. if (!cp || !cp.expand) {
  6035. return;
  6036. }
  6037. var key = expandKey(uid, which);
  6038. if (cp.expand.done.indexOf(key) < 0) {
  6039. cp.expand.done.push(key);
  6040. }
  6041. }
  6042. /**
  6043. * 这次还要展开谁。
  6044. *
  6045. * @param queue 本轮内存里攒的待展开项(`[{uid, which}]`,由 handleRow 边收边入队)
  6046. * @returns 过滤掉"上次已完成"之后的那部分
  6047. */
  6048. function pendingExpand(cp, queue) {
  6049. var out = [];
  6050. for (var i = 0; i < (queue || []).length; i += 1) {
  6051. var one = queue[i];
  6052. if (!expandDone(cp, one.uid, one.which)) {
  6053. out.push(one);
  6054. }
  6055. }
  6056. return out;
  6057. }
  6058. /* ------------------------------------------------------------ 收尾判定 */
  6059. /** 所有取材都 done,且展开队列里没有未完成的 → 这一轮真的做完了 */
  6060. function allDone(cp) {
  6061. if (!cp || !isArray(cp.sources) || cp.sources.length === 0) {
  6062. return false;
  6063. }
  6064. for (var i = 0; i < cp.sources.length; i += 1) {
  6065. if (cp.sources[i].status !== 'done') {
  6066. return false;
  6067. }
  6068. }
  6069. for (var j = 0; j < cp.expand.queue.length; j += 1) {
  6070. if (!expandDone(cp, cp.expand.queue[j].uid, cp.expand.queue[j].which)) {
  6071. return false;
  6072. }
  6073. }
  6074. return true;
  6075. }
  6076. /**
  6077. * 「提取满 N 个就停」—— 续跑时必须**按累计数**算。
  6078. *
  6079. * 续跑的人均语义是"这一轮凑够 N 个",那就变成设了 100 个、跑了 60 个被停、
  6080. * 接着再收 100 个 = 一共 160 个。所以基数是"上次已落盘的条数",
  6081. * 而那个数以**结果文件的行数**为准(文件是真相,不信 JSON 里的计数)。
  6082. *
  6083. * @param cp 断点(读 `limitBase`)
  6084. * @param limit 用户设的上限(0 = 不限)
  6085. * @param currentExtracted 本轮新增
  6086. */
  6087. function limitReached(cp, limit, currentExtracted) {
  6088. if (!(limit > 0)) {
  6089. return false;
  6090. }
  6091. var base = (cp && cp.limitBase) || 0;
  6092. return (base + (currentExtracted || 0)) >= limit;
  6093. }
  6094. return {
  6095. DEFAULT_NAME: DEFAULT_NAME,
  6096. VERSION: VERSION,
  6097. pathOf: pathOf,
  6098. empty: empty,
  6099. read: read,
  6100. save: save,
  6101. clearStub: clearStub,
  6102. planResume: planResume,
  6103. nameOf: nameOf,
  6104. sourceDone: sourceDone,
  6105. markSource: markSource,
  6106. sourceProgress: sourceProgress,
  6107. stoppedAt: stoppedAt,
  6108. sourceOf: sourceOf,
  6109. screensOf: screensOf,
  6110. enqueueExpand: enqueueExpand,
  6111. expandDone: expandDone,
  6112. markExpandDone: markExpandDone,
  6113. pendingExpand: pendingExpand,
  6114. allDone: allDone,
  6115. limitReached: limitReached
  6116. };
  6117. }());
  6118. /* ============================== src/filters.js ============================== */
  6119. /**
  6120. * filters.js — 「只要这样的账号」的判定
  6121. *
  6122. * 这一层是**纯函数**:给它一份采集到的资料 + 一份过滤设置,它只回答"要 / 不要 / 为什么"。
  6123. * 不点屏幕、不读写文件、不知道 TikTok 长什么样。所以它能被 100% 离线自检,
  6124. * 而"界面上的 21 项设置真的会拦人"这件事也就有了证据。
  6125. *
  6126. * ## 一条重要的区分:**数据缺失**不等于**不符合**
  6127. * 三种"没有值"必须分开处理,否则过滤要么形同虚设,要么乱杀:
  6128. *
  6129. * ① 用户没设这个门槛 → 放行(设置里是 0,语义就是"不限")
  6130. * ② 设了,但平台根本不提供 → **放行 + 记一条 warn**(例如"视频量":真机实测用户页
  6131. * 顶部只有 关注/粉丝/获赞 三组数字,没有视频数)
  6132. * ③ 设了,本应能读到却读不到 → **拦下**(读不到就无法证明符合,宁可少收)
  6133. *
  6134. * ②和③看起来像,但责任不同:②是我们做不到,不该惩罚用户抓到的人;③是数据异常,
  6135. * 放行会让过滤失去意义而用户以为它生效了。
  6136. *
  6137. * 依赖:TikTokAuto.parse、TikTokAuto.logger(调用时取)
  6138. */
  6139. TikTokAuto.filters = (function () {
  6140. 'use strict';
  6141. /**
  6142. * 这份过滤设置是否需要打开用户页才能判。
  6143. *
  6144. * 用途是**快路径**:所有过滤都关着时(最常见的第一种用法),
  6145. * 直接从列表页把 uid 抄下来就行 —— 不必为每个人开一次用户页。
  6146. * 实测一次用户页打开约 3~5 秒,100 个人就是 5~8 分钟,差一个数量级。
  6147. *
  6148. * 注意 `videos` 不参与这个判断:它读不到(见文件头 ②),
  6149. * 为此打开用户页也只是白花时间。
  6150. */
  6151. function needsProfile(filter) {
  6152. var f = filter || {};
  6153. if (f.private === true || f.noAvatar === true) {
  6154. return true;
  6155. }
  6156. if (f.langRegion && f.langRegion !== 'any') {
  6157. return true;
  6158. }
  6159. var groups = ['follow', 'fans', 'likes'];
  6160. for (var i = 0; i < groups.length; i += 1) {
  6161. var range = f[groups[i]] || {};
  6162. if (!TikTokAuto.parse.isRangeOpen(range.min, range.max)) {
  6163. return true;
  6164. }
  6165. }
  6166. return false;
  6167. }
  6168. /** 取该判定的取样文本:昵称、个签,或两者(昵称检测下拉的三个取值)。 */
  6169. function sampleText(profile, mode) {
  6170. var nick = profile.nickname || '';
  6171. var sign = profile.signature || '';
  6172. if (mode === 'nick') {
  6173. return nick;
  6174. }
  6175. if (mode === 'sign') {
  6176. return sign;
  6177. }
  6178. /* "通杀":两段合起来判 —— 分开判会让"昵称中文、个签全英文"的人两边都不算 */
  6179. return nick + ' ' + sign;
  6180. }
  6181. /**
  6182. * 单条判定的结果。
  6183. * `soft` 表示"我们做不到,放行但要告知"(对应文件头的 ②)。
  6184. */
  6185. function pass() {
  6186. return { ok: true, reason: '', soft: '' };
  6187. }
  6188. function reject(reason) {
  6189. return { ok: false, reason: reason, soft: '' };
  6190. }
  6191. function softPass(reason) {
  6192. return { ok: true, reason: '', soft: reason };
  6193. }
  6194. /**
  6195. * 判定一个账号要不要。
  6196. *
  6197. * @param profile 采集到的资料;`null` 表示"这次没有打开用户页"(快路径)
  6198. * —— 此时只有不看资料就能判的项目生效,其余全部放行
  6199. * @param filter 过滤设置
  6200. * @returns { ok, reason, soft }
  6201. * reason 是"为什么不要"(人话,会进日志)
  6202. * soft 是"放行了但你应该知道"(进 summary 的 warnings,只报一次)
  6203. */
  6204. function judge(profile, filter) {
  6205. var f = filter || {};
  6206. var parse = TikTokAuto.parse;
  6207. if (!profile) {
  6208. /* 没有资料,两种情况必须分开:
  6209. * · 这份设置**本来就不需要**资料(快路径)→ 放行;
  6210. * · 需要资料却拿不到(用户页打不开、读不出)→ **拦下**。
  6211. * 第二种如果放行,等于"打不开的人全都通过" —— 过滤看着在跑,
  6212. * 实际放进来一批根本没检查过的账号,比不过滤更糟(用户以为筛过了)。 */
  6213. if (needsProfile(f)) {
  6214. return reject('打不开用户页,读不到任何资料');
  6215. }
  6216. return pass();
  6217. }
  6218. /* ① 私密账户。列表页读不到,只能打开用户页看提示文案。 */
  6219. if (f.private === true && profile.isPrivate === true) {
  6220. return reject('私密账号');
  6221. }
  6222. /* ② 无头像。真机实测头像节点不可读(无文案、不可点),
  6223. * 只能靠像素法 —— 见 profile.js 里的说明。读不到时放行并告知。 */
  6224. if (f.noAvatar === true) {
  6225. if (profile.hasAvatar === false) {
  6226. return reject('无头像');
  6227. }
  6228. if (profile.hasAvatar === null) {
  6229. return {
  6230. ok: true,
  6231. reason: '',
  6232. soft: '无头像检测:头像读取不可用,本次未生效'
  6233. };
  6234. }
  6235. }
  6236. /* ③ 语言区域:按昵称/个签的字符构成判(见 parse.language 的规则说明) */
  6237. if (f.langRegion && f.langRegion !== 'any') {
  6238. var text = sampleText(profile, f.nicknameCheck || 'any');
  6239. var lang = parse.language(text);
  6240. if (lang === 'none') {
  6241. /* 纯 emoji / 纯数字昵称:没有语言线索。用户明确要筛语言,就不该混进来。 */
  6242. return reject('看不出语言(昵称与个签都没有文字)');
  6243. }
  6244. if (!parse.languageMatches(f.langRegion, lang)) {
  6245. return reject('语言不符:' + lang + ' ≠ ' + f.langRegion);
  6246. }
  6247. }
  6248. /* ④ 三组数量门槛。真机实测用户页顶部是 关注 / 粉丝 / 获赞,
  6249. * 数字形态是缩写("1.6M"),parse.count 负责解析。 */
  6250. var checks = [
  6251. { key: 'follow', label: '关注量' },
  6252. { key: 'fans', label: '粉丝量' },
  6253. { key: 'likes', label: '获赞量' }
  6254. ];
  6255. for (var i = 0; i < checks.length; i += 1) {
  6256. var one = checks[i];
  6257. var range = f[one.key] || {};
  6258. if (parse.isRangeOpen(range.min, range.max)) {
  6259. continue;
  6260. }
  6261. if (!parse.inRange(profile[one.key], range.min, range.max)) {
  6262. var actual = profile[one.key];
  6263. return reject(one.label + '不在区间内(' +
  6264. (actual === null || actual === undefined ? '读不到' : actual) + ')');
  6265. }
  6266. }
  6267. /* ⑤ 视频量:平台不提供(实测),放行 + 告知。见文件头 ②。 */
  6268. var videoRange = f.videos || {};
  6269. if (!parse.isRangeOpen(videoRange.min, videoRange.max)) {
  6270. return softPass('视频量:用户页不显示该数字,本次未参与过滤');
  6271. }
  6272. return pass();
  6273. }
  6274. /** 给人看的一句话:这次到底在筛什么(进日志与界面状态条) */
  6275. function describe(filter) {
  6276. var f = filter || {};
  6277. var parts = [];
  6278. if (f.private === true) { parts.push('排除私密'); }
  6279. if (f.noAvatar === true) { parts.push('排除无头像'); }
  6280. if (f.langRegion && f.langRegion !== 'any') {
  6281. parts.push(f.langRegion === 'zh' ? '仅中文' : '仅英文');
  6282. }
  6283. var labels = { follow: '关注', fans: '粉丝', likes: '获赞' };
  6284. var keys = ['follow', 'fans', 'likes'];
  6285. for (var i = 0; i < keys.length; i += 1) {
  6286. var range = f[keys[i]] || {};
  6287. if (TikTokAuto.parse.isRangeOpen(range.min, range.max)) {
  6288. continue;
  6289. }
  6290. parts.push(labels[keys[i]] + ' ' + (range.min || 0) + '~' + (range.max || '∞'));
  6291. }
  6292. return parts.length === 0 ? '不过滤(拿到就收)' : parts.join(',');
  6293. }
  6294. return {
  6295. needsProfile: needsProfile,
  6296. judge: judge,
  6297. describe: describe
  6298. };
  6299. }());
  6300. /* ============================== src/profile.js ============================== */
  6301. /**
  6302. * profile.js — 在 TikTok 里"走到某个账号的主页并读出来"
  6303. *
  6304. * ## 这一层管什么
  6305. * 只管**导航**与**读取**:打开搜索、进用户主页、点开粉丝列表、翻一屏、退回去。
  6306. * 它不判断"要不要这个人"(那是 filters.js),也不管"从哪来"(idsource.js)。
  6307. *
  6308. * ## 真机实测出来的三条导航事实(2026-10-20 / TikTok 47.1.4)
  6309. * 1. **用户主页 → 粉丝列表是一张覆盖页**,顶部有三个页签(Following / Followers / Suggested),
  6310. * 可以直接在页签间切换 —— 所以"先收粉丝再收关注"不必退回主页再进。
  6311. * 2. **从列表进用户页再返回,列表位置会保持**(实测首行与坐标都不变)。
  6312. * 这条决定了翻页策略:可以"读一屏 → 逐个进 → 退回来接着读下一屏",
  6313. * 不必先把整屏 uid 存起来再统一处理。
  6314. * 3. **列表行里就有 uid**(`txt_desc`),不必为了拿 uid 去开用户页。
  6315. * 用户页只在**需要更多资料**(数量、语言、私密、头像)时才开 —— 差一个数量级的时间。
  6316. *
  6317. * ## 头像为什么要截屏
  6318. * 头像是 ImageView,**没有 text、没有 desc、不可点**,无障碍树里读不到任何线索。
  6319. * 实测对比:默认头像(灰色人形剪影)高饱和像素占比 0.035,真头像 0.472 ——
  6320. * 分离度足够,所以用像素法。**已知局限:纯黑白照片的头像会被误判成"无头像"**,
  6321. * 这一点会如实写进 README 与界面说明,不假装没有。
  6322. *
  6323. * 依赖:TikTokAuto.layout / tiktok / ui / wait / logger / parse
  6324. */
  6325. TikTokAuto.profile = (function () {
  6326. 'use strict';
  6327. /** 头像采样网格:5x5。25 个点足够区分剪影与照片,又不会让截图开销变成瓶颈。 */
  6328. var AVATAR_GRID = 5;
  6329. /** 高饱和像素占比超过它就认为"有头像"。实测默认头像 0.035 / 真头像 0.472。 */
  6330. var AVATAR_SATURATED_RATIO = 0.12;
  6331. function logger() { return TikTokAuto.logger; }
  6332. /** 等页面安静下来(签名连续几次不变),比固定 sleep 可靠 */
  6333. function settle(maxMs) {
  6334. TikTokAuto.wait.waitQuiet(function () {
  6335. return TikTokAuto.layout.signature();
  6336. }, { maxMs: maxMs || 2500, tickMs: 200, stableTimes: 2 });
  6337. }
  6338. // ------------------------------------------------------------ 读取主页
  6339. /**
  6340. * 把当前用户主页读成一份资料。
  6341. *
  6342. * 读不到的字段一律给 **null**(而不是 0 或空串):
  6343. * "读不到"与"就是 0"在过滤判定里含义完全不同(见 filters.js 文件头)。
  6344. *
  6345. * @returns { uid, nickname, signature, follow, fans, likes, isPrivate, hasAvatar, gridCount }
  6346. */
  6347. function readProfile() {
  6348. var tiktok = TikTokAuto.tiktok;
  6349. var parse = TikTokAuto.parse;
  6350. var userNode = tiktok.profileUsernameNode();
  6351. var uid = userNode ? parse.username(TikTokAuto.layout.text(userNode)) : '';
  6352. var nicknameNode = tiktok.profileNicknameNode();
  6353. var signatureNode = tiktok.profileSignatureNode();
  6354. var stats = readStatsStable();
  6355. var hint = tiktok.privateHint();
  6356. var grid = tiktok.videoGridCount();
  6357. return {
  6358. uid: uid,
  6359. nickname: nicknameNode ? TikTokAuto.layout.text(nicknameNode) : '',
  6360. signature: signatureNode ? TikTokAuto.layout.text(signatureNode) : '',
  6361. follow: stats.follow,
  6362. fans: stats.fans,
  6363. likes: stats.likes,
  6364. /* 私密:读到了提示文案才算私密;读不到就是"看起来是公开的"。
  6365. * 反过来判("没读到就是私密")会把所有正常账号都排除掉。 */
  6366. isPrivate: hint !== '' && grid < 3,
  6367. privateHint: hint,
  6368. /* 头像:null = 判定不可用(截屏没授权 / 找不到头像节点) */
  6369. hasAvatar: readAvatarState(),
  6370. gridCount: grid
  6371. };
  6372. }
  6373. /**
  6374. * 读三组数字,直到**连续两次一致**才采信。
  6375. *
  6376. * ★ 这是实测踩出来的:点开用户主页后,页面会先渲染骨架、再把数字填进去,
  6377. * 中间那一小段时间读到的数字是**占位的 0**。
  6378. * 同一个账号(@user3670597276145)两次运行分别读到 78 与 0,
  6379. * 而 78 才是真的 —— 如果拿 0 去比"粉丝量 ≥ 1000",它就被冤枉地跳过了。
  6380. * 页面"安静"不代表数字已经就位,只有"两次读到一样"才是可靠的信号。
  6381. */
  6382. function readStatsStable() {
  6383. var tiktok = TikTokAuto.tiktok;
  6384. TikTokAuto.wait.tick(350);
  6385. var previous = tiktok.statNumbers();
  6386. for (var i = 0; i < 3; i += 1) {
  6387. TikTokAuto.wait.tick(350);
  6388. var current = tiktok.statNumbers();
  6389. if (current.follow === previous.follow && current.fans === previous.fans &&
  6390. current.likes === previous.likes) {
  6391. /* 三组全是 0 是**可疑值**:既可能是真新号,也可能是占位还没换掉。
  6392. * 多等 1.5 秒复核一次 —— 真新号复核后还是 0,不影响结果;
  6393. * 占位的话这一刻通常已经填上真值了(实测把 2019 粉丝读成 0 过)。 */
  6394. if (current.follow === 0 && current.fans === 0 && current.likes === 0) {
  6395. TikTokAuto.wait.tick(1500);
  6396. var recheck = tiktok.statNumbers();
  6397. if (recheck.follow !== 0 || recheck.fans !== 0 || recheck.likes !== 0) {
  6398. logger().info('profile', '三组全 0 复核后拿到真值:' +
  6399. recheck.follow + '/' + recheck.fans + '/' + recheck.likes);
  6400. return recheck;
  6401. }
  6402. }
  6403. return current;
  6404. }
  6405. previous = current;
  6406. }
  6407. logger().warn('profile', '三组数字连续三次都没稳定,采用最后一次的值(可能是中间态)');
  6408. return previous;
  6409. }
  6410. /**
  6411. * 截屏授权状态:null = 还没试过,true = 已就绪,false = 不可用。
  6412. *
  6413. * ★ 真机实测(2026-10-20):**`requestScreenCapture(false)` 是静默的** ——
  6414. * AutoX 的无障碍服务带 `CAPABILITY_CAN_TAKE_SCREENSHOT`,申请当场返回 true,
  6415. * 不弹 MediaProjection 确认框,1 秒内完成,随后 `captureScreen()` 直接可用。
  6416. * 而**不主动申请**时 `captureScreen()` 会抛 `No screen capture permission` ——
  6417. * 这就是早先"排除无头像一直不生效、每个账号还刷一条错误"的全部原因。
  6418. *
  6419. * 代价与风险:某些设备/ROM 上申请会弹一次系统授权框并阻塞。
  6420. * 所以申请失败就锁定"不可用"并**只告知一次**,不让它把整轮拖住或刷屏。
  6421. */
  6422. var screenCaptureReady = null;
  6423. function ensureScreenCapture() {
  6424. if (screenCaptureReady !== null) {
  6425. return screenCaptureReady;
  6426. }
  6427. if (typeof requestScreenCapture !== "function") {
  6428. screenCaptureReady = false;
  6429. logger().warn('profile', '这个运行时不支持截屏申请,本次运行的头像判定会跳过');
  6430. return false;
  6431. }
  6432. try {
  6433. screenCaptureReady = requestScreenCapture(false) === true;
  6434. } catch (error) {
  6435. screenCaptureReady = false;
  6436. logger().warn('profile', '申请截屏授权失败,本次运行的头像判定会跳过:' + error);
  6437. return false;
  6438. }
  6439. if (screenCaptureReady) {
  6440. logger().info('profile', '截屏授权已就绪,头像判定可用');
  6441. } else {
  6442. logger().warn('profile', '截屏授权未获批准,本次运行的头像判定会跳过');
  6443. }
  6444. return screenCaptureReady;
  6445. }
  6446. /**
  6447. * 头像是默认剪影吗?
  6448. *
  6449. * ★ "判为无头像"必须**再采一次**才采信。
  6450. * 实测发现:刚进用户页时(`waitProfile` 只等到 `@username` 出现,约 585ms),
  6451. * 头像图片往往还在下载,截到的是**灰色占位块** → 高饱和 0.000 → 误判"无头像"
  6452. * → 一个明明有头像的账号被错误跳过。
  6453. * 有色的情况不必二次确认(灰底不会自己变彩色),所以这个额外开销只落在
  6454. * "疑似无头像"的那部分账号上,约多花 400ms。
  6455. *
  6456. * @returns true(有头像)/ false(默认剪影)/ null(判不了)
  6457. */
  6458. function readAvatarState() {
  6459. if (!ensureScreenCapture()) {
  6460. return null;
  6461. }
  6462. var node = avatarNode();
  6463. if (!node) {
  6464. return null;
  6465. }
  6466. var first = sampleAvatar(node);
  6467. if (first === null || first === true) {
  6468. return first;
  6469. }
  6470. /* 第一次判为"无色":等图片加载完再确认一次 */
  6471. TikTokAuto.wait.tick(400);
  6472. var second = sampleAvatar(node);
  6473. if (second === null) {
  6474. return first;
  6475. }
  6476. if (second !== first) {
  6477. logger().info('profile', '头像两次采样不一致(' + first + ' → ' + second +
  6478. '),采用第二次 —— 第一次多半是图片还没加载');
  6479. }
  6480. return second;
  6481. }
  6482. /**
  6483. * 对头像区域采样一次,返回"是否有头像"。
  6484. * @returns true / false / null(拿不到节点或截屏失败)
  6485. */
  6486. function sampleAvatar(node) {
  6487. var box = node.bounds();
  6488. var saturated = 0;
  6489. var total = 0;
  6490. var image = null;
  6491. try {
  6492. image = captureScreen();
  6493. } catch (error) {
  6494. /* 申请过了却还是拿不到(被系统撤销 / ROM 限制):不是"没头像",
  6495. * 是"这次判不了" —— 必须返回 null,由上层如实告知。 */
  6496. screenCaptureReady = false;
  6497. logger().warn('profile', '截屏不可用,后续账号不再尝试头像判定:' + error);
  6498. return null;
  6499. }
  6500. if (!image) {
  6501. screenCaptureReady = false;
  6502. return null;
  6503. }
  6504. var stepX = (box.right - box.left) / (AVATAR_GRID + 1);
  6505. var stepY = (box.bottom - box.top) / (AVATAR_GRID + 1);
  6506. for (var row = 1; row <= AVATAR_GRID; row += 1) {
  6507. for (var col = 1; col <= AVATAR_GRID; col += 1) {
  6508. var x = Math.round(box.left + stepX * col);
  6509. var y = Math.round(box.top + stepY * row);
  6510. var pixel = 0;
  6511. try {
  6512. pixel = image.pixel(x, y);
  6513. } catch (error2) {
  6514. continue;
  6515. }
  6516. total += 1;
  6517. if (isSaturated(pixel)) {
  6518. saturated += 1;
  6519. }
  6520. }
  6521. }
  6522. if (total === 0) {
  6523. return null;
  6524. }
  6525. var ratio = saturated / total;
  6526. logger().info('profile', '头像像素判定:高饱和 ' + saturated + '/' + total +
  6527. ' = ' + ratio.toFixed(3) + '(阈值 ' + AVATAR_SATURATED_RATIO + ')');
  6528. return ratio >= AVATAR_SATURATED_RATIO;
  6529. }
  6530. /** ARGB int → 饱和度是否够高。s = (max-min)/max,避开明亮的白色背景。 */
  6531. function isSaturated(pixel) {
  6532. var r = (pixel >> 16) & 0xff;
  6533. var g = (pixel >> 8) & 0xff;
  6534. var b = pixel & 0xff;
  6535. var max = Math.max(r, Math.max(g, b));
  6536. var min = Math.min(r, Math.min(g, b));
  6537. if (max < 40) {
  6538. return false; /* 太暗:黑边/阴影,不算色彩 */
  6539. }
  6540. return (max - min) / max >= 0.25;
  6541. }
  6542. /**
  6543. * 用户主页的头像节点。
  6544. * 判据:可见 ImageView、尺寸 140~280px、位于页面上部 1/3。
  6545. * 实测新版布局在右上角([856,168][1048,360]),老布局可能在左上,所以两侧都认。
  6546. */
  6547. function avatarNode() {
  6548. var layout = TikTokAuto.layout;
  6549. var best = null;
  6550. var bestArea = 0;
  6551. var collection = className('android.widget.ImageView').find();
  6552. for (var i = 0; i < collection.size(); i += 1) {
  6553. var node = collection.get(i);
  6554. if (!layout.isVisible(node)) {
  6555. continue;
  6556. }
  6557. var box = node.bounds();
  6558. var width = box.right - box.left;
  6559. var height = box.bottom - box.top;
  6560. if (width < 140 || width > 280 || height < 140 || height > 280) {
  6561. continue;
  6562. }
  6563. if (box.top > device.height * 0.33) {
  6564. continue;
  6565. }
  6566. /* 取面积最大的那个:页面上部可能还有别的图标,头像一定是最大的 */
  6567. if (width * height > bestArea) {
  6568. bestArea = width * height;
  6569. best = node;
  6570. }
  6571. }
  6572. return best;
  6573. }
  6574. // ------------------------------------------------------------ 导航
  6575. /** 当前在哪个页面(ID 提取这条链路自己的判据) */
  6576. function where() {
  6577. return TikTokAuto.tiktok.idPageState();
  6578. }
  6579. /**
  6580. * 打开一个账号的主页(走搜索)。
  6581. *
  6582. * 为什么不直接拼 URL 打开:TikTok 的 deeplink 需要签名参数,
  6583. * 而且"用户在用的这个 TikTok 版本"才是唯一可靠的入口。
  6584. * 走搜索虽然慢(约 8 秒),但实测稳定。
  6585. *
  6586. * @returns 资料对象;失败返回 null
  6587. */
  6588. function openBySearch(uid, config) {
  6589. var step = 'openProfile';
  6590. var tiktok = TikTokAuto.tiktok;
  6591. var log = logger();
  6592. var parse = TikTokAuto.parse;
  6593. var wanted = parse.username(uid);
  6594. if (wanted === '') {
  6595. return null;
  6596. }
  6597. log.step(step, '搜索并打开 @' + wanted);
  6598. if (!ensureFeed(config)) {
  6599. return null;
  6600. }
  6601. if (!openSearch(config)) {
  6602. return null;
  6603. }
  6604. if (!submit(wanted, config)) {
  6605. return null;
  6606. }
  6607. /* 切到 Users 页签:不切的话看到的是视频,找不到人 */
  6608. var usersTab = tiktok.resultTab('Users');
  6609. if (usersTab) {
  6610. TikTokAuto.ui.clickNode(usersTab, step + ':usersTab');
  6611. /* ★ 等**结果行真的出现**,不能只等"页面安静":
  6612. * 页签条一渲染出来签名就稳定了,而结果列表还要联网加载 1~2 秒。
  6613. * 这里如果不等,就会稳定地误报"搜索结果里找不到该账号"(实测踩到)。 */
  6614. TikTokAuto.wait.waitFor(step + ':rows', function () {
  6615. return tiktok.usersResultRows().length > 0 ? true : null;
  6616. }, config.timeouts.searchResultsMs, config.probe.pollTickMs);
  6617. }
  6618. var row = findRowByUid(wanted);
  6619. if (!row) {
  6620. log.warn(step, '搜索结果里找不到 @' + wanted +
  6621. '(当前可见 ' + tiktok.usersResultRows().length + ' 行)');
  6622. return null;
  6623. }
  6624. tapRow(row, step + ':row');
  6625. var arrived = waitProfile(step);
  6626. if (!arrived) {
  6627. /* 实测:`node.click()` 打在整行容器上会落在**中心**,
  6628. * 而搜索用户行的中心那块是空白(有时还是 Follow 按钮的邻域)——
  6629. * 点了返回 true 却没进主页。左侧 30% 处(用户名文字上)才稳定。
  6630. * 所以这里再补一次"按坐标点",只重试一次,不做无限重试。 */
  6631. log.info(step, '第一次点击没进主页,改用行内坐标重试');
  6632. tapRow(row, step + ':row-retry');
  6633. arrived = waitProfile(step + ':retry');
  6634. }
  6635. if (!arrived) {
  6636. log.warn(step, '点了搜索结果但没有进入用户主页(当前 ' + where() + ')');
  6637. return null;
  6638. }
  6639. /* 主页渲染完成后,数字区可能还差一帧。读之前再确认一次"三组数字里至少有一个读到了",
  6640. * 否则会把"还没渲染"当成"这个账号没有数据"。最多再等 3 秒。 */
  6641. TikTokAuto.wait.waitFor(step + ':stats', function () {
  6642. var stats = tiktok.statNumbers();
  6643. return (stats.follow !== null || stats.fans !== null || stats.likes !== null) ? true : null;
  6644. }, 3000, 200);
  6645. return readProfile();
  6646. }
  6647. /** 搜索页 → 输入 → 提交。复用 flow 里已经验证过的锚点,但关键词由这里给。 */
  6648. function openSearch(config) {
  6649. var step = 'openSearch';
  6650. var entry = TikTokAuto.tiktok.searchEntry();
  6651. if (!entry) {
  6652. logger().error(step, '首页找不到搜索入口');
  6653. return false;
  6654. }
  6655. TikTokAuto.ui.clickNode(entry, step);
  6656. return TikTokAuto.wait.waitFor(step, function () {
  6657. var state = TikTokAuto.tiktok.idPageState();
  6658. return (state === 'search' || state === 'users-results') ? true : null;
  6659. }, config.timeouts.pageReadyMs, config.probe.pollTickMs) === true;
  6660. }
  6661. function submit(keyword, config) {
  6662. var step = 'submitKeyword';
  6663. var tiktok = TikTokAuto.tiktok;
  6664. var input = tiktok.searchInput();
  6665. if (!input) {
  6666. logger().error(step, '找不到搜索输入框');
  6667. return false;
  6668. }
  6669. TikTokAuto.ui.clickNode(input, step);
  6670. TikTokAuto.wait.tick(250);
  6671. var typed = TikTokAuto.ui.typeInto(tiktok.searchInput() || input, keyword, step);
  6672. if (!typed.ok) {
  6673. return false;
  6674. }
  6675. var button = tiktok.searchSubmit();
  6676. if (!button) {
  6677. logger().error(step, '找不到搜索提交按钮');
  6678. return false;
  6679. }
  6680. TikTokAuto.ui.clickNode(button, step);
  6681. return TikTokAuto.wait.waitFor(step, function () {
  6682. var state = tiktok.idPageState();
  6683. return (state === 'users-results' || TikTokAuto.tiktok.resultTabStripPresent()) ? true : null;
  6684. }, config.timeouts.searchResultsMs, config.probe.pollTickMs) === true;
  6685. }
  6686. /**
  6687. * 打开**自己**的主页(底部导航的 Profile)。
  6688. *
  6689. * 为什么必须先回信息流:底部导航只在首页有。当前若停在搜索页 / 播放页 /
  6690. * 别人的主页,屏幕上根本没有那个入口 —— 直接找必然失败,而且报的是
  6691. * "找不到 Profile 入口"这种看不出原因的话。
  6692. *
  6693. * 为什么不用 openBySearch(自己的用户名):引擎里没有"我是谁"这个信息
  6694. * (不带登录态、也不应该去猜),而底部导航这一步是零成本且永远正确的。
  6695. */
  6696. function openOwnProfile(config) {
  6697. var step = 'openOwnProfile';
  6698. var log = logger();
  6699. if (!ensureFeed(config)) {
  6700. log.warn(step, '回不到信息流,打不开自己的主页');
  6701. return false;
  6702. }
  6703. var nav = TikTokAuto.tiktok.profileNav();
  6704. if (!nav) {
  6705. log.warn(step, '底部导航上找不到 Profile 入口');
  6706. return false;
  6707. }
  6708. TikTokAuto.ui.clickNode(nav, step);
  6709. var ready = TikTokAuto.wait.waitFor(step, function () {
  6710. return where() === 'profile' ? true : null;
  6711. }, config.timeouts.pageReadyMs, config.probe.pollTickMs);
  6712. if (ready !== true) {
  6713. log.warn(step, '点了 Profile 但没进入用户主页');
  6714. return false;
  6715. }
  6716. /* ★ 必须等**三组数字**读全,不能用 settle(3000)(2026-10-29 真机踩到)。
  6717. *
  6718. * 「username 出现」比「数字渲染完」早得多:实测点完 Profile 1 秒就
  6719. * `where() === 'profile'` 通过,而 fans 入口挂在数字上 —— 这时 `statEntry('fans')`
  6720. * 找不到,上层看到的是"用户主页上找不到 fans 入口(等了 3 秒仍没有)",
  6721. * 一次运行就此失败。`settle()` 在动态页面上会提前返回(它的判据是
  6722. * "文案连续两次不变",而加载中与渲染完的签名可能一样),挡不住这件事。
  6723. *
  6724. * `waitProfile` 的判据正好是「username + 关注/粉丝/获赞三组都读到」,
  6725. * 而且它读不到时**只警告不失败**(有的账号确实不显示某一组)。 */
  6726. waitProfile(step + ':stats');
  6727. return true;
  6728. }
  6729. /** 在当前搜索结果里按 uid 找行(找不到返回 null,不猜第一行) */
  6730. function findRowByUid(uid) { var rows = TikTokAuto.tiktok.usersResultRows();
  6731. for (var i = 0; i < rows.length; i += 1) {
  6732. if (rows[i].uid === uid) {
  6733. return rows[i];
  6734. }
  6735. }
  6736. return null;
  6737. }
  6738. /** 保证停在信息流首页(复用 flow 的成熟实现,避免两套退出逻辑) */
  6739. function ensureFeed(config) {
  6740. return TikTokAuto.flow.ensureFeed(config) === true;
  6741. }
  6742. /**
  6743. * 在用户主页上打开「粉丝/关注」列表。
  6744. *
  6745. * ★ 这里**不能**用 `settle()` 判就绪:点完之后列表要联网加载,
  6746. * 而 `waitQuiet` 的判据是"可见文案连续两次不变" —— 加载中的列表和
  6747. * 打开前的页面**签名是一样的**,于是它会立刻就返回(实测 872ms 就回来了),
  6748. * 然后被判成"打不开列表"。正确做法是等**列表真的有行**。
  6749. *
  6750. * @param which 'fans' | 'following'
  6751. */
  6752. function openList(which, config) {
  6753. var step = 'openList';
  6754. var tiktok = TikTokAuto.tiktok;
  6755. var wanted = which === 'following' ? 'following' : 'fans';
  6756. var entry = tiktok.statEntry(wanted);
  6757. if (!entry) {
  6758. /* ★ 找不到入口时**再等一次**,不要立刻报失败。
  6759. * 实测(用户 2026-10-21 反馈):从搜索结果进主页后,标题已经出来了,
  6760. * 但数字区还在渲染,这时 `statEntry()` 返回 null,直接判"找不到 fans 入口",
  6761. * 用户看到的现象就是"还没加载出来就已经返回了"。重跑往往就正常 —— 典型的时序问题。
  6762. * 这里最多等 3 秒,够渲染完;真没有(比如私密号不显示列表)就如实失败。 */
  6763. logger().info(step, '暂时找不到 ' + wanted + ' 入口,等页面渲染完再试');
  6764. TikTokAuto.wait.waitFor(step + ':entry', function () {
  6765. entry = tiktok.statEntry(wanted);
  6766. return entry !== null ? true : null;
  6767. }, 3000, 300);
  6768. }
  6769. if (!entry) {
  6770. logger().warn(step, '用户主页上找不到 ' + wanted + ' 入口(等了 3 秒仍没有)');
  6771. return false;
  6772. }
  6773. logger().step(step, '打开列表:' + wanted);
  6774. TikTokAuto.ui.clickNode(entry, step);
  6775. var ready = TikTokAuto.wait.waitFor(step, function () {
  6776. return where() === 'user-list' ? true : null;
  6777. }, config.timeouts.pageReadyMs, config.probe.pollTickMs);
  6778. if (ready === true) {
  6779. return true;
  6780. }
  6781. /* 与粉丝列表相关的一个真机实况:首次打开会弹「给 TikTok 访问你的 Facebook 好友列表」
  6782. * 的授权窗,盖住整个列表。它不是错误页,但会让"列表就绪"永远等不到。
  6783. * 这里点掉它再等一次 —— 只做一轮,不无限重试。 */
  6784. if (dismissFriendPrompt()) {
  6785. logger().info(step, '点掉了好友列表授权窗,再等一次');
  6786. ready = TikTokAuto.wait.waitFor(step + ':retry', function () {
  6787. return where() === 'user-list' ? true : null;
  6788. }, config.timeouts.pageReadyMs, config.probe.pollTickMs);
  6789. }
  6790. return ready === true;
  6791. }
  6792. /**
  6793. * 点掉「Facebook 好友列表」授权窗的"不允许"。
  6794. *
  6795. * 实测这个弹窗的文案是英文 `Don’t allow`(撇号是 U+2019,不是 ASCII 单引号,
  6796. * 直接比对 'Don\'t allow' 是匹配不上的 —— 这里按**包含**比对并同时认几种写法)。
  6797. * 找不到就当没有弹窗,返回 false,由上层决定下一步。
  6798. */
  6799. function dismissFriendPrompt() {
  6800. var layout = TikTokAuto.layout;
  6801. var keywords = ['Facebook', '好友列表', 'friends list'];
  6802. var buttons = className('android.widget.Button').find();
  6803. var hint = false;
  6804. for (var i = 0; i < buttons.size(); i += 1) {
  6805. var text = layout.text(buttons.get(i));
  6806. if (text === '') {
  6807. continue;
  6808. }
  6809. if (text.indexOf('allow') >= 0 || text.indexOf('允许') >= 0 || text.indexOf('允許') >= 0) {
  6810. hint = true;
  6811. continue;
  6812. }
  6813. for (var k = 0; k < keywords.length; k += 1) {
  6814. if (text.indexOf(keywords[k]) >= 0) {
  6815. hint = true;
  6816. break;
  6817. }
  6818. }
  6819. }
  6820. if (!hint) {
  6821. return false;
  6822. }
  6823. /* 有授权窗:找那个"不允许 / Don't allow",它一定比"OK"靠左(实测 [260,1114][539,1209] vs [540,1114][819,1209]) */
  6824. var target = null;
  6825. for (var j = 0; j < buttons.size(); j += 1) {
  6826. var label = layout.text(buttons.get(j));
  6827. if (/don.t allow|不允许|不允許|稍后|稍後|not now|no thanks/i.test(label)) {
  6828. if (target === null || buttons.get(j).bounds().left < target.bounds().left) {
  6829. target = buttons.get(j);
  6830. }
  6831. }
  6832. }
  6833. if (!target) {
  6834. return false;
  6835. }
  6836. logger().info('profile', '点掉授权窗:' + JSON.stringify(TikTokAuto.layout.text(target)));
  6837. TikTokAuto.ui.clickNode(target, 'dismissFriendPrompt');
  6838. settle(2000);
  6839. return true;
  6840. }
  6841. /**
  6842. * 在列表页切页签。已在目标页签上时直接返回 true(不重复点)。
  6843. * @param which 'fans' | 'following'
  6844. */
  6845. function switchListTab(which) {
  6846. var step = 'switchTab';
  6847. var tabs = TikTokAuto.tiktok.userListTabs();
  6848. var wanted = which === 'following' ? 'following' : 'fans';
  6849. for (var i = 0; i < tabs.length; i += 1) {
  6850. if (tabs[i].key !== wanted) {
  6851. continue;
  6852. }
  6853. if (TikTokAuto.tiktok.selectedListTab() === wanted) {
  6854. logger().info(step, '已在 ' + wanted + ' 页签上');
  6855. return true;
  6856. }
  6857. logger().info(step, '切到 ' + wanted + ' 页签(' + tabs[i].label + ')');
  6858. TikTokAuto.ui.clickNode(tabs[i].node, step);
  6859. settle(3000);
  6860. return true;
  6861. }
  6862. logger().warn(step, '找不到 ' + wanted + ' 页签');
  6863. return false;
  6864. }
  6865. /**
  6866. * 当前在不在"一个可以逐行取 uid 的列表页"上。
  6867. *
  6868. * 有两种,长得不一样、资源 id 也不同,但对上层来说都是"一屏一屏的用户行":
  6869. * · `user-list` 粉丝/关注列表(`txt_desc` 装 uid)
  6870. * · `users-results` 搜索结果的用户页签(`tv_username` 装 uid)
  6871. * 上层不该关心是哪种 —— 这正是这个判据存在的意义。
  6872. */
  6873. function isListPage(state) {
  6874. var now = state || where();
  6875. return now === 'user-list' || now === 'users-results';
  6876. }
  6877. /**
  6878. * 列表当前可见的每一行(已按屏幕从上到下排序)。
  6879. *
  6880. * ★ **按当前页面选解析器**,这是实测踩出来的一个"看起来能跑但永远 0 行"的 bug:
  6881. * 当初这里写死成 `userListRows()`(粉丝列表的解析器,找 `txt_desc`),
  6882. * 而搜索结果页的用户行是 `tv_username` —— 于是"取材=搜索结果"时,
  6883. * 上一步 `usersResultRows()` 明明读到了行(日志里 `userRows` 就绪),
  6884. * 下一步 `listRows()` 却返回空,最后表现为"处理 0 行、一个都没抓到"。
  6885. * 两种页面的行结构不同,但**对调用方是同一件事**,所以分派必须留在这里。
  6886. */
  6887. function listRows() {
  6888. if (where() === 'users-results') {
  6889. return TikTokAuto.tiktok.usersResultRows();
  6890. }
  6891. return TikTokAuto.tiktok.userListRows();
  6892. }
  6893. /**
  6894. * 列表向下翻一屏。
  6895. *
  6896. * 起点刻意选在屏幕中部偏下、终点在中部偏上:太靠下会落到 Follow 按钮上
  6897. * (按钮吃掉手势,页面纹丝不动 —— 这个坑在界面上踩过一次,这里避开)。
  6898. *
  6899. * @returns 翻之前那一屏的 uid 签名,供调用方判断"翻完有没有新东西"
  6900. */
  6901. function scrollList() {
  6902. var layout = TikTokAuto.layout;
  6903. var screen = layout.screen();
  6904. TikTokAuto.ui.dragScreen(
  6905. Math.floor(screen.width * 0.3), Math.floor(screen.height * 0.78),
  6906. Math.floor(screen.width * 0.3), Math.floor(screen.height * 0.3),
  6907. 420
  6908. );
  6909. settle(2500);
  6910. }
  6911. /** 点列表里的一行,进入该用户主页 */
  6912. function openRow(row) {
  6913. var step = 'openRow';
  6914. if (!row) {
  6915. return false;
  6916. }
  6917. logger().info(step, '点开 @' + row.uid);
  6918. tapRow(row, step);
  6919. return waitProfile(step);
  6920. }
  6921. /**
  6922. * 等用户主页**真的渲染出来**。
  6923. *
  6924. * ★ 不能只等 `settle()`(页面安静):点开主页后骨架先出现、`@username`
  6925. * 那几个字后出现,中间页面是"安静的" —— 于是 settle 会提前返回,
  6926. * 紧接着 readProfile() 读到一份**空资料**。
  6927. * 实测后果:一个粉丝数为 0 的账号被当成"读不到资料",或被误判过滤。
  6928. * 判据必须是"@username 节点已经存在"。
  6929. */
  6930. /**
  6931. * 等用户主页**真的渲染完**,而不是"标题出来了就算到"。
  6932. *
  6933. * ★ 两段等待,缺一不可(实测踩出来的):
  6934. *
  6935. * ① 等 `@username` 出现 —— 这一步很快(约 585ms),因为标题先渲染;
  6936. * ② 再等顶部三组数字(关注/粉丝/获赞)**至少读到两个**。
  6937. *
  6938. * 只做 ① 会出两类问题,而且都很难查:
  6939. * · 数字区还是占位的 0 → 把"粉丝 78"读成"粉丝 0"→ 按数量门槛被错误跳过;
  6940. * · 数字区根本没渲染 → `statEntry()` 找不到"粉丝/关注"那个可点入口
  6941. * → 报"用户主页上找不到 fans 入口"(用户看到的就是"还没加载出来就返回了")。
  6942. *
  6943. * 第 ② 段是**尽力等**:有些账号(私密号、无数据的新号)可能只有一组数字,
  6944. * 所以超时也照常返回 true —— 不能因为等不到三组就把正常账号判成失败。
  6945. */
  6946. function waitProfile(step) {
  6947. var tiktok = TikTokAuto.tiktok;
  6948. var arrived = TikTokAuto.wait.waitFor(step + ':profile', function () {
  6949. return tiktok.profileUsernameNode() !== null ? true : null;
  6950. }, 10000, 300);
  6951. if (arrived !== true) {
  6952. return false;
  6953. }
  6954. /* 第二段:数字区。**三组都要读到**,10 秒上限。
  6955. *
  6956. * 为什么要求三组而不是两组:TikTok 会先把数字渲染成占位的 0,
  6957. * 再逐组换成真值。要求"两组"时,`粉丝 0 / 关注 0 / 获赞 null` 这种
  6958. * 半渲染状态就算通过了 —— 实测把 @.1523615 的 2019 粉丝读成了 0,
  6959. * 而这类错误会直接影响数量门槛的判定(读成 0 就会被"粉丝≥1000"筛掉)。 */
  6960. var statsReady = TikTokAuto.wait.waitFor(step + ':stats', function () {
  6961. var stats = tiktok.statNumbers();
  6962. return (stats.follow !== null && stats.fans !== null && stats.likes !== null)
  6963. ? true : null;
  6964. }, 10000, 300);
  6965. if (statsReady !== true) {
  6966. /* 不是错误:确实有账号不显示某一组(新号可能没有获赞)。
  6967. * 但要在日志里留痕,因为后面的"读不到数量"多半是这里没等到。 */
  6968. var last = tiktok.statNumbers();
  6969. logger().info('profile', '三组数字 10 秒内没读全(关注 ' + last.follow +
  6970. ' / 粉丝 ' + last.fans + ' / 获赞 ' + last.likes + '),继续');
  6971. }
  6972. return true;
  6973. }
  6974. /**
  6975. * 点一行用户条目。
  6976. *
  6977. * 用**坐标**而不是 `node.click()`:整行容器的 click() 在真机上常常落到
  6978. * "行内第一个可点元素"(也就是右侧那个 Follow 按钮),会把用户关注掉 ——
  6979. * 这是不能接受的副作用。所以点在行左侧 30% 处(用户名文字上),远离按钮。
  6980. * 实测这一处既能进主页,也不会误触关注。
  6981. */
  6982. function tapRow(row, step) {
  6983. if (row.x === undefined || row.y === undefined) {
  6984. TikTokAuto.ui.clickNode(row.node, step || 'tapRow');
  6985. return;
  6986. }
  6987. TikTokAuto.ui.tapPoint(row.x, row.y);
  6988. }
  6989. /** 从用户主页退回列表(列表位置会保持,实测);两种列表页都认 */
  6990. function backToList() {
  6991. var step = 'backToList';
  6992. var deadline = Date.now() + 10000;
  6993. while (Date.now() < deadline) {
  6994. var state = where();
  6995. if (isListPage(state)) {
  6996. return true;
  6997. }
  6998. if (state === 'profile' || state === 'comment-panel') {
  6999. TikTokAuto.ui.goBack(step);
  7000. settle(2000);
  7001. continue;
  7002. }
  7003. /* 已经退到搜索页/结果页/信息流:说明退过头了(列表已经不在栈上)。
  7004. * 这种情况不该再往回退 —— 再退就退到首页去了。 */
  7005. return false;
  7006. }
  7007. return isListPage();
  7008. }
  7009. /** 从列表退回用户主页 */
  7010. function backToProfile() {
  7011. var step = 'backToProfile';
  7012. var deadline = Date.now() + 8000;
  7013. while (Date.now() < deadline) {
  7014. var state = where();
  7015. if (state === 'profile') {
  7016. return true;
  7017. }
  7018. TikTokAuto.ui.goBack(step);
  7019. settle(2000);
  7020. }
  7021. return where() === 'profile';
  7022. }
  7023. /**
  7024. * 从用户主页退回评论区。
  7025. *
  7026. * 与 backToProfile/backToList 是一组:评论区取材时每个评论者都要
  7027. * "点头像 → 读 → 退回来",退错一层(退到播放页而不是评论区)就会
  7028. * 让下一轮找不到评论者。所以这里按状态确认,不靠数次数。
  7029. */
  7030. function backToCommentPanel() {
  7031. var step = 'backToComment';
  7032. var deadline = Date.now() + 10000;
  7033. while (Date.now() < deadline) {
  7034. var state = where();
  7035. if (state === 'comment-panel') {
  7036. return true;
  7037. }
  7038. if (state === 'player') {
  7039. /* 退到了播放页:把评论面板重新点开,而不是继续退 */
  7040. var button = TikTokAuto.tiktok.commentButton();
  7041. if (button) {
  7042. TikTokAuto.ui.clickNode(button, step + ':reopen');
  7043. settle(2500);
  7044. if (where() === 'comment-panel') {
  7045. return true;
  7046. }
  7047. }
  7048. return false;
  7049. }
  7050. TikTokAuto.ui.goBack(step);
  7051. settle(2000);
  7052. }
  7053. return where() === 'comment-panel';
  7054. }
  7055. return {
  7056. readProfile: readProfile,
  7057. readAvatarState: readAvatarState,
  7058. avatarNode: avatarNode,
  7059. where: where,
  7060. ensureFeed: ensureFeed,
  7061. openBySearch: openBySearch,
  7062. openOwnProfile: openOwnProfile,
  7063. findRowByUid: findRowByUid,
  7064. openList: openList,
  7065. switchListTab: switchListTab,
  7066. listRows: listRows,
  7067. isListPage: isListPage,
  7068. /** 给外部用的"等主页就绪":等 @username + 三组数字,见 waitProfile 的注释 */
  7069. waitProfilePublic: function () { return waitProfile('waitProfile'); },
  7070. scrollList: scrollList,
  7071. openRow: openRow,
  7072. backToList: backToList,
  7073. backToProfile: backToProfile,
  7074. backToCommentPanel: backToCommentPanel,
  7075. settle: settle,
  7076. dismissFriendPrompt: dismissFriendPrompt,
  7077. AVATAR_SATURATED_RATIO: AVATAR_SATURATED_RATIO
  7078. };
  7079. }());
  7080. /* ============================== src/idsource.js ============================== */
  7081. /**
  7082. * idsource.js — 五种「从哪儿找人」,以及深度展开
  7083. *
  7084. * ## 为什么是五种而不是四张脚本卡
  7085. * 旧的四个脚本卡是「起点 × 深度」的 4 个**不完整组合**。把它们拆开后发现,
  7086. * 真正不同的只有一件事:**候选 ID 从哪儿来**。于是收敛成五种起点
  7087. * (指定账号 / 搜索结果 / 某个视频的评论区 / 文本文件 / 当前屏幕),
  7088. * 深度(再取这些人的粉丝)是所有起点共用的一个选项。
  7089. *
  7090. * ## 三种遍历模式
  7091. * 五种起点在"怎么走"上只有三种形态,实现都归到这三条路径上:
  7092. * · **列表型**(指定账号 / 搜索用户 / 当前屏幕)—— 逐屏读行,可翻页;
  7093. * · **文本型**(文本文件)—— 一次性读出每一行;
  7094. * · **逐个点开型**(搜索视频作者 / 评论区)—— 屏幕上的条目没有 uid,
  7095. * 只能点进去看(视频页底部有 `@uid`,评论者要靠头像进用户页)。
  7096. *
  7097. * ## 深度展开为什么分两阶段
  7098. * "取这批人的粉丝"如果边遍历边展开,会嵌套两层列表上下文 ——
  7099. * 打开 B 列表时 A 的滚动位置就丢了。所以做成:
  7100. * ① 先把基础层收完,边收边记下"这些人要展开";
  7101. * ② 再对这批人逐个打开其粉丝/关注列表。
  7102. * 两层都线性,没有嵌套,也就没有"回到上一层时不知道走到哪"的问题。
  7103. * **上限固定两层**:100 粉丝 × 每人 100 粉丝 = 1 万次开页,真机上不现实。
  7104. *
  7105. * 依赖:TikTokAuto.tiktok / profile / filters / parse / logger / wait
  7106. */
  7107. TikTokAuto.idsource = (function () {
  7108. 'use strict';
  7109. /** 翻一个列表最多翻多少屏。防止"永远有新内容"(推荐流)把一轮拖成无限长。 */
  7110. var MAX_SCREENS = 60;
  7111. /**
  7112. * 连续这么多次"翻屏后内容不变"就认为到头了(有的列表末尾会重复推荐位)。
  7113. *
  7114. * ★ 注意判据是**翻不动**,不是"没有新 uid"(2026-10-28 修,见 walkStep 的注释)。
  7115. */
  7116. var IDLE_SCREENS_TO_STOP = 2;
  7117. function logger() { return TikTokAuto.logger; }
  7118. /**
  7119. * 一屏处理完之后决定下一步。纯函数,便于在 PC 上回归(`tools/pc-selftest-id.js`)。
  7120. *
  7121. * ★ 这里修的是一个**真 bug**(2026-10-28)。
  7122. *
  7123. * 旧实现有两个地方同时给 `idle` 加一:
  7124. * ① 本屏 `fresh` 为空(全是已收过的人)→ idle + 1
  7125. * ② 翻屏后签名不变 → idle + 1
  7126. * 而 `idflow` 会先把 `uid_extracted.txt` 预载进 `seen`。于是**第二轮跑同一个账号**时,
  7127. * 前两屏全是历史 → idle=2 → **在列表顶部就判"到底"**,一条新人都拿不到。
  7128. * 断点续跑要把上次的产物也预载进 `seen`,这个误判会从"偶发"变成"必然"。
  7129. *
  7130. * 正解:**只看"翻得动翻不动"**。一屏全是旧人说明"旧人多",不代表列表到头;
  7131. * 只有"滚动之后签名没变"才是真的到头。
  7132. *
  7133. * @param state { screens, stuck }
  7134. * @param view { sigBefore, sigAfter, rowsAfter, maxScreens }
  7135. * @returns { action: 'continue'|'break', state, reason }
  7136. */
  7137. function walkStep(state, view) {
  7138. var stuck = state.stuck;
  7139. var screens = state.screens;
  7140. if (view.rowsAfter === 0) {
  7141. /* 翻完这一屏读不到行了:可能还在加载,也可能真的到头。
  7142. * 不在这里下结论 —— 交给下一轮循环开头的"空行即结束"处理
  7143. * (这样"加载慢"和"列表结束"两个判断只在一处)。 */
  7144. return { action: 'continue', reason: '', state: { screens: screens, stuck: stuck } };
  7145. }
  7146. if (view.sigAfter === view.sigBefore) {
  7147. stuck += 1;
  7148. if (stuck >= IDLE_SCREENS_TO_STOP) {
  7149. return {
  7150. action: 'break',
  7151. reason: '翻屏后内容不变,认为列表到底',
  7152. state: { screens: screens, stuck: stuck }
  7153. };
  7154. }
  7155. } else {
  7156. /* 内容变了 → 列表还在往下走。**不管这一屏里有没有新人**都归零。 */
  7157. stuck = 0;
  7158. }
  7159. if (screens >= view.maxScreens) {
  7160. return {
  7161. action: 'break',
  7162. reason: '达到单列表翻屏上限 ' + view.maxScreens + ' 屏',
  7163. state: { screens: screens, stuck: stuck }
  7164. };
  7165. }
  7166. return { action: 'continue', reason: '', state: { screens: screens, stuck: stuck } };
  7167. }
  7168. /** 一行 uid 的临时载体(列表行带昵称与计数,用户页带更多资料,统一成这个形状) */
  7169. function rowOf(uid, nickname, extra) {
  7170. var one = { uid: uid, nickname: nickname || '' };
  7171. if (extra) {
  7172. for (var key in extra) {
  7173. if (extra.hasOwnProperty(key)) {
  7174. one[key] = extra[key];
  7175. }
  7176. }
  7177. }
  7178. return one;
  7179. }
  7180. /* ============================================================ 列表型 */
  7181. /**
  7182. * 在"一个可滚动的用户列表"上逐行推进。
  7183. *
  7184. * 关键设计:**用 uid 集合决定"哪一行还要处理",不用行下标**。
  7185. * 因为每次点进用户页再退回来,列表的节点树都重建过一遍(引用失效),
  7186. * 只有 uid 是稳的。
  7187. *
  7188. * 但**停止判据不能用 uid 集合** —— 那会把"全是旧人"误判成"列表到底"(见 walkStep)。
  7189. *
  7190. * @param ctx { seen, onRow(row), shouldStop(), maxScreens?, stat?, config? }
  7191. * maxScreens:本次最多翻多少屏(续跑时按上次已翻的屏数放大,见 idflow)
  7192. * stat:传入则回填 { screens, handled },供断点记录
  7193. * @returns 处理了多少行(本屏新收的)
  7194. */
  7195. function walkList(ctx) {
  7196. var log = logger();
  7197. var handled = 0;
  7198. var maxScreens = ctx.maxScreens || MAX_SCREENS;
  7199. var state = { screens: 0, stuck: 0 };
  7200. while (state.screens < maxScreens) {
  7201. if (ctx.shouldStop()) {
  7202. log.info('idsource', '达到停止条件,结束当前列表');
  7203. break;
  7204. }
  7205. state.screens += 1;
  7206. var rows = TikTokAuto.profile.listRows();
  7207. if (rows.length === 0 && state.screens === 1) {
  7208. /* 首屏还在加载。**等一次**再判定,否则会把"慢"当成"空":
  7209. * 从用户页点开粉丝列表、或从结果页切到用户页签后,
  7210. * 容器先出来、行后出来(实测差 1~2 秒),这时 listRows() 返回空数组 ——
  7211. * 直接 break 就等于"一条都没抓到"。 */
  7212. TikTokAuto.wait.waitFor('listRows', function () {
  7213. return TikTokAuto.profile.listRows().length > 0 ? true : null;
  7214. }, ctx.config ? ctx.config.timeouts.pageReadyMs : 8000, 300);
  7215. rows = TikTokAuto.profile.listRows();
  7216. }
  7217. if (rows.length === 0) {
  7218. log.info('idsource', '当前屏没有读到用户行,结束列表');
  7219. break;
  7220. }
  7221. /* 一行一行处理:只处理没见过的人(见过的直接跳过,但**不因此判定列表到底**)。
  7222. * 需要资料时会点进用户页再退回(实测位置保持)。 */
  7223. var stopped = false;
  7224. for (var i = 0; i < rows.length; i += 1) {
  7225. if (ctx.shouldStop()) {
  7226. stopped = true;
  7227. break;
  7228. }
  7229. if (ctx.seen.has(rows[i].uid)) {
  7230. continue;
  7231. }
  7232. ctx.onRow(rows[i]);
  7233. ctx.seen.add(rows[i].uid); /* 处理过就算见过,避免同一行被反复点开 */
  7234. handled += 1;
  7235. }
  7236. if (stopped) {
  7237. break;
  7238. }
  7239. /* 翻一屏,用"内容有没有变"判断是否到头 */
  7240. var before = signatureOfRows(rows);
  7241. TikTokAuto.profile.scrollList();
  7242. var afterRows = TikTokAuto.profile.listRows();
  7243. var step = walkStep(state, {
  7244. sigBefore: before,
  7245. sigAfter: signatureOfRows(afterRows),
  7246. rowsAfter: afterRows.length,
  7247. maxScreens: maxScreens
  7248. });
  7249. state = step.state;
  7250. if (step.action === 'break') {
  7251. log.info('idsource', step.reason);
  7252. break;
  7253. }
  7254. }
  7255. if (ctx.stat) {
  7256. ctx.stat.screens = state.screens;
  7257. ctx.stat.handled = handled;
  7258. }
  7259. return handled;
  7260. }
  7261. /** 一屏 uid 的签名,用来判断"翻完到底有没有变" */
  7262. function signatureOfRows(rows) {
  7263. var parts = [];
  7264. for (var i = 0; i < rows.length; i += 1) {
  7265. parts.push(rows[i].uid);
  7266. }
  7267. return parts.join('|');
  7268. }
  7269. /* ============================================================ 文本型 */
  7270. /**
  7271. * 从本地文本文件读 uid。
  7272. *
  7273. * 两条容忍(都是为了让用户不用改自己的文件):
  7274. * · 行首的 `@` 会被剥掉(有人习惯直接粘贴 "@name");
  7275. * · `#` 开头的行当注释跳过。
  7276. */
  7277. function readTextSource(source) {
  7278. var list = TikTokAuto.uidset.readUidFile(source.path);
  7279. logger().info('idsource', '文本取材:' + source.path + ' 读到 ' + list.length + ' 行');
  7280. var rows = [];
  7281. for (var i = 0; i < list.length; i += 1) {
  7282. rows.push(rowOf(list[i], ''));
  7283. }
  7284. return rows;
  7285. }
  7286. /* ============================================================ 逐个点开型 */
  7287. /**
  7288. * 搜索结果里的视频 → 逐个点开,读播放页底部的作者 uid。
  7289. *
  7290. * 实测:搜索结果的视频卡片上只有**作者昵称**,没有 uid;
  7291. * 播放页底部有一个 `Button` 文案是 `@uid` —— 这是唯一能拿到 uid 的地方。
  7292. * 所以每个视频要"点开 → 读 → 退回",约 6 秒一个。
  7293. */
  7294. function walkSearchVideos(source, ctx) {
  7295. var log = logger();
  7296. var handled = 0;
  7297. var idle = 0;
  7298. for (var screen = 0; screen < MAX_SCREENS; screen += 1) {
  7299. if (ctx.shouldStop()) {
  7300. break;
  7301. }
  7302. var cards = TikTokAuto.tiktok.videoCards();
  7303. if (cards.length === 0) {
  7304. /* 0 张卡可能是"到底了",也可能是"列表正在重排/图片在加载"。
  7305. * 先给 5 秒,等不到再判定到底 —— 直接 break 会把慢当成空。 */
  7306. cards = TikTokAuto.flow.waitForCards(ctx.config, 5000) || [];
  7307. }
  7308. if (cards.length === 0) {
  7309. log.info('idsource', '结果页没有卡片了');
  7310. break;
  7311. }
  7312. var opened = 0;
  7313. for (var i = 0; i < cards.length; i += 1) {
  7314. if (ctx.shouldStop()) {
  7315. return handled;
  7316. }
  7317. var card = cards[i];
  7318. var marker = 'card:' + String(card.caption).slice(0, 40);
  7319. if (ctx.seen.has(marker)) {
  7320. continue;
  7321. }
  7322. ctx.seen.add(marker);
  7323. if (!TikTokAuto.flow.openCard(ctx.config, card)) {
  7324. log.warn('idsource', '打开视频失败:' + marker);
  7325. TikTokAuto.flow.returnToResults(ctx.config);
  7326. continue;
  7327. }
  7328. var uid = TikTokAuto.tiktok.playerAuthorUid();
  7329. if (uid) {
  7330. ctx.onRow(rowOf(uid, String(card.caption).slice(0, 30)));
  7331. handled += 1;
  7332. opened += 1;
  7333. } else {
  7334. log.warn('idsource', '播放页读不到作者 uid');
  7335. }
  7336. TikTokAuto.flow.returnToResults(ctx.config);
  7337. }
  7338. if (opened === 0) {
  7339. idle += 1;
  7340. if (idle >= IDLE_SCREENS_TO_STOP) {
  7341. log.info('idsource', '连续几屏都读不到作者,结束');
  7342. break;
  7343. }
  7344. } else {
  7345. idle = 0;
  7346. }
  7347. TikTokAuto.flow.scrollResults(ctx.config);
  7348. }
  7349. return handled;
  7350. }
  7351. /**
  7352. * 评论区 → 逐个点评论者的头像进用户页读 uid。
  7353. *
  7354. * 实测这条最贵:评论区只显示昵称,没有 uid(`Button title` 是昵称,
  7355. * 旁边那个 `@uid` 是固定节点、不是评论者的)。所以每个评论者都要
  7356. * "点头像 → 读 → 退回",约 8 秒一个。这也是为什么方案里
  7357. * 把"评论者"和"评论者的粉丝"分开成两条取材,而不是默认都开。
  7358. */
  7359. function walkCommenters(source, ctx) {
  7360. var log = logger();
  7361. var handled = 0;
  7362. for (var screen = 0; screen < MAX_SCREENS; screen += 1) {
  7363. if (ctx.shouldStop()) {
  7364. break;
  7365. }
  7366. var rows = TikTokAuto.tiktok.commentRowers();
  7367. if (rows.length === 0) {
  7368. log.info('idsource', '评论区没有更多可见条目');
  7369. break;
  7370. }
  7371. var opened = 0;
  7372. var before = signatureOfNodes(rows);
  7373. for (var i = 0; i < rows.length; i += 1) {
  7374. if (ctx.shouldStop()) {
  7375. return handled;
  7376. }
  7377. /* 头像节点每次都会重建,用"位置 + 序号"当标记 —— 换一屏位置就会变 */
  7378. var marker = 'cmt:' + rows[i].top + ':' + (screen * 100 + i);
  7379. if (ctx.seen.has(marker)) {
  7380. continue;
  7381. }
  7382. ctx.seen.add(marker);
  7383. TikTokAuto.ui.tapPoint(rows[i].x, rows[i].y);
  7384. /* ★ 用 waitProfile(等 @username + 三组数字),而不是 settle + where()。
  7385. *
  7386. * 早先这里是"等页面安静 3 秒,再看 where() 是不是 profile":
  7387. * · settle 会提前返回(骨架先出、内容后加载,签名看起来没变);
  7388. * · 而 where() 要求 `@username` 已经渲染出来。
  7389. * 两头一凑,就成了"人明明已经进主页了,却被判成没进去"——
  7390. * 实测日志里连续两条"点评论者头像没有进入用户主页",然后一条都没取到。
  7391. */
  7392. if (!TikTokAuto.profile.waitProfilePublic()) {
  7393. log.warn('idsource', '点评论者头像没有进入用户主页(等了 10 秒)');
  7394. continue;
  7395. }
  7396. var profile = TikTokAuto.profile.readProfile();
  7397. if (profile.uid) {
  7398. /* 资料已经在手上了,带上 `_profile` 让编排层不必再开一次用户页 */
  7399. ctx.onRow(rowOf(profile.uid, profile.nickname, { _profile: profile }));
  7400. handled += 1;
  7401. opened += 1;
  7402. }
  7403. TikTokAuto.profile.backToCommentPanel();
  7404. }
  7405. if (opened === 0) {
  7406. break;
  7407. }
  7408. /* 评论区滚动 */
  7409. var layout = TikTokAuto.layout;
  7410. var screenSize = layout.screen();
  7411. TikTokAuto.ui.dragScreen(
  7412. Math.floor(screenSize.width * 0.5), Math.floor(screenSize.height * 0.8),
  7413. Math.floor(screenSize.width * 0.5), Math.floor(screenSize.height * 0.4),
  7414. 420
  7415. );
  7416. TikTokAuto.profile.settle(2000);
  7417. if (signatureOfNodes(TikTokAuto.tiktok.commentRowers()) === before) {
  7418. log.info('idsource', '评论区滚动后内容不变,结束');
  7419. break;
  7420. }
  7421. }
  7422. return handled;
  7423. }
  7424. function signatureOfNodes(rows) {
  7425. var parts = [];
  7426. for (var i = 0; i < rows.length; i += 1) {
  7427. parts.push(rows[i].top);
  7428. }
  7429. return parts.join(',');
  7430. }
  7431. /* ============================================================ 入口 */
  7432. /**
  7433. * 把一个取材源跑完,逐行交给 `onRow`。
  7434. *
  7435. * @param source 取材项(与界面 sources.js 的形状一致)
  7436. * @param ctx { config, seen, onRow(row), shouldStop() }
  7437. * @returns 处理的行数
  7438. */
  7439. function run(source, ctx) {
  7440. var log = logger();
  7441. log.step('idsource', '取材开始:' + describe(source));
  7442. if (source.kind === 'text') {
  7443. /* 文本源也走 `onRow`,**不在这一层自己判去重** ——
  7444. * 去重与过滤是编排层(idflow)的职责,在这里再做一遍会让
  7445. * `duplicates` 统计漏掉(实测:跨轮去重跳了 3 个,summary 里却是 0)。 */
  7446. var rows = readTextSource(source);
  7447. var handled = 0;
  7448. for (var i = 0; i < rows.length; i += 1) {
  7449. if (ctx.shouldStop()) {
  7450. break;
  7451. }
  7452. ctx.onRow(rows[i]);
  7453. handled += 1;
  7454. }
  7455. return handled;
  7456. }
  7457. if (source.kind === 'screen') {
  7458. /* 当前屏幕:不做任何导航,直接把眼前这一屏当列表读。
  7459. *
  7460. * 但要先确认眼前确实是"一个用户列表" —— 否则 walkList 会一屏一屏地读,
  7461. * 最后报"处理 0 行",用户完全不知道是页面不对。 */
  7462. var state = TikTokAuto.profile.where();
  7463. if (!TikTokAuto.profile.isListPage(state)) {
  7464. log.warn('idsource', '当前屏幕取材:需要先把页面停在「用户列表」(粉丝/关注/搜索用户)上,' +
  7465. '引擎不会替你跳转。现在停在「' + state + '」');
  7466. return 0;
  7467. }
  7468. return walkList(ctx);
  7469. }
  7470. if (source.kind === 'anchor') {
  7471. return runAnchor(source, ctx);
  7472. }
  7473. if (source.kind === 'search') {
  7474. if (source.searchType === 'videos') {
  7475. if (!prepareSearch(source.keyword, ctx.config)) {
  7476. return 0;
  7477. }
  7478. TikTokAuto.flow.selectResultTab({
  7479. target: { resultTab: 'Top' },
  7480. timeouts: ctx.config.timeouts,
  7481. probe: ctx.config.probe
  7482. });
  7483. return walkSearchVideos(source, ctx);
  7484. }
  7485. if (!prepareSearch(source.keyword, ctx.config)) {
  7486. return 0;
  7487. }
  7488. return walkList(ctx);
  7489. }
  7490. if (source.kind === 'video') {
  7491. if (!prepareVideo(source, ctx.config)) {
  7492. return 0;
  7493. }
  7494. return walkCommenters(source, ctx);
  7495. }
  7496. log.warn('idsource', '未知的取材起点:' + source.kind);
  7497. return 0;
  7498. }
  7499. /**
  7500. * 指定账号:打开它的主页 → 按 `lists` 逐个开列表 → 每处走一遍 walkList。
  7501. *
  7502. * `anchors` 为空表示"不限定账号,从当前屏幕取" —— 这是界面允许的状态,
  7503. * 对应 v6 时代「主播 ID 留空」的语义。
  7504. */
  7505. function runAnchor(source, ctx) {
  7506. var log = logger();
  7507. var parse = TikTokAuto.parse;
  7508. var anchors = String(source.anchors || '').split('#');
  7509. var handled = 0;
  7510. var accounts = [];
  7511. for (var i = 0; i < anchors.length; i += 1) {
  7512. var name = parse.username(anchors[i]);
  7513. if (name !== '') {
  7514. accounts.push(name);
  7515. }
  7516. }
  7517. if (accounts.length === 0) {
  7518. log.info('idsource', '没有指定账号 → 直接把当前屏幕当列表');
  7519. return walkList(ctx);
  7520. }
  7521. for (var a = 0; a < accounts.length; a += 1) {
  7522. if (ctx.shouldStop()) {
  7523. break;
  7524. }
  7525. var profile = TikTokAuto.profile.openBySearch(accounts[a], ctx.config);
  7526. if (!profile) {
  7527. log.warn('idsource', '打不开账号 @' + accounts[a] + ',跳过');
  7528. continue;
  7529. }
  7530. ctx.onSeenProfile(profile);
  7531. var lists = source.lists && source.lists.length > 0 ? source.lists : ['fans'];
  7532. for (var l = 0; l < lists.length; l += 1) {
  7533. if (ctx.shouldStop()) {
  7534. return handled;
  7535. }
  7536. var which = lists[l] === 'home' ? 'fans' : lists[l];
  7537. if (!TikTokAuto.profile.openList(which, ctx.config)) {
  7538. log.warn('idsource', '打不开 ' + lists[l] + ' 列表');
  7539. continue;
  7540. }
  7541. TikTokAuto.profile.switchListTab(which);
  7542. handled += walkList(ctx);
  7543. TikTokAuto.profile.backToProfile();
  7544. }
  7545. }
  7546. return handled;
  7547. }
  7548. /** 搜索关键词并停在结果页(供列表型与逐个点开型共用) */
  7549. function prepareSearch(keyword, config) {
  7550. var log = logger();
  7551. if (!TikTokAuto.profile.ensureFeed(config)) {
  7552. return false;
  7553. }
  7554. var tiktok = TikTokAuto.tiktok;
  7555. var entry = tiktok.searchEntry();
  7556. if (!entry) {
  7557. log.error('idsource', '首页找不到搜索入口');
  7558. return false;
  7559. }
  7560. TikTokAuto.ui.clickNode(entry, 'idsource:search');
  7561. if (TikTokAuto.wait.waitFor('idsource:search', function () {
  7562. var state = tiktok.idPageState();
  7563. return (state === 'search' || state === 'users-results') ? true : null;
  7564. }, config.timeouts.pageReadyMs, config.probe.pollTickMs) !== true) {
  7565. return false;
  7566. }
  7567. var input = tiktok.searchInput();
  7568. if (!input) {
  7569. return false;
  7570. }
  7571. TikTokAuto.ui.clickNode(input, 'idsource:input');
  7572. TikTokAuto.wait.tick(250);
  7573. if (!TikTokAuto.ui.typeInto(tiktok.searchInput() || input, keyword, 'idsource:type').ok) {
  7574. return false;
  7575. }
  7576. var submit = tiktok.searchSubmit();
  7577. if (!submit) {
  7578. return false;
  7579. }
  7580. TikTokAuto.ui.clickNode(submit, 'idsource:submit');
  7581. if (TikTokAuto.wait.waitFor('idsource:results', function () {
  7582. return tiktok.resultTabStripPresent() ? true : null;
  7583. }, config.timeouts.searchResultsMs, config.probe.pollTickMs) !== true) {
  7584. return false;
  7585. }
  7586. /* 用户搜索要切到 Users 页签;视频搜索留在 Top */
  7587. var usersTab = tiktok.resultTab('Users');
  7588. if (usersTab && !tiktok.resultTabSelected('Users')) {
  7589. TikTokAuto.ui.clickNode(usersTab, 'idsource:usersTab');
  7590. /* 等结果行真的出现(页签条先渲染、行后加载,只等"安静"会误判为空) */
  7591. TikTokAuto.wait.waitFor('idsource:userRows', function () {
  7592. return tiktok.usersResultRows().length > 0 ? true : null;
  7593. }, config.timeouts.searchResultsMs, config.probe.pollTickMs);
  7594. }
  7595. return true;
  7596. }
  7597. /**
  7598. * 打开一个视频(播放页),供评论区取材用。
  7599. * `videoSource = "screen"` 表示用户自己已经把视频停在屏幕上,不动它。
  7600. */
  7601. function prepareVideo(source, config) {
  7602. var log = logger();
  7603. if (source.videoSource !== 'keyword') {
  7604. var state = TikTokAuto.tiktok.pageState();
  7605. if (state === 'player' || state === 'comment-panel') {
  7606. log.info('idsource', '已在播放页,直接用当前屏幕的视频');
  7607. return true;
  7608. }
  7609. /* 这里的提示必须是**可操作**的:用户能做的只有三件事 —— 自己先把页面停好、
  7610. * 改用关键词模式、或者换一个取材源。不能只说"不在播放页"。 */
  7611. log.warn('idsource', '「当前屏幕」需要用你手上已经打开的那个视频,引擎不会替你跳转。\n' +
  7612. ' 请先在 TikTok 里打开视频(或直接打开它的评论区),再点「开始运行」。\n' +
  7613. ' 当前页面是「' + state + '」。');
  7614. return false;
  7615. }
  7616. if (!prepareSearch(source.videoKeyword, config)) {
  7617. return false;
  7618. }
  7619. /* Top 页签是视频;取第一张卡点开 */
  7620. var cards = TikTokAuto.tiktok.videoCards();
  7621. if (cards.length === 0 && !TikTokAuto.flow.waitForCards(config)) {
  7622. return false;
  7623. }
  7624. cards = TikTokAuto.tiktok.videoCards();
  7625. if (cards.length === 0) {
  7626. return false;
  7627. }
  7628. if (!TikTokAuto.flow.openCard(config, cards[0])) {
  7629. return false;
  7630. }
  7631. /* 打开评论面板 */
  7632. var button = TikTokAuto.tiktok.commentButton();
  7633. if (!button) {
  7634. log.warn('idsource', '播放页找不到评论按钮');
  7635. return false;
  7636. }
  7637. TikTokAuto.ui.clickNode(button, 'idsource:comments');
  7638. return TikTokAuto.wait.waitFor('idsource:panel', function () {
  7639. return TikTokAuto.tiktok.commentPanelOpen() ? true : null;
  7640. }, config.timeouts.panelReadyMs, config.probe.pollTickMs) === true;
  7641. }
  7642. /** 一句人话摘要(日志与状态条用) */
  7643. function describe(source) {
  7644. var parse = TikTokAuto.parse;
  7645. if (source.kind === 'anchor') {
  7646. var list = source.anchors ? source.anchors.split('#')[0] : '当前屏幕';
  7647. return '账号 ' + list + ' 的 ' + (source.lists || []).join('+');
  7648. }
  7649. if (source.kind === 'search') {
  7650. return (source.searchType === 'videos' ? '视频作者' : '用户') + '「' + source.keyword + '」';
  7651. }
  7652. if (source.kind === 'video') {
  7653. return source.videoSource === 'keyword'
  7654. ? '评论区「' + source.videoKeyword + '」'
  7655. : '当前屏幕的评论区';
  7656. }
  7657. if (source.kind === 'text') {
  7658. return '文本 ' + String(source.path).split('/').pop();
  7659. }
  7660. return '当前屏幕';
  7661. }
  7662. return {
  7663. MAX_SCREENS: MAX_SCREENS,
  7664. IDLE_SCREENS_TO_STOP: IDLE_SCREENS_TO_STOP,
  7665. run: run,
  7666. walkList: walkList,
  7667. /* 纯函数,供 PC 回归(`tools/pc-selftest-id.js`)——
  7668. * 停止判据是"翻不动了"而不是"没有新人",这条必须能被离线断言。 */
  7669. walkStep: walkStep,
  7670. readTextSource: readTextSource,
  7671. describe: describe
  7672. };
  7673. }());
  7674. /* ============================== src/resolve.js ============================== */
  7675. /**
  7676. * resolve.js — 把 username(@handle)换成 TikTok 的数字 uid
  7677. *
  7678. * ## 为什么需要它
  7679. * 抓取产物里存的是 **username**(`beautifulsoul78869`)—— 那是能搜到人、能 @ 的标识。
  7680. * 但有些下游(对接接口、入库、跟外部系统对账)要的是 **数字 uid**(`254801657882882048`)。
  7681. * 两者完全不同:
  7682. *
  7683. * uniqueId / handle beautifulsoul78869 能搜到人、能 @,界面上可见
  7684. * nickname 🌹Beautiful Soul💀 显示名,可重名、可随时改
  7685. * id(本文的 uid) 254801657882882048 平台内部主键,界面上**读不到**
  7686. *
  7687. * 数字 uid 在 App 界面上任何地方都不显示,所以只能靠 HTTP 拿。
  7688. *
  7689. * ## 三条取数路径(照 `tiktok-uid和视频短链解析.js` 的分级思路)
  7690. *
  7691. * ① `www.tiktok.com/api/user/detail/?uniqueId=` 官方详情接口,最轻(本应几百字节)
  7692. * ② `www.tiktok.com/node/share/user/@<handle>` 老的分享接口
  7693. * ③ `www.tiktok.com/@<handle>` 的整页 SSR 最重(约 388KB),但**最可靠**
  7694. *
  7695. * 2026-10-20 真机实测:**①② 都已经不可用** ——
  7696. * ① 返回 `HTTP 200` 但**响应体为空**(风控静默拒绝,最容易让人误以为"接口还在")
  7697. * ② 返回 `403`
  7698. * ③ 200 / 388KB / 约 2 秒 / `JSON.parse` 仅 7ms
  7699. *
  7700. * 所以路径按"轻 → 重"逐个降级,且**每条只探一次**:某个账号证明 ① 不可用之后,
  7701. * 本次运行内不再尝试 ① —— 否则每个账号都要白付两次失败请求。
  7702. * 这样将来接口恢复了,第一次就会自动走轻的那条;恢复不了也不会拖慢。
  7703. *
  7704. * ## 试过但走不通的(别再试)
  7705. * · `tikwm.com/api/user/info` —— Cloudflare 挑战页("Just a moment...")
  7706. * · `m.tiktok.com/@user` —— 404(那条短链只认 `/v/<videoId>.html`)
  7707. * · `www.tiktok.com/oembed?url=...` —— 只有昵称与 handle,**没有数字 id**
  7708. * · AutoX 的 `http.get` —— 本机这个版本是坏的:
  7709. * `TypeError: Cannot find function newCall`(OkHttp 绑定冲突)→ 走 Java `HttpURLConnection`
  7710. *
  7711. * 依赖:TikTokAuto.logger、TikTokAuto.wait(节流与停止响应)、TikTokAuto.parse
  7712. */
  7713. TikTokAuto.resolve = (function () {
  7714. 'use strict';
  7715. var UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
  7716. '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
  7717. /** 整页近 400KB,慢网下 20 秒是合理的上限;接口那两条给 8 秒就够。 */
  7718. var API_TIMEOUT_MS = 8000;
  7719. var PAGE_TIMEOUT_MS = 20000;
  7720. /**
  7721. * 两次请求之间的间隔区间。
  7722. *
  7723. * 为什么不并发:这是网页版接口,短时间高频请求很容易被要求过验证码;
  7724. * 而我们是"一次几十个"的量级,串行 + 随机间隔的代价可以接受(每个约 2.5 秒)。
  7725. */
  7726. var GAP_MIN_MS = 400;
  7727. var GAP_MAX_MS = 900;
  7728. /**
  7729. * 本次运行里各条路径是否已被证明不可用。
  7730. *
  7731. * 这是**性能关键**:接口①② 实测必失败,若不记住,每个账号要多付两次失败请求;
  7732. * 记住之后只有第一个账号付这个代价。
  7733. */
  7734. var deadPaths = { api: false, share: false };
  7735. /** ttwid 只取一次(接口风控用;主页 HTML 不需要它) */
  7736. var ttwidCache = null;
  7737. var ttwidTried = false;
  7738. function logger() { return TikTokAuto.logger; }
  7739. /**
  7740. * 读一个连接的响应体。
  7741. *
  7742. * ★ 必须区分**正常流与错误流**:`getInputStream()` 在 4xx/5xx 时会直接抛异常,
  7743. * 于是「404 账号不存在」会被误判成「网络错误」。
  7744. * 从 `getErrorStream()` 读出来的 body 才是真正的原因(有时还带 JSON 错误码)。
  7745. */
  7746. function readBody(conn) {
  7747. var stream = null;
  7748. try {
  7749. var code = conn.getResponseCode();
  7750. stream = code >= 400 ? conn.getErrorStream() : conn.getInputStream();
  7751. if (!stream) {
  7752. return { code: code, body: '' };
  7753. }
  7754. var reader = new java.io.BufferedReader(new java.io.InputStreamReader(stream, 'UTF-8'));
  7755. var buffer = new java.lang.StringBuilder();
  7756. var line;
  7757. while ((line = reader.readLine()) !== null) {
  7758. buffer.append(line).append('\n');
  7759. }
  7760. reader.close();
  7761. return { code: code, body: String(buffer.toString()) };
  7762. } finally {
  7763. try { if (stream) { stream.close(); } } catch (ignored) { }
  7764. }
  7765. }
  7766. /**
  7767. * 发一个 GET。
  7768. * @returns { ok, status, body, error }
  7769. */
  7770. function get(url, headers, timeoutMs) {
  7771. var conn = null;
  7772. try {
  7773. conn = new java.net.URL(url).openConnection();
  7774. conn.setConnectTimeout(timeoutMs);
  7775. conn.setReadTimeout(timeoutMs);
  7776. conn.setInstanceFollowRedirects(true);
  7777. conn.setRequestProperty('User-Agent', UA);
  7778. conn.setRequestProperty('Accept-Language', 'en-US,en;q=0.9');
  7779. if (headers) {
  7780. for (var key in headers) {
  7781. if (headers.hasOwnProperty(key)) {
  7782. conn.setRequestProperty(key, headers[key]);
  7783. }
  7784. }
  7785. }
  7786. var result = readBody(conn);
  7787. return { ok: true, status: result.code, body: result.body };
  7788. } catch (error) {
  7789. return { ok: false, status: 0, body: '', error: String(error) };
  7790. } finally {
  7791. /* 一定要断开:不关的话连接会一直占着,几十个账号之后就容易出问题 */
  7792. try { if (conn) { conn.disconnect(); } } catch (ignoredToo) { }
  7793. }
  7794. }
  7795. /**
  7796. * 取 ttwid(TikTok 首页下发,接口风控用)。
  7797. * 取不到不算错 —— 只是那两条接口大概也走不通,直接降级到主页 HTML。
  7798. */
  7799. function ttwid() {
  7800. if (ttwidTried) {
  7801. return ttwidCache;
  7802. }
  7803. ttwidTried = true;
  7804. var conn = null;
  7805. try {
  7806. conn = new java.net.URL('https://www.tiktok.com/').openConnection();
  7807. conn.setConnectTimeout(API_TIMEOUT_MS);
  7808. conn.setReadTimeout(API_TIMEOUT_MS);
  7809. conn.setRequestProperty('User-Agent', UA);
  7810. conn.getResponseCode();
  7811. var fields = conn.getHeaderFields();
  7812. var cookies = fields ? fields.get('Set-Cookie') : null;
  7813. if (cookies) {
  7814. for (var i = 0; i < cookies.size(); i += 1) {
  7815. var one = String(cookies.get(i));
  7816. var idx = one.indexOf('ttwid=');
  7817. if (idx !== -1) {
  7818. ttwidCache = one.substring(idx).split(';')[0]; /* 形如 ttwid=xxx */
  7819. break;
  7820. }
  7821. }
  7822. }
  7823. } catch (error) {
  7824. logger().warn('resolve', '取 ttwid 失败(不影响后续降级):' + error);
  7825. } finally {
  7826. try { if (conn) { conn.disconnect(); } } catch (ignored) { }
  7827. }
  7828. return ttwidCache;
  7829. }
  7830. /** 从 JSON 对象里找 `userInfo.user`(别写死键路径,层级改过) */
  7831. function findUser(node, depth) {
  7832. if (depth > 8 || node === null || typeof node !== 'object') {
  7833. return null;
  7834. }
  7835. if (node.userInfo && node.userInfo.user && node.userInfo.user.id) {
  7836. return node.userInfo.user;
  7837. }
  7838. for (var key in node) {
  7839. if (node.hasOwnProperty(key)) {
  7840. var hit = findUser(node[key], depth + 1);
  7841. if (hit) {
  7842. return hit;
  7843. }
  7844. }
  7845. }
  7846. return null;
  7847. }
  7848. /** JSON 解析失败时用正则兜一层(整页 SSR 结构改版时不至于全废) */
  7849. function findUserByRegex(html, handle) {
  7850. var escaped = String(handle).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  7851. var matched = html.match(
  7852. new RegExp('"uniqueId":"' + escaped + '"[^}]*?"id":"(\\d+)"')
  7853. ) || html.match(
  7854. new RegExp('"id":"(\\d+)","shortId":"[^"]*","uniqueId":"' + escaped + '"')
  7855. );
  7856. if (!matched) {
  7857. return null;
  7858. }
  7859. return { id: matched[1], uniqueId: handle };
  7860. }
  7861. /** 统一校验:拿到的确实是这个 handle 的用户(网页版可能重定向到别名) */
  7862. function accept(user, wanted) {
  7863. if (!user || !user.id) {
  7864. return null;
  7865. }
  7866. var got = TikTokAuto.parse.username(user.uniqueId || '');
  7867. if (got !== '' && got.toLowerCase() !== String(wanted).toLowerCase()) {
  7868. logger().warn('resolve', '返回的是另一个账号(' + got + ' ≠ ' + wanted + '),丢弃');
  7869. return null;
  7870. }
  7871. return {
  7872. ok: true,
  7873. uid: String(user.id),
  7874. nickname: user.nickname || '',
  7875. secUid: user.secUid || ''
  7876. };
  7877. }
  7878. /* ---------------------------------------------------------- 三条路径 */
  7879. /** ① 官方用户详情接口 */
  7880. function viaApi(handle) {
  7881. var headers = {
  7882. 'Accept': 'application/json, text/plain, */*',
  7883. 'Referer': 'https://www.tiktok.com/@' + encodeURIComponent(handle)
  7884. };
  7885. var cookie = ttwid();
  7886. if (cookie) {
  7887. headers['Cookie'] = cookie;
  7888. }
  7889. var res = get('https://www.tiktok.com/api/user/detail/?uniqueId=' +
  7890. encodeURIComponent(handle), headers, API_TIMEOUT_MS);
  7891. if (!res.ok || res.status !== 200 || res.body === '') {
  7892. return null;
  7893. }
  7894. var data = null;
  7895. try { data = JSON.parse(res.body); } catch (error) { return null; }
  7896. return findUser(data, 0);
  7897. }
  7898. /** ② 老的分享接口 */
  7899. function viaShare(handle) {
  7900. var res = get('https://www.tiktok.com/node/share/user/@' + encodeURIComponent(handle) +
  7901. '?uniqueId=' + encodeURIComponent(handle),
  7902. { 'Accept': 'application/json, text/plain, */*' }, API_TIMEOUT_MS);
  7903. if (!res.ok || res.status !== 200 || res.body === '') {
  7904. return null;
  7905. }
  7906. var data = null;
  7907. try { data = JSON.parse(res.body); } catch (error) { return null; }
  7908. return findUser(data, 0);
  7909. }
  7910. /** ③ 主页整页 SSR(实测唯一可用的一条) */
  7911. function viaProfileHtml(handle) {
  7912. var res = get('https://www.tiktok.com/@' + encodeURIComponent(handle),
  7913. { 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' },
  7914. PAGE_TIMEOUT_MS);
  7915. if (!res.ok || res.status !== 200 || res.body === '') {
  7916. return null;
  7917. }
  7918. var matched = res.body.match(
  7919. /<script id="__UNIVERSAL_DATA_FOR_REHYDRATION__" type="application\/json">([\s\S]*?)<\/script>/
  7920. );
  7921. if (matched) {
  7922. var data = null;
  7923. try { data = JSON.parse(matched[1]); } catch (error) { data = null; }
  7924. if (data) {
  7925. var user = findUser(data, 0);
  7926. if (user) {
  7927. return user;
  7928. }
  7929. }
  7930. }
  7931. /* 结构改版 / 解析失败时的兜底 */
  7932. return findUserByRegex(res.body, handle);
  7933. }
  7934. /**
  7935. * 一个 username → uid。
  7936. *
  7937. * @returns { ok, uid, nickname, secUid, via, error }
  7938. * `via` 记录这次用的是哪条路径(api / share / html),看日志就知道接口有没有恢复。
  7939. */
  7940. function fetchUserId(username) {
  7941. var name = TikTokAuto.parse.username(username);
  7942. if (name === '') {
  7943. return { ok: false, error: '空用户名' };
  7944. }
  7945. var paths = [
  7946. { key: 'api', dead: deadPaths.api, run: viaApi,
  7947. label: '官方 user/detail' },
  7948. { key: 'share', dead: deadPaths.share, run: viaShare,
  7949. label: 'node/share' },
  7950. { key: 'html', dead: false, run: viaProfileHtml,
  7951. label: '主页 SSR' }
  7952. ];
  7953. for (var i = 0; i < paths.length; i += 1) {
  7954. var path = paths[i];
  7955. if (path.dead) {
  7956. continue;
  7957. }
  7958. var user = path.run(name);
  7959. if (user) {
  7960. var accepted = accept(user, name);
  7961. if (accepted) {
  7962. accepted.via = path.key;
  7963. return accepted;
  7964. }
  7965. }
  7966. /* 前两条失败就标记"本次运行不再试";主页是最后一条,没有可退的地方 */
  7967. if (path.key !== 'html') {
  7968. deadPaths[path.key] = true;
  7969. logger().info('resolve', path.label + ' 不可用,本次运行后续直接走主页 SSR');
  7970. }
  7971. }
  7972. return { ok: false, error: '三条路径都拿不到(账号可能不存在或已注销)' };
  7973. }
  7974. /** 随机间隔,让请求节奏不像机器 */
  7975. function pause() {
  7976. var span = GAP_MAX_MS - GAP_MIN_MS;
  7977. TikTokAuto.wait.tick(GAP_MIN_MS + Math.floor(Math.random() * (span + 1)));
  7978. }
  7979. /**
  7980. * 批量把 username 换成 uid。
  7981. *
  7982. * 三条原则:
  7983. * ① **一个失败不影响其余**(注销的号、网络抖动都只记一行);
  7984. * ② 每处理一个就回报进度(这个阶段比抓取慢,没有进度会让人以为卡死);
  7985. * ③ 可以被停止打断(`wait.tick` 里会检查停止标志);
  7986. * ④ 换不到的**不写进映射文件** —— 文件里全是可用的对应关系,比写一堆空值有用。
  7987. *
  7988. * @param usernames 用户名数组
  7989. * @param options { onProgress(done, total, name), shouldStop() }
  7990. * @returns { ok, map: [{username, uid, nickname}], failed: [{username, error}], stopped }
  7991. */
  7992. function resolveAll(usernames, options) {
  7993. var settings = options || {};
  7994. var log = logger();
  7995. var map = [];
  7996. var failed = [];
  7997. var total = usernames.length;
  7998. for (var i = 0; i < total; i += 1) {
  7999. if (settings.shouldStop && settings.shouldStop()) {
  8000. /* 注意措辞:这里的 shouldStop 只反映**时长护栏**,
  8001. * 不含"提取上限已到"(那个属于抓取阶段,不该阻止收尾)。
  8002. * 早先写成"收到停止",把"跑太久"和"用户点了停止"混为一谈,排查时很误导。 */
  8003. log.warn('resolve', '到达时长上限,已换 ' + map.length + '/' + total + ' 个');
  8004. return { ok: true, map: map, failed: failed, stopped: true };
  8005. }
  8006. var name = usernames[i];
  8007. if (settings.onProgress) {
  8008. settings.onProgress(i + 1, total, name);
  8009. }
  8010. var result = fetchUserId(name);
  8011. if (result.ok) {
  8012. map.push({ username: name, uid: result.uid, nickname: result.nickname });
  8013. /* 带上 via:这样一看日志就知道现在走的是哪条路径,
  8014. * 接口哪天恢复了也能立刻发现(不用等别人报告)。 */
  8015. log.info('resolve', name + ' → ' + result.uid + '(via ' + result.via + ')');
  8016. } else {
  8017. failed.push({ username: name, error: result.error });
  8018. log.warn('resolve', '换不到 ' + name + ':' + result.error);
  8019. }
  8020. if (i < total - 1) {
  8021. pause();
  8022. }
  8023. }
  8024. return { ok: true, map: map, failed: failed, stopped: false };
  8025. }
  8026. /** 把映射写成两列文本(TAB 分隔)。写入失败返回 false,不抛。 */
  8027. function writeMap(path, map) {
  8028. var lines = [];
  8029. for (var i = 0; i < map.length; i += 1) {
  8030. lines.push(map[i].username + '\t' + map[i].uid);
  8031. }
  8032. try {
  8033. files.write(path, lines.join('\n') + (lines.length > 0 ? '\n' : ''));
  8034. return true;
  8035. } catch (error) {
  8036. logger().error('resolve', '写映射文件失败 ' + path + ':' + error);
  8037. return false;
  8038. }
  8039. }
  8040. return {
  8041. fetchUserId: fetchUserId,
  8042. viaApi: viaApi,
  8043. viaShare: viaShare,
  8044. viaProfileHtml: viaProfileHtml,
  8045. resolveAll: resolveAll,
  8046. writeMap: writeMap,
  8047. GAP_MIN_MS: GAP_MIN_MS,
  8048. GAP_MAX_MS: GAP_MAX_MS
  8049. };
  8050. }());
  8051. /* ============================== src/idflow.js ============================== */
  8052. /**
  8053. * idflow.js — ID 提取的编排
  8054. *
  8055. * 与 `flow.js`(视频链路)并列,共用同一套基础设施:应用启动、弹窗守护、
  8056. * 坐标点击、`wait.tick()` 的停止、`bridge.report()` 的状态上报。
  8057. *
  8058. * ## 一轮跑完是什么样
  8059. *
  8060. * 启动应用 → 进信息流 → 对每条取材:
  8061. * 取材(列表型 / 文本型 / 逐个点开型)
  8062. * → 每个候选:去重判定 → 需要时打开用户页 → 过滤判定 → 写盘
  8063. * → 深度展开(第二层,最多一层)
  8064. * → 收尾:关文件、把 summary 上报给界面
  8065. *
  8066. * ## 三个必须处理好的"与视频链路不同"
  8067. *
  8068. * ① **总量未知**。粉丝列表可能几十万人,没有 `total` 可报。
  8069. * 所以状态条上的进度是「已收 N 个」而不是"N/M" —— 界面只读 `phase`,零改动。
  8070. *
  8071. * ② **"不限"是危险的**。界面里 `extractLimit = 0` 的语义是"不限制"。
  8072. * 但真跑起来没有上限就等于跑到没电。所以引擎**另加三道物理护栏**:
  8073. * 最大时长、单列表最大翻屏数(在 idsource 里)、连续无新内容即止。
  8074. * 到护栏时**如实报告**"因达到护栏而结束",绝不装作抓完了。
  8075. *
  8076. * ③ **停止时必须写文件**。用户在任意时刻点停,已经抓到的部分不能丢 ——
  8077. * 所以 `writer.close()` 放在 finally 里,无论怎么结束都会刷盘。
  8078. *
  8079. * 依赖:全部 ID 相关模块 + bridge / wait / ui / popups
  8080. */
  8081. TikTokAuto.idflow = (function () {
  8082. 'use strict';
  8083. /**
  8084. * 最近一次运行的 summary。
  8085. *
  8086. * 为什么挂模块级而不是只靠返回值:用户点停止时 StopSignal 会从
  8087. * `wait.tick()` 里抛出来,穿过整个流程到 main.js 的 catch ——
  8088. * 那时 `runSession` 的返回值根本用不上。main 从这里取已经统计好的数字,
  8089. * 否则界面会显示"提取 0 个"(实际抓到的都在文件里,只是没报上去)。
  8090. */
  8091. var lastSummary = null;
  8092. function logger() { return TikTokAuto.logger; }
  8093. /** 上报进度。只动 phase(界面状态条只读它),total 恒为 0 —— 这里没有总量。 */
  8094. function progress(phase, done) {
  8095. TikTokAuto.bridge.report({
  8096. status: 'running',
  8097. phase: phase + '(已收 ' + done + ' 个)',
  8098. done: done,
  8099. total: 0
  8100. });
  8101. }
  8102. /**
  8103. * 估算"够用的抓取预算"。
  8104. *
  8105. * ## 为什么要自动算
  8106. * 预算是**按时间**给的,而用户的目标是**按个数**("提取满 100 个")。
  8107. * 两者之间要经过"每个的平均耗时",而这个耗时取决于过滤设置(差 6 倍):
  8108. * · 不过滤 → 直接从列表抄 uid,约 1.5 秒/个;
  8109. * · 开了私密/无头像/数量门槛 → 每个都要打开用户页读资料,约 8 秒/个;
  8110. * · 再加上被过滤跳过的(实测收录率约 37%)→ 折算到"每个**收录**"约 20 秒。
  8111. *
  8112. * 所以固定给 30 分钟是错的:设 100 个要 38 分钟、设 1000 个要 5 个多小时。
  8113. * 用户设的数只当**下限**("至少跑这么久"),实际按提取上限放宽。
  8114. *
  8115. * ## 为什么还要封顶
  8116. * 4 小时是设备安全的边界(长时间亮屏运行会发热)。真要更多,分几轮跑更好 ——
  8117. * 每轮都刷盘,中断不丢,还能跨轮去重。
  8118. */
  8119. var PER_RESULT_MS = 20000; /* 折算到每个"收录"的耗时 */
  8120. var FIXED_OVERHEAD_MS = 5 * 60 * 1000; /* 启动 app / 搜索 / 开列表的固定开销 */
  8121. var HARD_CAP_MS = 4 * 60 * 60 * 1000; /* 4 小时硬顶 */
  8122. /** 解析 uid 的预算:按实际待解析个数算(每个约 3 秒 + 1 分钟缓冲),1~60 分钟 */
  8123. var PER_RESOLVE_MS = 3000;
  8124. var RESOLVE_CAP_MS = 60 * 60 * 1000;
  8125. function budgetFor(limit, userMinMs) {
  8126. var userMs = Math.max(1, userMinMs);
  8127. if (limit <= 0) {
  8128. /* 不限个数 → 没有依据可算,按用户设的走 */
  8129. return Math.min(userMs, HARD_CAP_MS);
  8130. }
  8131. var autoMs = limit * PER_RESULT_MS + FIXED_OVERHEAD_MS;
  8132. return Math.min(Math.max(userMs, autoMs), HARD_CAP_MS);
  8133. }
  8134. function resolveBudgetFor(count) {
  8135. var need = count * PER_RESOLVE_MS + 60000;
  8136. return Math.min(Math.max(10 * 60 * 1000, need), RESOLVE_CAP_MS);
  8137. }
  8138. /**
  8139. * 把界面配置里的 `id` 段收拢成引擎侧要用的形状。
  8140. *
  8141. * 界面已经做过一遍归一化(Sources.normalize),但**引擎不能假设界面一定对**:
  8142. * 手写的 engine-config.json、旧版本残留、被改坏的文件都会进来。
  8143. * 这里只做"缺了就补默认",不做语义校验 —— 语义校验在 filters 与 idsource 里。
  8144. */
  8145. function readSettings(config) {
  8146. var id = config.id || {};
  8147. var filter = id.filter || {};
  8148. var output = id.output || {};
  8149. var guard = id.guard || {};
  8150. var limit = Number(output.limit) || 0;
  8151. var userMaxMs = Number(guard.maxRuntimeMs) || 30 * 60 * 1000;
  8152. return {
  8153. sources: (id.sources instanceof Array ? id.sources : []),
  8154. filter: {
  8155. private: filter.private === true,
  8156. noAvatar: filter.noAvatar === true,
  8157. langRegion: filter.langRegion || 'any',
  8158. nicknameCheck: filter.nicknameCheck || 'any',
  8159. follow: filter.follow || {},
  8160. fans: filter.fans || {},
  8161. likes: filter.likes || {},
  8162. videos: filter.videos || {}
  8163. },
  8164. output: {
  8165. dir: output.dir || '/sdcard/Download/tiktok_app',
  8166. historyPath: output.historyPath || (output.dir || '/sdcard/Download/tiktok_app') + '/uid_extracted.txt',
  8167. dedupeHistory: output.dedupeHistory !== false,
  8168. dryRun: output.dryRun === true,
  8169. /* 缺省开:数字 uid 在界面上读不到,不在这里换就得事后另跑一次 */
  8170. resolveUid: output.resolveUid !== false
  8171. },
  8172. limit: limit,
  8173. /** 用户设的(下限,界面「单次最长运行」) */
  8174. userMaxRuntimeMs: userMaxMs,
  8175. /** 实际生效的抓取预算(按 limit 自动放宽) */
  8176. maxRuntimeMs: budgetFor(limit, userMaxMs),
  8177. resolveMaxMs: Number(guard.resolveMaxMs) || 10 * 60 * 1000,
  8178. /**
  8179. * 断点续跑开关。默认关:只有界面点「继续」时才由 `--resume` 打开。
  8180. * path 留空时由 output.dir 派生 —— 用户改产物目录,断点跟着走。
  8181. */
  8182. resume: {
  8183. enabled: (id.resume && id.resume.enabled === true),
  8184. path: (id.resume && id.resume.path) ||
  8185. ((output.dir || '/sdcard/Download/tiktok_app') + '/' +
  8186. TikTokAuto.checkpoint.DEFAULT_NAME)
  8187. }
  8188. };
  8189. }
  8190. /**
  8191. * 跑一轮 ID 提取。
  8192. *
  8193. * @returns 结构化 summary(与视频链路同族,供 bridge/main 复用)
  8194. */
  8195. function runSession(config) {
  8196. var log = logger();
  8197. var profileApi = TikTokAuto.profile;
  8198. var settings = readSettings(config);
  8199. var began = Date.now();
  8200. var summary = {
  8201. ok: false,
  8202. task: 'id',
  8203. extracted: 0, /* **本轮**写进产物文件的个数 */
  8204. skipped: 0, /* 因过滤被排除 */
  8205. duplicates: 0, /* 因去重被排除 */
  8206. openedProfiles: 0, /* 打开过多少次用户页(成本指标) */
  8207. sources: [],
  8208. warnings: [],
  8209. resultFile: '',
  8210. /* —— 断点续跑相关 ——
  8211. * resumed:本次是不是接着上次跑
  8212. * extractedTotal:累计口径(上次已落盘 + 本轮新增),上限与文案都按它算
  8213. * resumeRejected:要求续跑但被拒时,把"为什么"结构化成一份给界面看
  8214. */
  8215. resumed: false,
  8216. extractedTotal: 0,
  8217. resumeRejected: null,
  8218. errors: []
  8219. };
  8220. lastSummary = summary;
  8221. var seen = TikTokAuto.uidset.create();
  8222. var expandQueue = [];
  8223. var softReported = {};
  8224. var writer = null;
  8225. var runFile = '';
  8226. /* 断点(cp)在"决定续跑"之后才会有值;base 是"上次已落盘的条数",
  8227. * 上限、文案、ok 判定都按 base + 本轮 来算。 */
  8228. var cp = null;
  8229. var cpPath = settings.resume.path;
  8230. var resumed = false;
  8231. var base = 0;
  8232. var sig = TikTokAuto.fingerprint.ofIdSection(config.id);
  8233. /* —— 停止条件:上限 / 时长 / 用户叫停,三处统一问它 —— */
  8234. function limitReached() {
  8235. /* ★ 上限必须按**累计**算:不然设 100 个、跑了 60 个被停,
  8236. * 续跑后又会再收 100 个 = 一共 160 个。 */
  8237. return TikTokAuto.checkpoint.limitReached(cp, settings.limit, summary.extracted);
  8238. }
  8239. function timeUp() {
  8240. return Date.now() - began >= settings.maxRuntimeMs;
  8241. }
  8242. function shouldStop() {
  8243. if (limitReached()) {
  8244. return true;
  8245. }
  8246. if (timeUp()) {
  8247. if (!summary.warnings.length || summary.warnings[summary.warnings.length - 1] !== 'TIME_LIMIT') {
  8248. log.warn('idflow', '已达单次运行时长上限(' +
  8249. Math.round(settings.maxRuntimeMs / 60000) + ' 分钟),准备收尾');
  8250. summary.warnings.push('TIME_LIMIT');
  8251. }
  8252. return true;
  8253. }
  8254. return false;
  8255. }
  8256. /**
  8257. * 给一个候选拿到资料。三条路径按代价从低到高:
  8258. * ① 调用方已经读过(评论者那只,`_profile`);
  8259. * ② 列表行自带计数(搜索用户列表有"粉丝 · 获赞");
  8260. * ③ 打开用户页(最贵,约 3~5 秒)。
  8261. */
  8262. function obtainProfile(row) {
  8263. if (row._profile) {
  8264. return row._profile;
  8265. }
  8266. if (!TikTokAuto.filters.needsProfile(settings.filter)) {
  8267. /* 不需要任何用户页才能判的条件 → 绝不为此付一次开页代价 */
  8268. if (row.fans !== undefined || row.likes !== undefined) {
  8269. return {
  8270. uid: row.uid,
  8271. nickname: row.nickname,
  8272. signature: '',
  8273. follow: null,
  8274. fans: row.fans === undefined ? null : row.fans,
  8275. likes: row.likes === undefined ? null : row.likes,
  8276. isPrivate: false,
  8277. hasAvatar: null
  8278. };
  8279. }
  8280. return null;
  8281. }
  8282. /* 从列表行点进去最快(且实测返回后列表位置保持)。
  8283. * ★ 判据是"当前在**任一**种列表页上",不是只有粉丝列表:
  8284. * 搜索结果的用户列表也一样能直接点行进去。
  8285. * 早先写死成 `where() === 'user-list'`,于是搜索取材时
  8286. * 每个候选都要**重新搜一次**(约 15 秒/个)—— 慢十倍且没有必要。
  8287. * ★ 点开失败时不再退回搜索:在列表遍历中途跳去搜索,
  8288. * 会把"当前在哪个列表的第几行"整个丢掉,之后再也接不回原来的列表。 */
  8289. if (row.node && row.x !== undefined && profileApi.isListPage()) {
  8290. if (profileApi.openRow(row)) {
  8291. summary.openedProfiles += 1;
  8292. var data = profileApi.readProfile();
  8293. profileApi.backToList();
  8294. return data;
  8295. }
  8296. log.warn('idflow', '点不开 @' + row.uid + ' 的主页,跳过(不回搜索,避免丢失列表位置)');
  8297. return null;
  8298. }
  8299. var profile = profileApi.openBySearch(row.uid, config);
  8300. if (profile) {
  8301. summary.openedProfiles += 1;
  8302. }
  8303. return profile;
  8304. }
  8305. /**
  8306. * 收尾阶段:把本轮抓到的 username 换成数字 uid,另存一份映射文件。
  8307. *
  8308. * 产物分工:
  8309. * `uid_*.txt` 一行一个 username —— 给"搜人/私信"这类下游用
  8310. * `uid_map_*.txt` `username<TAB>uid` 两列 —— 给"要平台主键"的下游用
  8311. * 两份都留着,因为它们的用途真的不同,而且映射只有这一轮的数据。
  8312. */
  8313. function resolvePhase(target, settings) {
  8314. /* ★ 续跑时解析**整份结果文件**,不是只解析本轮新增。
  8315. * 映射文件是"结果文件的对照表",只含本轮新增就成了残表 ——
  8316. * 上次已收的 37 个永远拿不到数字 uid。
  8317. * 代价是这些名字要重新请求一遍(每个约 2.5 秒),先要正确性。 */
  8318. var names = resumed ? TikTokAuto.uidset.readUidFile(runFile) : writer.list();
  8319. if (names.length === 0) {
  8320. return;
  8321. }
  8322. /* 续跑复用上次的映射文件路径(覆盖写整份):
  8323. * 否则一次任务会产出两个 uid_map,下游还得自己合并。 */
  8324. var mapFile = (resumed && cp && cp.mapFile)
  8325. ? cp.mapFile
  8326. : settings.output.dir + '/' + TikTokAuto.parse.timestampName('uid_map', new Date());
  8327. log.step('idflow', '开始解析数字 uid:' + names.length + ' 个(每个约 2~3 秒)');
  8328. var resolveStarted = Date.now();
  8329. /* 解析预算也按实际个数算:10 分钟只够 240 个,抓了 1000 个就不够用了 */
  8330. var resolveBudgetMs = resolveBudgetFor(names.length);
  8331. log.info('idflow', '解析预算 ' + Math.round(resolveBudgetMs / 60000) + ' 分钟');
  8332. var result = TikTokAuto.resolve.resolveAll(names, {
  8333. /**
  8334. * ★ 这里**只用自己的预算**,不继承抓取阶段的任何停止条件。
  8335. *
  8336. * 两次踩同一个坑,两次都是"收尾继承了抓取的条件":
  8337. * ① 先是 `shouldStop()` 里的 `limitReached()` —— 抓满 N 个后解析 0 个;
  8338. * ② 再是 `shouldStop()` 里的 `timeUp()` —— 抓取把 30 分钟用满后解析 0 个
  8339. * (实测:83 个候选 × 每人约 8 秒 = 30 分钟,收尾一点时间都不剩)。
  8340. *
  8341. * 抓取阶段的"够了 / 到点了"和收尾阶段的"必须做完"是相反的要求。
  8342. * 所以这里从**进入解析的那一刻**重新计时,用一个独立预算(默认 10 分钟)。
  8343. * 用户主动停止仍然有效 —— 那是 StopSignal 从 `wait.tick` 抛出来的。
  8344. */
  8345. shouldStop: function () {
  8346. return Date.now() - resolveStarted >= resolveBudgetMs;
  8347. },
  8348. onProgress: function (done, total) {
  8349. progress('解析 uid ' + done + '/' + total, target.extracted);
  8350. }
  8351. });
  8352. if (result.stopped) {
  8353. target.warnings.push('解析 uid 到达自身时长上限(' +
  8354. Math.round(resolveBudgetMs / 60000) + ' 分钟),只换到 ' +
  8355. result.map.length + '/' + names.length + ' 个');
  8356. }
  8357. target.resolved = result.map.length;
  8358. target.unresolved = result.failed.length;
  8359. if (result.map.length > 0 && TikTokAuto.resolve.writeMap(mapFile, result.map)) {
  8360. target.mapFile = mapFile;
  8361. if (cp) {
  8362. cp.mapFile = mapFile;
  8363. TikTokAuto.checkpoint.save(cpPath, cp);
  8364. }
  8365. }
  8366. if (result.failed.length > 0) {
  8367. target.warnings.push(result.failed.length +
  8368. ' 个账号换不到数字 uid(账号可能已注销,详见日志)');
  8369. }
  8370. log.step('idflow', 'uid 解析完成:成功 ' + result.map.length +
  8371. ' / 失败 ' + result.failed.length);
  8372. }
  8373. /** 处理一个候选:去重 → 过滤 → 写盘 → 记下要不要展开 */
  8374. function handleRow(row) {
  8375. var parse = TikTokAuto.parse;
  8376. var uid = parse.username(row.uid);
  8377. if (uid === '' || !parse.looksLikeUsername(uid)) {
  8378. return;
  8379. }
  8380. if (seen.has(uid)) {
  8381. summary.duplicates += 1;
  8382. return;
  8383. }
  8384. if (shouldStop()) {
  8385. return;
  8386. }
  8387. progress('正在读取 @' + uid, summary.extracted);
  8388. var profile = obtainProfile(row);
  8389. var verdict = TikTokAuto.filters.judge(profile, settings.filter);
  8390. if (!verdict.ok) {
  8391. summary.skipped += 1;
  8392. log.info('idflow', '跳过 @' + uid + ':' + verdict.reason);
  8393. seen.add(uid);
  8394. return;
  8395. }
  8396. if (verdict.soft && !softReported[verdict.soft]) {
  8397. softReported[verdict.soft] = true;
  8398. summary.warnings.push(verdict.soft);
  8399. log.warn('idflow', verdict.soft);
  8400. }
  8401. seen.add(uid);
  8402. writer.write(uid);
  8403. summary.extracted += 1;
  8404. /* 收录时记下依据。这不只是"好看":用户事后想核对"这个人为啥被收进来"时,
  8405. * 日志是唯一的答案;没有它就只能重新跑一遍。 */
  8406. log.info('idflow', '收录 @' + uid + (profile
  8407. ? '(粉丝 ' + profile.fans + ' / 关注 ' + profile.follow +
  8408. ' / 获赞 ' + profile.likes + ')'
  8409. : '(未打开用户页,按列表现有资料通过)'));
  8410. if (row.expandTo) {
  8411. expandQueue.push({ uid: uid, which: row.expandTo });
  8412. /* 同步记进断点:不然续跑时"这个人还要展开"这条信息就丢了 */
  8413. TikTokAuto.checkpoint.enqueueExpand(cp, uid, row.expandTo);
  8414. }
  8415. /* 节流落盘,与产物刷盘同拍(每 20 个)。
  8416. * 不追每一个:粒度是取材条目级,写那么频只换来"少重走 19 个人"。 */
  8417. if (cp && summary.extracted % TikTokAuto.uidset.FLUSH_EVERY === 0) {
  8418. saveCheckpoint();
  8419. }
  8420. }
  8421. /** 把当前进度写进断点文件(失败只记 warn,不影响主流程) */
  8422. function saveCheckpoint() {
  8423. if (!cp) {
  8424. return;
  8425. }
  8426. cp.counts = {
  8427. extracted: base + summary.extracted,
  8428. skipped: summary.skipped,
  8429. duplicates: summary.duplicates,
  8430. openedProfiles: summary.openedProfiles
  8431. };
  8432. cp.limitBase = base;
  8433. TikTokAuto.checkpoint.save(cpPath, cp);
  8434. }
  8435. try {
  8436. log.step('idflow', '=== TikTok ID 提取开始 ===');
  8437. log.info('idflow', '取材 ' + settings.sources.length + ' 条,过滤:' +
  8438. TikTokAuto.filters.describe(settings.filter));
  8439. /* 把预算说清楚:用户设的是下限,实际按提取上限放宽过。
  8440. * 不说的话,"怎么跑了 38 分钟"会变成第二个疑问。 */
  8441. log.info('idflow', '抓取预算 ' + Math.round(settings.maxRuntimeMs / 60000) + ' 分钟' +
  8442. (settings.limit > 0
  8443. ? '(按提取上限 ' + settings.limit + ' 个估算;你设的下限是 ' +
  8444. Math.round(settings.userMaxRuntimeMs / 60000) + ' 分钟)'
  8445. : '(未设提取上限,按你设的时长)'));
  8446. /* —— 续跑决策(★ 全部在启动 App 之前做完)——
  8447. * 拒绝续跑时零成本返回:不启动 TikTok、不动用户手机上的任何一个页面。
  8448. * 判定顺序见 checkpoint.planResume(文件不在 → 已跑完 → 演练 → 配置变 → 结果文件没了)。 */
  8449. if (settings.resume.enabled) {
  8450. var prev = TikTokAuto.checkpoint.read(cpPath);
  8451. var decision = TikTokAuto.checkpoint.planResume(
  8452. prev, sig,
  8453. !!(prev && prev.runFile && files.exists(prev.runFile)),
  8454. { dryRun: settings.output.dryRun });
  8455. if (decision.mode === 'reject') {
  8456. summary.errors.push(decision.message);
  8457. summary.resumeRejected = {
  8458. reason: decision.reason,
  8459. changed: decision.changed || [],
  8460. message: decision.message,
  8461. runFile: (prev && prev.runFile) || ''
  8462. };
  8463. log.warn('idflow', '续跑被拒绝:' + decision.message);
  8464. return summary;
  8465. }
  8466. if (decision.mode === 'resume') {
  8467. cp = prev;
  8468. resumed = true;
  8469. summary.resumed = true;
  8470. log.info('idflow', '续跑:上次停在「' +
  8471. (TikTokAuto.checkpoint.stoppedAt(cp) || '未知') + '」,已收 ' +
  8472. (cp.counts && cp.counts.extracted ? cp.counts.extracted : 0) + ' 个');
  8473. /* 「当前屏幕」类取材的前提是"用户此刻停在那个页面"。
  8474. * 续跑时那一屏早就没了,引擎也不会替他跳回去
  8475. * (`flow.launchApp` 刻意不动页面,见它的注释)。
  8476. * 这种情况必须说清楚 —— 否则用户只会看到"这条取材又是 0 个"。 */
  8477. var screenSources = 0;
  8478. for (var ssi = 0; ssi < settings.sources.length; ssi += 1) {
  8479. var src = settings.sources[ssi];
  8480. if (src.kind === 'screen' ||
  8481. (src.kind === 'video' && src.videoSource === 'screen')) {
  8482. screenSources += 1;
  8483. }
  8484. }
  8485. if (screenSources > 0) {
  8486. summary.warnings.push('续跑提示:有 ' + screenSources +
  8487. ' 条取材依赖「你当前停留的页面」,而上次那一屏已经不在了 —— ' +
  8488. '这几条可能取不到人(失败会保留断点,下次还能再试)');
  8489. log.warn('idflow', '续跑:' + screenSources +
  8490. ' 条取材依赖当前屏幕,可能需要你手动打开对应页面');
  8491. }
  8492. } else {
  8493. log.info('idflow', '没有可用的断点(' + decision.reason + '),从零开始');
  8494. }
  8495. }
  8496. TikTokAuto.bridge.report({ status: 'running', phase: '准备', done: 0, total: 0 });
  8497. if (!TikTokAuto.uidset.ensureDir(settings.output.dir)) {
  8498. summary.errors.push('无法创建结果目录:' + settings.output.dir);
  8499. return summary;
  8500. }
  8501. /* 文件名只生成一次:分两次调 timestampName 有可能跨过整分钟,
  8502. * 于是"写进去的文件"和"报给界面的文件名"不是同一个 —— 这种错几乎无法复现。
  8503. *
  8504. * ★ 续跑时**复用上次的文件**(不新开):一次任务只该有一个 uid_*.txt,
  8505. * 否则用户拿到两个文件还得自己合并。 */
  8506. if (resumed && cp && cp.runFile) {
  8507. runFile = cp.runFile;
  8508. } else {
  8509. runFile = settings.output.dir + '/' +
  8510. TikTokAuto.parse.timestampName('uid', new Date());
  8511. }
  8512. writer = TikTokAuto.uidset.createWriter({
  8513. runPath: runFile,
  8514. historyPath: settings.output.historyPath,
  8515. dryRun: settings.output.dryRun,
  8516. historyEnabled: settings.output.dedupeHistory
  8517. });
  8518. summary.resultFile = settings.output.dryRun ? '' : runFile;
  8519. if (settings.output.dedupeHistory) {
  8520. TikTokAuto.uidset.loadHistory(seen, settings.output.historyPath);
  8521. }
  8522. /* ★ 续跑时**无条件**把上次的产物载入去重集合(不依赖 dedupeHistory):
  8523. * 否则会把已经收过的人再写一遍。base 以**文件行数**为准 —— 文件是真相,
  8524. * 不信断点里那个计数(它可能是被杀之前写的旧值)。 */
  8525. if (resumed) {
  8526. base = TikTokAuto.uidset.readUidFile(runFile).length;
  8527. TikTokAuto.uidset.loadHistory(seen, runFile);
  8528. summary.warnings.push('本次为续跑:结果继续追加到 ' + runFile);
  8529. log.info('idflow', '续跑:跳过已收 ' + base + ' 个');
  8530. }
  8531. /* 建/续断点,并**立刻写一次** —— 刚起步就被杀也能接着跑 */
  8532. if (!resumed) {
  8533. var entries = [];
  8534. for (var si = 0; si < settings.sources.length; si += 1) {
  8535. entries.push({
  8536. key: settings.sources[si].key ||
  8537. ('i' + si + ':' + (settings.sources[si].kind || '')),
  8538. kind: settings.sources[si].kind || '',
  8539. label: TikTokAuto.idsource.describe(settings.sources[si])
  8540. });
  8541. }
  8542. cp = TikTokAuto.checkpoint.empty(
  8543. TikTokAuto.bridge.currentRunId ? TikTokAuto.bridge.currentRunId() : '',
  8544. sig, runFile, entries);
  8545. }
  8546. saveCheckpoint();
  8547. /* —— 启动与就位(复用视频链路的成熟实现) —— */
  8548. progress('启动应用', 0);
  8549. if (!TikTokAuto.flow.launchApp(config)) {
  8550. summary.errors.push('启动应用失败');
  8551. return summary;
  8552. }
  8553. /* ★ 这里**刻意不做 ensureFeed**。
  8554. *
  8555. * 早先这里无条件把页面退回信息流首页,于是「当前屏幕」类的取材必然失败:
  8556. * 用户手动把 TikTok 停在视频评论区(那正是这个取材源的全部前提),
  8557. * 引擎一上来就连按几次返回退到首页,然后再报"取材设置为当前屏幕,
  8558. * 但当前不在播放页(feed)"。用户看到的是"我明明停在评论区,它说我不在"。
  8559. *
  8560. * 各个取材源自己知道自己需要什么前置状态,让它们自己保证:
  8561. * · 指定账号 / 搜索 → 走 prepareSearch,内部会 ensureFeed;
  8562. * · 视频/当前屏幕 → **不动页面**,就用用户准备好的那一屏;
  8563. * · 文本文件 → 根本不需要 UI。
  8564. */
  8565. progress('准备取材', 0);
  8566. if (settings.sources.length === 0) {
  8567. summary.errors.push('没有配置任何取材(界面里至少需要一条)');
  8568. return summary;
  8569. }
  8570. /* —— 第一层:逐条取材 —— */
  8571. for (var i = 0; i < settings.sources.length; i += 1) {
  8572. if (shouldStop()) {
  8573. break;
  8574. }
  8575. var source = settings.sources[i];
  8576. var sourceKey = source.key || ('i' + i + ':' + (source.kind || ''));
  8577. var before = summary.extracted;
  8578. /* 上次已走完的取材直接跳过 —— 这就是"断点续跑"的全部含义:
  8579. * 不是从第几屏接着滚,而是**不再重走已经走完的那几条**。 */
  8580. if (resumed && TikTokAuto.checkpoint.sourceDone(cp, sourceKey)) {
  8581. summary.sources.push({
  8582. label: TikTokAuto.idsource.describe(source),
  8583. handled: 0,
  8584. added: 0,
  8585. reused: true
  8586. });
  8587. log.info('idflow', '续跑跳过(上次已完成):' + TikTokAuto.idsource.describe(source));
  8588. continue;
  8589. }
  8590. TikTokAuto.checkpoint.markSource(cp, sourceKey, 'running');
  8591. saveCheckpoint();
  8592. progress('取材 ' + (i + 1) + '/' + settings.sources.length, summary.extracted);
  8593. /* 续跑时这条取材上次翻了多少屏 → 这次把预算放大相应倍数。
  8594. * 从头重放旧人也要翻屏,不放大就会"还没走到新人就撞上 60 屏硬顶"。 */
  8595. var prevScreens = resumed ? TikTokAuto.checkpoint.screensOf(cp, sourceKey) : 0;
  8596. var maxScreens = 0;
  8597. if (prevScreens > 0) {
  8598. maxScreens = Math.min(TikTokAuto.idsource.MAX_SCREENS * 2,
  8599. TikTokAuto.idsource.MAX_SCREENS +
  8600. Math.min(prevScreens, TikTokAuto.idsource.MAX_SCREENS));
  8601. }
  8602. var stat = {};
  8603. var ctx = {
  8604. config: config,
  8605. seen: seen,
  8606. shouldStop: shouldStop,
  8607. maxScreens: maxScreens,
  8608. stat: stat,
  8609. onSeenProfile: function (prof) {
  8610. log.info('idflow', '取材账号 @' + prof.uid + ':粉丝 ' + prof.fans +
  8611. ' / 关注 ' + prof.follow + ' / 获赞 ' + prof.likes);
  8612. },
  8613. onRow: function (row) {
  8614. /* 深度只在第一层记:第二层不再展开(上限两层) */
  8615. if (source.expand && !row.expandTo) {
  8616. row.expandTo = source.expand;
  8617. }
  8618. handleRow(row);
  8619. }
  8620. };
  8621. var handled = 0;
  8622. var errorsBefore = summary.errors.length;
  8623. try {
  8624. handled = TikTokAuto.idsource.run(source, ctx);
  8625. } catch (error) {
  8626. if (TikTokAuto.bridge.isStopSignal(error)) {
  8627. throw error;
  8628. }
  8629. log.error('idflow', '取材失败(' + TikTokAuto.idsource.describe(source) + '):' + error);
  8630. summary.errors.push('取材失败:' + TikTokAuto.idsource.describe(source));
  8631. }
  8632. summary.sources.push({
  8633. label: TikTokAuto.idsource.describe(source),
  8634. handled: handled,
  8635. added: summary.extracted - before
  8636. });
  8637. log.info('idflow', '取材完成:' + TikTokAuto.idsource.describe(source) +
  8638. ',处理 ' + handled + ' 行,新增 ' + (summary.extracted - before) + ' 个');
  8639. /* ★ 走完一条就记一条 —— 下次续跑正是靠它跳过这一条。
  8640. *
  8641. * 但**失败的那条不能标 done**:`idsource.run` 打不开列表时会记 errors
  8642. * 然后返回 0(不抛异常),如果一律标 done,续跑就再也不会重试它,
  8643. * 用户会以为"这条取材就是这样、没数据"。所以:
  8644. * · 本轮新增了错误 → 保持 running,下次接着试
  8645. * · 否则(正常走完,哪怕列表本来就是空的)→ done
  8646. *
  8647. * 中途被停止时这里不会执行,那条取材保持 running,
  8648. * 续跑就从它开头重走 —— 这就是"最多回退一条取材"。 */
  8649. var sourceFailed = summary.errors.length > errorsBefore;
  8650. /* 没抛错但一个人都没取到:列表是空的、或者「当前屏幕」那一刻页面不对
  8651. * (这种情况 idsource 只记 warn 不记 error)。必须说出来 ——
  8652. * 否则用户只会看到"总数不对",却不知道是哪条取材白跑了。 */
  8653. if (!sourceFailed && handled === 0 && (summary.extracted - before) === 0) {
  8654. summary.warnings.push('取材「' + TikTokAuto.idsource.describe(source) +
  8655. '」这次没有取到任何人(列表为空或页面不合适)');
  8656. }
  8657. TikTokAuto.checkpoint.markSource(cp, sourceKey,
  8658. sourceFailed ? 'running' : 'done', {
  8659. screens: stat.screens || 0,
  8660. handled: handled,
  8661. added: summary.extracted - before
  8662. });
  8663. saveCheckpoint();
  8664. }
  8665. /* —— 第二层:深度展开 ——
  8666. * 与第一层分开跑,是为了避免嵌套列表上下文(见 idsource.js 文件头)。
  8667. * 展开进来的人**同样要过一遍过滤** —— 否则"粉丝的粉丝"会绕过所有条件。
  8668. *
  8669. * 续跑时先用断点过滤掉"上次已经展开过"的人:展开一个人要 5~10 秒,
  8670. * 重做一遍纯属浪费。 */
  8671. var todoExpand = TikTokAuto.checkpoint.pendingExpand(cp, expandQueue);
  8672. if (expandQueue.length > 0 && todoExpand.length === 0) {
  8673. log.info('idflow', '展开项上次已全部完成(' + expandQueue.length + ' 个),跳过');
  8674. }
  8675. if (todoExpand.length > 0 && shouldStop() && limitReached()) {
  8676. /* 上限在主层就满了 → 展开根本没跑。这必须说出来:
  8677. * 用户明明勾了「再取粉丝列表」,看到"提取满 3 个"很容易以为展开做了。 */
  8678. summary.warnings.push('提取上限已到,深度展开(再取粉丝列表)本次没有执行');
  8679. log.warn('idflow', '提取上限已到,' + todoExpand.length + ' 个待展开的账号本次没有展开');
  8680. }
  8681. if (todoExpand.length > 0 && !shouldStop()) {
  8682. log.step('idflow', '开始深度展开:' + todoExpand.length + ' 个账号' +
  8683. (resumed ? '(已跳过上次做完的)' : ''));
  8684. for (var e = 0; e < todoExpand.length; e += 1) {
  8685. if (shouldStop()) {
  8686. break;
  8687. }
  8688. var target = todoExpand[e];
  8689. progress('展开 @' + target.uid + ' 的' +
  8690. (target.which === 'following' ? '关注' : '粉丝'), summary.extracted);
  8691. var opened = profileApi.openBySearch(target.uid, config);
  8692. if (!opened) {
  8693. log.warn('idflow', '展开时打不开 @' + target.uid);
  8694. continue;
  8695. }
  8696. summary.openedProfiles += 1;
  8697. if (!profileApi.openList(target.which, config)) {
  8698. continue;
  8699. }
  8700. profileApi.switchListTab(target.which);
  8701. var subCtx = {
  8702. config: config,
  8703. seen: seen,
  8704. shouldStop: shouldStop,
  8705. onSeenProfile: function () { },
  8706. onRow: handleRow /* 注意:这里**不再**设置 expandTo,所以不会递归 */
  8707. };
  8708. try {
  8709. TikTokAuto.idsource.walkList(subCtx);
  8710. } catch (error2) {
  8711. if (TikTokAuto.bridge.isStopSignal(error2)) {
  8712. throw error2;
  8713. }
  8714. log.warn('idflow', '展开 @' + target.uid + ' 失败:' + error2);
  8715. }
  8716. /* 做完一个就记一个:展开成本高(5~10 秒/人),重做代价大 */
  8717. TikTokAuto.checkpoint.markExpandDone(cp, target.uid, target.which);
  8718. saveCheckpoint();
  8719. }
  8720. }
  8721. /* 成功判定按**累计**:续跑本轮新增 0 个、但上次已有 37 个,这是成功不是失败 */
  8722. var totalSoFar = base + summary.extracted;
  8723. summary.ok = totalSoFar > 0;
  8724. if (!summary.ok && summary.errors.length === 0) {
  8725. summary.errors.push('没有提取到任何 ID(取材范围内都被过滤或去重掉了)');
  8726. }
  8727. /* —— 收尾:username → 数字 uid ——
  8728. * 数字 uid 在 App 界面上任何地方都读不到,只能取网页版 SSR(见 resolve.js)。
  8729. * 它比抓取慢(每个约 2.5 秒)且依赖网络,所以三条边界:
  8730. * ① 只在**主产物已经落盘之后**做(先 close 一次,保证文件完整);
  8731. * ② 失败**只记 warning**,绝不把已经抓到的 username 算成失败;
  8732. * ③ 换不到的**不写进映射文件** —— 文件里全是可用的对应关系。
  8733. */
  8734. var needResolve = settings.output.resolveUid && !settings.output.dryRun && totalSoFar > 0;
  8735. /* 续跑但本轮一个都没新增、上次也已经生成过映射 → 直接沿用,不重算。
  8736. * 否则每次续跑都要把整份文件重新解析一遍(每个 2.5 秒)。 */
  8737. if (needResolve && resumed && summary.extracted === 0 && cp && cp.mapFile) {
  8738. log.info('idflow', '本轮没有新增,映射文件沿用上次的:' + cp.mapFile);
  8739. summary.mapFile = cp.mapFile;
  8740. needResolve = false;
  8741. }
  8742. if (needResolve) {
  8743. try {
  8744. writer.close();
  8745. resolvePhase(summary, settings);
  8746. } catch (error) {
  8747. if (TikTokAuto.bridge.isStopSignal(error)) {
  8748. summary.warnings.push('解析 uid 阶段被停止,映射文件只含已换到的部分');
  8749. throw error;
  8750. }
  8751. log.warn('idflow', '解析 uid 失败(不影响已抓到的 username):' + error);
  8752. summary.warnings.push('解析 uid 失败:' + error);
  8753. }
  8754. }
  8755. } finally {
  8756. /* 无论是正常结束、出错、还是用户叫停,都必须把已经抓到的刷进文件 ——
  8757. * "点了停止但文件是空的"是最不能接受的一种失败。 */
  8758. if (writer) {
  8759. var written = writer.close();
  8760. if (written !== summary.extracted) {
  8761. log.warn('idflow', '写盘数量与统计不符:文件 ' + written + ' / 统计 ' + summary.extracted);
  8762. }
  8763. if (writer.hadFailure()) {
  8764. summary.warnings.push('结果文件写入失败(部分结果可能没有落盘)');
  8765. }
  8766. }
  8767. /* 累计口径给上层(bridge 的 done、界面状态条、记录文案都用它) */
  8768. summary.extractedTotal = base + summary.extracted;
  8769. /* 断点收尾:无论正常结束还是被停止/出错解栈,都要把"走到哪"落盘。
  8770. * `allDone` 为真才算真跑完(界面上不再提供"继续");否则留成 stopped 等续跑。 */
  8771. if (cp) {
  8772. /* 三种情况都算"这一轮没有可续的了":
  8773. * ① 取材与展开都走完了;
  8774. * ② 达到了用户设的提取上限 —— 否则会留下一个"点了继续立刻又停"的断点,
  8775. * 因为 limitReached 按累计算,续跑一进来就已经到顶了。
  8776. */
  8777. cp.state = (TikTokAuto.checkpoint.allDone(cp) || limitReached()) ? 'done' : 'stopped';
  8778. saveCheckpoint();
  8779. log.info('idflow', '断点已保存(' + cp.state + '):取材 ' +
  8780. TikTokAuto.checkpoint.sourceProgress(cp).done + '/' +
  8781. TikTokAuto.checkpoint.sourceProgress(cp).total + ' 条已完成');
  8782. }
  8783. /* 一个都没收时**不报文件名**:那个文件根本没被创建(writer 只在有内容时 append),
  8784. * 报一个不存在的路径,用户去文件管理器找会以为"抓到了但丢了"。
  8785. * 续跑时按累计判:上次已收 37 个,文件是存在的。 */
  8786. if (summary.extractedTotal === 0) {
  8787. summary.resultFile = '';
  8788. }
  8789. if (limitReached()) {
  8790. summary.warnings.push('已提取满 ' + settings.limit + ' 个,按设置停止');
  8791. } else if (settings.limit > 0 && timeUp()) {
  8792. /* ★ 时间先到、上限没满 —— 这是最容易被误解的一种结束:
  8793. * 用户设了"提取满 100 个",看到只收了 83 个,会以为是过滤太严或程序漏了,
  8794. * 而真正的原因是**两个上限谁先到听谁**:30 分钟到了就停,不管够不够数。
  8795. *
  8796. * 这里直接给出换算结论,省得用户自己猜:
  8797. * 按本轮速度,收满 limit 个大约需要多久。
  8798. *
  8799. * 估算方法(简单但够用):本轮 T 秒收到 E 个 → 平均每个 E/T 个,
  8800. * 那么收满 L 个需要 L × T / E 秒。实测 1800 秒 / 83 个 → 100 个约 36 分钟。
  8801. */
  8802. var elapsedMs = Date.now() - began;
  8803. /* 文案与换算都按**累计**:续跑时本轮可能只新增 5 个,
  8804. * 但用户真正关心的是"总共 42/100",说"只收了 5/100"会吓人。 */
  8805. if (summary.extracted > 0) {
  8806. var needMin = Math.ceil(
  8807. (settings.limit - summary.extractedTotal + summary.extracted) *
  8808. elapsedMs / summary.extracted / 60000
  8809. );
  8810. summary.warnings.push(
  8811. '时间到,共收了 ' + summary.extractedTotal + '/' + settings.limit +
  8812. ' 个(本轮新增 ' + summary.extracted + ' 个)。' +
  8813. '按本轮速度,收满 ' + settings.limit + ' 个还需约 ' + needMin +
  8814. ' 分钟 —— 可以点「继续」接着跑'
  8815. );
  8816. log.warn('idflow', '时间到,累计 ' + summary.extractedTotal + ' 个;' +
  8817. '按本轮速度收满 ' + settings.limit + ' 个还需约 ' + needMin + ' 分钟');
  8818. } else {
  8819. /* 一个都没收到就超时:多半不是时间不够,而是过滤太严或取材取不到人。
  8820. * 这时给"调大时间"的建议是害人 —— 要说清该查哪里。 */
  8821. summary.warnings.push(
  8822. '时间到,一个都没收到 —— 不是时间太短,请检查取材是否取到人、' +
  8823. '过滤条件是否过严(见日志里的跳过原因)'
  8824. );
  8825. }
  8826. } else if (settings.limit > 0 && summary.extractedTotal < settings.limit) {
  8827. /* 第三种"没到上限":取材本身跑完了(列表到底/文件读完),不是时间问题。
  8828. * 这时调时间没用,得加取材量。 */
  8829. summary.warnings.push(
  8830. '取材范围已取尽,共收 ' + summary.extractedTotal + '/' + settings.limit +
  8831. ' 个 —— 想收更多请增加取材(换关键词、加账号,或打开「再取粉丝列表」)'
  8832. );
  8833. }
  8834. log.step('idflow', '=== 提取结束' + (resumed ? '(续跑)' : '') +
  8835. ':新增 ' + summary.extracted + ' 个,累计 ' + summary.extractedTotal +
  8836. ' 个,跳过 ' + summary.skipped + ' 个,重复 ' + summary.duplicates +
  8837. ' 个,耗时 ' + Math.round((Date.now() - began) / 1000) + ' 秒 ===');
  8838. }
  8839. return summary;
  8840. }
  8841. return {
  8842. runSession: runSession,
  8843. readSettings: readSettings,
  8844. lastSummary: function () {
  8845. return lastSummary;
  8846. }
  8847. };
  8848. }());
  8849. /* ============================== src/dm.js ============================== */
  8850. /**
  8851. * dm.js — 发私信:打开面板 → 输入 → **点发送** → 验证真的发出去了
  8852. *
  8853. * ## 实测锚点(2026-10-29 / TikTok 47.1.4 / 1080×1920 / @nasa 主页)
  8854. *
  8855. * | 元素 | 类 | 识别方式 | 备注 |
  8856. * |---|---|---|---|
  8857. * | Message 入口 | TextView | text/desc === `Message` | 用户主页上,Follow 右边 |
  8858. * | 面板输入框 | EditText | `top > 25%H` | 未聚焦时贴底(y=1816) |
  8859. * | 表情按钮 | Button | desc 含 `stickers, gifs` | 会挡在发送键左边 |
  8860. * | **发送键** | ImageView | 见 `sendButton()` | **无 id、无 desc,只能按位置** |
  8861. *
  8862. * ## 为什么发送键要按位置找
  8863. *
  8864. * 输入前它根本不存在;输入文字后输入框会**缩短**(960 → 888),
  8865. * 空出来的位置才出现那个 72×72 的可点 ImageView(968~1040)。
  8866. * 它没有 id、没有 desc、也没有任何文案 —— 除了几何关系没有别的抓手。
  8867. *
  8868. * ## 和评论的区别(这是私信能做、评论不能做的原因)
  8869. *
  8870. * 评论的发送键是**自绘的**,无障碍树里根本没有对应节点;
  8871. * 私信这个是标准 ImageView、`clickable=true`,无障碍点得到。
  8872. *
  8873. * 依赖:TikTokAuto.logger / layout / ui / wait / tiktok
  8874. */
  8875. TikTokAuto.dm = (function () {
  8876. 'use strict';
  8877. /** 表情按钮的 desc 特征 —— 它紧贴在发送键左边,必须排除 */
  8878. var STICKER_HINT = /stickers|gifs|emoji|表情|贴纸/i;
  8879. function logger() { return TikTokAuto.logger; }
  8880. function screenH() { return TikTokAuto.layout.screen().height; }
  8881. /**
  8882. * 找可见的、text 或 desc 完全匹配的节点。
  8883. *
  8884. * ★ 必须**分别**测 text 和 desc,不能拼起来再测:
  8885. * 拼成 `txt + ' ' + desc` 之后,`desc="Message"` 会变成 " Message"(前导空格),
  8886. * `/^Message$/` 就匹配不上了 —— 明明节点在树里却报"找不到"。
  8887. */
  8888. function findExact(re, classes) {
  8889. var layout = TikTokAuto.layout;
  8890. var height = screenH();
  8891. for (var c = 0; c < classes.length; c += 1) {
  8892. var found = className(classes[c]).find();
  8893. for (var i = 0; i < found.size(); i += 1) {
  8894. var n = found.get(i);
  8895. var box = layout.visibleRect(n);
  8896. if (!box || box.bottom <= 0 || box.top >= height) {
  8897. continue;
  8898. }
  8899. if (re.test(layout.text(n)) || re.test(layout.desc(n))) {
  8900. return n;
  8901. }
  8902. }
  8903. }
  8904. return null;
  8905. }
  8906. /** 用户主页上的 Message 入口 */
  8907. function messageButton() {
  8908. return findExact(/^(Message|Send message|消息|私信)$/i,
  8909. ['android.widget.Button', 'android.widget.TextView']);
  8910. }
  8911. /**
  8912. * 私信面板里的输入框。
  8913. *
  8914. * 位置判据用「top > 25% 屏高」而不是"底部固定 y":聚焦后键盘弹起,
  8915. * 输入框会被顶到 0.65H(实测 1240),贴底判据就失效了。
  8916. */
  8917. function panelInput() {
  8918. var layout = TikTokAuto.layout;
  8919. var height = screenH();
  8920. var found = className('android.widget.EditText').find();
  8921. for (var i = 0; i < found.size(); i += 1) {
  8922. var n = found.get(i);
  8923. var box = layout.visibleRect(n);
  8924. if (!box) { continue; }
  8925. if (box.top > height * 0.25 && box.top < height * 0.95) {
  8926. return n;
  8927. }
  8928. }
  8929. return null;
  8930. }
  8931. /**
  8932. * 发送键。
  8933. *
  8934. * 判据(都来自实测,缺一条就会点到表情按钮或点空):
  8935. * ① 与输入框**垂直重叠**(同一行)
  8936. * ② 左边界在输入框右边界**之外**(`left >= right + 10`)——
  8937. * 输入框输入后会缩短,表情按钮紧贴着它,所以必须留出这 10px 的余量
  8938. * ③ 尺寸在 40~120 之间(排除整行的容器)
  8939. * ④ 可点
  8940. * ⑤ desc 里不含 stickers/gifs/emoji(排除表情按钮)
  8941. *
  8942. * 取**最靠左**的那个:紧挨输入框的是发送键,更右边没有东西。
  8943. */
  8944. function sendButton() {
  8945. var layout = TikTokAuto.layout;
  8946. var input = panelInput();
  8947. if (!input) {
  8948. return null;
  8949. }
  8950. var ib = input.bounds();
  8951. var best = null;
  8952. var bestLeft = null;
  8953. var classes = ['android.widget.ImageView', 'android.widget.Button'];
  8954. for (var c = 0; c < classes.length; c += 1) {
  8955. var found = className(classes[c]).find();
  8956. for (var i = 0; i < found.size(); i += 1) {
  8957. var n = found.get(i);
  8958. var box = layout.visibleRect(n);
  8959. if (!box) { continue; }
  8960. var overlap = Math.min(box.bottom, ib.bottom) - Math.max(box.top, ib.top);
  8961. if (overlap <= 0) { continue; } // ① 同一行
  8962. if (box.left < ib.right + 10) { continue; } // ② 在输入框右边
  8963. var w = box.right - box.left;
  8964. var h = box.bottom - box.top;
  8965. if (w < 40 || h < 40 || w > 120 || h > 120) { continue; } // ③ 尺寸
  8966. try {
  8967. if (n.clickable() !== true) { continue; } // ④ 可点
  8968. } catch (ignored) {
  8969. continue;
  8970. }
  8971. if (STICKER_HINT.test(layout.desc(n))) { continue; } // ⑤ 不是表情
  8972. if (bestLeft === null || box.left < bestLeft) {
  8973. bestLeft = box.left;
  8974. best = n;
  8975. }
  8976. }
  8977. }
  8978. return best;
  8979. }
  8980. /**
  8981. * 拿到发送键,拿不到就**再点一次输入框**强制聚焦后重试一次。
  8982. *
  8983. * ★ 为什么需要重试(2026-10-29 真机踩到):发送键只在**键盘弹起**时才存在
  8984. * (输入框缩短,右侧空出那块 72×72 的 ImageView)。而 `typeInto` 的那次点击
  8985. * 有时被输入法吞掉 —— 文字确实写进去了,键盘却没起来,于是发送键不存在。
  8986. * 表现是"演练成功、实发失败:输入后找不到发送键",且时好时坏。
  8987. *
  8988. * 重试的动作很小(点一下输入框),但它把"时好时坏"变成"基本必成"。
  8989. */
  8990. function ensureSendButton(config, step) {
  8991. var log = logger();
  8992. var button = TikTokAuto.wait.waitFor(step, function () {
  8993. return sendButton() || null;
  8994. }, 1500, 250);
  8995. if (button) {
  8996. return button;
  8997. }
  8998. var input = panelInput();
  8999. if (!input) {
  9000. return null;
  9001. }
  9002. var point = TikTokAuto.layout.center(input);
  9003. if (!point) {
  9004. return null;
  9005. }
  9006. log.info(step, '发送键还没出现,再点一次输入框强制聚焦');
  9007. TikTokAuto.ui.tapPoint(point.x, point.y);
  9008. TikTokAuto.wait.tick(400);
  9009. return TikTokAuto.wait.waitFor(step + ':retry', function () {
  9010. return sendButton() || null;
  9011. }, 4000, 250);
  9012. }
  9013. /**
  9014. * 打开私信面板(前提:当前已经在目标用户的**主页**上)。
  9015. *
  9016. * @returns {ok, via, reason}
  9017. */
  9018. function openPanel(config, step) {
  9019. var story = step || 'dm:open';
  9020. var log = logger();
  9021. if (panelInput() !== null) {
  9022. log.info(story, '私信面板已经开着');
  9023. return { ok: true, via: 'already-open' };
  9024. }
  9025. var entry = messageButton();
  9026. if (!entry) {
  9027. /* 两种可能:对方关闭了私信(没有按钮),或页面还没渲染完。
  9028. * 分不清时如实说,不要假惺惺地重试到超时。 */
  9029. log.warn(story, '主页上没有 Message 按钮(对方可能关闭了私信,或页面还没渲染)');
  9030. return { ok: false, via: 'no-message-button', reason: '主页上没有 Message 按钮' };
  9031. }
  9032. log.info(story, '点 Message 入口');
  9033. TikTokAuto.ui.clickNode(entry, story);
  9034. var opened = TikTokAuto.wait.waitFor(story + ':panel', function () {
  9035. return panelInput() !== null ? true : null;
  9036. }, config.timeouts.panelReadyMs, config.probe.pollTickMs);
  9037. if (opened !== true) {
  9038. /* 点一次没开就再点一次(首次点击常被页面动画吞掉) */
  9039. log.warn(story, '第一次点击没打开面板,重试一次');
  9040. TikTokAuto.wait.waitQuiet(function () {
  9041. return TikTokAuto.layout.signature();
  9042. }, { maxMs: 1500, tickMs: 200, stableTimes: 2 });
  9043. var again = messageButton();
  9044. if (again) {
  9045. TikTokAuto.ui.clickNode(again, story + ':retry');
  9046. }
  9047. opened = TikTokAuto.wait.waitFor(story + ':panel2', function () {
  9048. return panelInput() !== null ? true : null;
  9049. }, config.timeouts.panelReadyMs, config.probe.pollTickMs);
  9050. }
  9051. if (opened !== true) {
  9052. return { ok: false, via: 'panel-not-opened', reason: '私信面板没打开' };
  9053. }
  9054. return { ok: true, via: 'message-button' };
  9055. }
  9056. /** 收起面板(只在确认开着时调用,不无脑 back) */
  9057. function closePanel(step) {
  9058. if (panelInput() === null) {
  9059. return false;
  9060. }
  9061. TikTokAuto.logger.info(step || 'dm:close', '收起私信面板');
  9062. back();
  9063. TikTokAuto.wait.until(function () {
  9064. return panelInput() === null ? true : null;
  9065. }, 4000, 200);
  9066. return true;
  9067. }
  9068. /**
  9069. * 发送一条私信。
  9070. *
  9071. * @param config 生效配置
  9072. * @param text 要发的内容
  9073. * @returns {ok, via, text, reason}
  9074. */
  9075. function send(config, text, step) {
  9076. var story = step || 'dm:send';
  9077. var log = logger();
  9078. var content = String(text || '');
  9079. if (content === '') {
  9080. return { ok: false, via: 'empty-text', text: content, reason: '私信内容为空' };
  9081. }
  9082. var opened = openPanel(config, story);
  9083. if (!opened.ok) {
  9084. return { ok: false, via: opened.via, text: content, reason: opened.reason };
  9085. }
  9086. var input = panelInput();
  9087. if (!input) {
  9088. return { ok: false, via: 'no-input', text: content, reason: '面板里找不到输入框' };
  9089. }
  9090. /* 先用 typeInto(它会点输入框聚焦、再写入、再回读校验)。
  9091. *
  9092. * ⚠️ **必须先聚焦**:不聚焦就不会弹键盘,而发送键是"输入框缩短后"
  9093. * 才出现的那块区域 —— 两者是同一个布局变化。 */
  9094. var typed = TikTokAuto.ui.typeInto(input, content, story);
  9095. if (!typed.ok) {
  9096. return { ok: false, via: 'type-failed', text: content, reason: '输入框回读不一致' };
  9097. }
  9098. if (config.runtime && config.runtime.dryRun) {
  9099. /* dryRun 也要**先把发送键找一遍**(只记录、不点)。
  9100. * 否则"演练模式"永远验证不到最不确定的那一环 —— 发送键是输入后才出现的、
  9101. * 无 id 无 desc 的 ImageView,找不到就等于发不出去,而演练却会报"成功"。 */
  9102. var preview = ensureSendButton(config, story + ':btn-preview');
  9103. log.warn(story, 'dryRun:已输入但不发送。发送键' +
  9104. (preview ? '已定位 ' + JSON.stringify(preview.bounds()) : '**没找到**') +
  9105. ',内容=' + JSON.stringify(content));
  9106. var restore = panelInput();
  9107. if (restore) {
  9108. try { restore.setText(''); } catch (e) { }
  9109. }
  9110. TikTokAuto.wait.tick(300);
  9111. closePanel(story);
  9112. return { ok: true, via: 'dry-run', text: content, skipped: true,
  9113. sendButtonFound: preview !== null };
  9114. }
  9115. /* 发送键是输入之后才出现的(而且依赖键盘弹起),拿不到会再聚焦重试一次 */
  9116. var button = ensureSendButton(config, story + ':btn');
  9117. if (!button) {
  9118. log.warn(story, '输入后仍找不到发送键');
  9119. closePanel(story);
  9120. return { ok: false, via: 'send-button-not-found', text: content,
  9121. reason: '输入后找不到发送键' };
  9122. }
  9123. /* 点发送键必须用**坐标点击**:它是无 id/desc 的 ImageView,
  9124. * 无障碍 ACTION_CLICK 大概率落到父容器上(评论那边就栽在这)。 */
  9125. var point = TikTokAuto.layout.center(button);
  9126. log.info(story, '点发送键 id=' + TikTokAuto.layout.shortId(button) +
  9127. ' b=' + JSON.stringify(button.bounds()));
  9128. if (point) {
  9129. TikTokAuto.ui.tapPoint(point.x, point.y);
  9130. } else {
  9131. TikTokAuto.ui.clickNode(button, story);
  9132. }
  9133. return awaitSent(config, content, story);
  9134. }
  9135. /**
  9136. * 判定"到底发出去没有"。
  9137. *
  9138. * 证据两档:
  9139. * 强:对话列表里出现了刚发的那条文本
  9140. * 中:输入框被清空并恢复占位符("Message...")+ 面板仍然开着
  9141. *
  9142. * 只有一档"面板关了"时**不算成功** —— 面板也可能被别的原因关掉,
  9143. * 这正是评论那边假阳性的来源。
  9144. */
  9145. function awaitSent(config, text, step) {
  9146. var log = logger();
  9147. /* 轮询等"输入框被清空"。
  9148. * `setText` 清空后 text 是空串;占位符 "Message..." 是 UI 画的、不算 text,
  9149. * 但某些版本会把占位符当 text 读回来,所以两个都认。 */
  9150. var sent = TikTokAuto.wait.waitFor(step + ':sent', function () {
  9151. var input = panelInput();
  9152. if (!input) {
  9153. return null; // 面板没了 ≠ 发出去了,不能算成功
  9154. }
  9155. var value = String(input.text() || '');
  9156. return (value === '' || value === 'Message...') ? true : null;
  9157. }, config.timeouts.postMs, config.probe.pollTickMs);
  9158. if (sent === true) {
  9159. /* 强证据:对话里能不能看到这条文本 */
  9160. if (conversationContains(text)) {
  9161. log.info(step, '私信已发送(对话里已出现该内容)' + JSON.stringify(text.slice(0, 20)));
  9162. return { ok: true, via: 'text-in-conversation', confidence: 'high', text: text };
  9163. }
  9164. log.info(step, '私信已发送(输入框已清空)' + JSON.stringify(text.slice(0, 20)));
  9165. return { ok: true, via: 'input-cleared', confidence: 'medium', text: text };
  9166. }
  9167. log.warn(step, '点了发送键但输入框没清空,疑似没发出去');
  9168. closePanel(step);
  9169. return { ok: false, via: 'not-cleared', text: text, reason: '点发送后输入框没清空' };
  9170. }
  9171. /** 对话列表里有没有这条文本(可见的 TextView 里找) */
  9172. function conversationContains(text) {
  9173. var layout = TikTokAuto.layout;
  9174. var needle = String(text || '');
  9175. if (needle === '') { return false; }
  9176. var found = className('android.widget.TextView').find();
  9177. for (var i = 0; i < found.size(); i += 1) {
  9178. var value = layout.text(found.get(i));
  9179. if (value && value.indexOf(needle) >= 0) {
  9180. return true;
  9181. }
  9182. }
  9183. return false;
  9184. }
  9185. return {
  9186. messageButton: messageButton,
  9187. panelInput: panelInput,
  9188. sendButton: sendButton,
  9189. openPanel: openPanel,
  9190. closePanel: closePanel,
  9191. send: send,
  9192. conversationContains: conversationContains
  9193. };
  9194. }());
  9195. /* ============================== src/dmflow.js ============================== */
  9196. /**
  9197. * dmflow.js — UID 私信的编排:读 UID → 关注一批 → 私信另一批 → 逐个休息
  9198. *
  9199. * ## 两批是**完全不同**的人
  9200. *
  9201. * 界面上「关注数量 100~200」与「私信数量 20~30」是两组独立额度,作用在不同的人身上:
  9202. * · 前 followMax 个 UID → 只关注
  9203. * · 接下来 sendMax 个 UID → 只发私信
  9204. * 所以 UID 文件里的号要够多(至少 关注上限 + 私信上限)。
  9205. *
  9206. * ## 每处理完一个人就休息 restMin~Max 秒
  9207. *
  9208. * 这个值 v9 起默认 10~40 秒(原来是 100~500,一轮要跑 5~24 小时,实际跑不完)。
  9209. * 休息是**分段等**的:每秒醒一次,用户点停止能立刻响应,而不是干等半分钟。
  9210. *
  9211. * ## 失败一律跳过
  9212. *
  9213. * 打不开主页、对方关闭私信、找不到发送键 —— 都记一条 warning 继续下一个,
  9214. * 不中断整轮。但**连续失败**会有上限:连着 5 个都不成,说明是环境问题
  9215. * (没登录、被限流、页面改版),继续跑只是白耗时间,停下来并说明原因。
  9216. *
  9217. * 依赖:TikTokAuto.profile / interact / dm / bridge / wait / logger / parse
  9218. */
  9219. TikTokAuto.dmflow = (function () {
  9220. 'use strict';
  9221. /** 连续这么多个都失败就认为环境出问题了,提前收尾 */
  9222. var CONSECUTIVE_FAIL_LIMIT = 5;
  9223. function logger() { return TikTokAuto.logger; }
  9224. function progress(phase, done, total) {
  9225. TikTokAuto.bridge.report({
  9226. status: 'running',
  9227. phase: phase,
  9228. done: done,
  9229. total: total
  9230. });
  9231. }
  9232. /**
  9233. * 读 UID 文本:每行一个(`@` 可有可无),忽略空行与 `#` 开头的注释行。
  9234. * 去重按**规范化后的用户名**,避免 `@abc` 与 `abc` 被当成两个人。
  9235. */
  9236. function readUids(path) {
  9237. var parse = TikTokAuto.parse;
  9238. var out = [];
  9239. var seen = {};
  9240. if (!path) {
  9241. return out;
  9242. }
  9243. var text = '';
  9244. try {
  9245. if (!files.exists(path)) {
  9246. return out;
  9247. }
  9248. text = String(files.read(path) || '');
  9249. } catch (error) {
  9250. return out;
  9251. }
  9252. var rows = text.split(/\r?\n/);
  9253. for (var i = 0; i < rows.length; i += 1) {
  9254. var line = String(rows[i] || '').replace(/^\s+|\s+$/g, '');
  9255. if (line === '' || line.charAt(0) === '#') {
  9256. continue;
  9257. }
  9258. var uid = parse.username(line);
  9259. if (uid === '' || seen[uid]) {
  9260. continue;
  9261. }
  9262. seen[uid] = true;
  9263. out.push(uid);
  9264. }
  9265. return out;
  9266. }
  9267. /**
  9268. * 脚本二:从**自己的粉丝列表**取材。
  9269. *
  9270. * 采够 `need` 个就停 —— 多采没意义,而且每多翻一屏就多等一次渲染。
  9271. * 停止条件挂在 `ctx.shouldStop()` 上:`walkList` 每处理一行前都会问,
  9272. * 够了就自然结束(不是抛异常,所以不会把已采到的丢掉)。
  9273. *
  9274. * ★ 不要在这里判"列表到底":那是 walkList 的事(它只看"翻不翻得动")。
  9275. * 早期版本用"本屏没有新人"当到底判据,结果预载历史时在列表顶部就收工。
  9276. *
  9277. * @returns 采到的 uid 数组(可能少于 need,比如粉丝本来就没那么多)
  9278. */
  9279. function collectOwnFans(config, need, summary) {
  9280. var log = logger();
  9281. var profileApi = TikTokAuto.profile;
  9282. var uids = [];
  9283. if (!profileApi.openOwnProfile(config)) {
  9284. summary.errors.push('打不开自己的主页(底部导航 Profile),无法取材');
  9285. return uids;
  9286. }
  9287. if (!profileApi.openList('fans', config)) {
  9288. /* 主页数字区的入口第一次常常还没渲染出来(openList 内部已等 3 秒)。
  9289. * 再等 2 秒重试一次 —— 失败一次就整轮退出太亏了。 */
  9290. log.warn('dmflow', '第一次没打开粉丝列表,2 秒后重试');
  9291. TikTokAuto.wait.tick(2000);
  9292. if (!profileApi.openList('fans', config)) {
  9293. summary.errors.push('打不开自己的粉丝列表(可能主页上不显示粉丝数)');
  9294. return uids;
  9295. }
  9296. }
  9297. /* 列表页默认停在「粉丝」还是「关注」取决于上次的操作,必须显式切一次。
  9298. * 切错页签的后果很难发现:一样能翻、一样有人,只是全是**你关注的人**。 */
  9299. if (!profileApi.switchListTab('fans')) {
  9300. summary.warnings.push('没找到「粉丝」页签,按当前页签取材(可能取到的是关注列表)');
  9301. }
  9302. log.step('dmflow', '开始从自己的粉丝列表取材,目标 ' + need + ' 个');
  9303. var ctx = {
  9304. config: config,
  9305. seen: TikTokAuto.uidset.create(),
  9306. stat: {},
  9307. shouldStop: function () {
  9308. return uids.length >= need;
  9309. },
  9310. onSeenProfile: function () { },
  9311. onRow: function (row) {
  9312. if (uids.length < need) {
  9313. uids.push(row.uid);
  9314. }
  9315. }
  9316. };
  9317. try {
  9318. TikTokAuto.idsource.walkList(ctx);
  9319. } catch (error) {
  9320. if (TikTokAuto.bridge.isStopSignal(error)) {
  9321. throw error;
  9322. }
  9323. log.warn('dmflow', '遍历粉丝列表时出错:' + error);
  9324. summary.warnings.push('遍历粉丝列表时出错,已采到 ' + uids.length + ' 个');
  9325. }
  9326. profileApi.backToProfile();
  9327. log.step('dmflow', '自己粉丝取材完成:' + uids.length + '/' + need +
  9328. ' 个(翻了 ' + (ctx.stat.screens || 0) + ' 屏)');
  9329. return uids;
  9330. }
  9331. /** 休息指定区间,**分段等**:每秒醒一次,停止信号能立刻生效 */
  9332. function rest(config, step) {
  9333. var lo = config.dm.rest.minMs;
  9334. var hi = config.dm.rest.maxMs;
  9335. var total = TikTokAuto.player.randomInt(lo, hi);
  9336. var left = total;
  9337. var sliceMs = 1000;
  9338. while (left > 0) {
  9339. var slice = Math.min(sliceMs, left);
  9340. TikTokAuto.wait.tick(slice);
  9341. left -= slice;
  9342. }
  9343. return total;
  9344. }
  9345. /** 从文案池随机挑一条,尽量不重复(池子用完就允许重复并告警) */
  9346. function pickText(pool, used, step) {
  9347. if (!pool || pool.length === 0) {
  9348. return '';
  9349. }
  9350. var candidates = [];
  9351. for (var i = 0; i < pool.length; i += 1) {
  9352. if (used.indexOf(pool[i]) === -1) {
  9353. candidates.push(pool[i]);
  9354. }
  9355. }
  9356. if (candidates.length === 0) {
  9357. logger().warn(step, '私信文案已用完,本轮允许重复');
  9358. candidates = pool;
  9359. }
  9360. return candidates[Math.floor(Math.random() * candidates.length)];
  9361. }
  9362. /**
  9363. * 处理一个人:打开主页 → 关注 或 发私信。
  9364. *
  9365. * @returns {uid, follow?, dm?, skipped?, reason?}
  9366. */
  9367. function handleOne(config, uid, mode, text, step) {
  9368. var log = logger();
  9369. var profileApi = TikTokAuto.profile;
  9370. var detail = { uid: uid, mode: mode };
  9371. var opened = profileApi.openBySearch(uid, config);
  9372. if (!opened) {
  9373. detail.skipped = true;
  9374. detail.reason = '打不开主页';
  9375. return detail;
  9376. }
  9377. if (mode === 'follow') {
  9378. detail.follow = TikTokAuto.interact.follow(step + ':follow');
  9379. return detail;
  9380. }
  9381. /* 私信:先确认面板能开,再发。发完把面板收掉,否则下一个人的主页会被它挡住。 */
  9382. detail.dm = TikTokAuto.dm.send(config, text, step + ':dm');
  9383. TikTokAuto.dm.closePanel(step + ':close');
  9384. return detail;
  9385. }
  9386. /**
  9387. * @param config 生效配置(含 `config.dm`)
  9388. * @returns summary
  9389. */
  9390. function runSession(config) {
  9391. var log = logger();
  9392. var settings = config.dm;
  9393. var summary = {
  9394. ok: false,
  9395. task: 'dm',
  9396. script: settings.script,
  9397. /** 人是哪儿来的:'uids' = 脚本一读文本;'ownFans' = 脚本二自己粉丝列表 */
  9398. sourceKind: settings.script === 's2' ? 'ownFans' : 'uids',
  9399. /** 演练模式:输入但不发送。要单独计数,否则 dryRun 会被误报成"什么都没做成" */
  9400. dryRun: !!(config.runtime && config.runtime.dryRun),
  9401. dryRunCount: 0,
  9402. /** 取材结果里有多少个人(s1 = 文件行数,s2 = 粉丝列表采到的) */
  9403. totalUids: 0,
  9404. followPlanned: 0,
  9405. dmPlanned: 0,
  9406. followed: 0,
  9407. sent: 0,
  9408. skipped: 0,
  9409. failed: 0,
  9410. details: [],
  9411. warnings: [],
  9412. errors: []
  9413. };
  9414. /* ① 先算两批各要多少人 —— 脚本二靠这个数才知道要采多少人 */
  9415. var followMax = settings.follow.enabled ? settings.follow.max : 0;
  9416. var sendMax = settings.send.enabled ? settings.send.max : 0;
  9417. if (followMax === 0 && sendMax === 0) {
  9418. summary.errors.push('「进行关注」和「进行私信」都没开,没有可执行的动作');
  9419. return summary;
  9420. }
  9421. var need = followMax + sendMax;
  9422. var uids = [];
  9423. var appReady = false;
  9424. if (settings.script === 's2') {
  9425. /* 脚本二:人就在**自己**主页的粉丝列表里,必须先把 App 拉起来才能取材。
  9426. * 顺序与脚本一相反(脚本一是"文件不对就立刻返回,不折腾设备")。 */
  9427. progress('打开自己的粉丝列表', 0, need);
  9428. if (!TikTokAuto.flow.launchApp(config)) {
  9429. summary.errors.push('启动应用失败');
  9430. return summary;
  9431. }
  9432. if (!TikTokAuto.flow.ensureFeed(config)) {
  9433. summary.errors.push('未能进入信息流首页');
  9434. return summary;
  9435. }
  9436. appReady = true;
  9437. uids = collectOwnFans(config, need, summary);
  9438. if (uids.length === 0) {
  9439. if (summary.errors.length === 0) {
  9440. summary.errors.push('自己的粉丝列表里没取到人(可能还没有粉丝)');
  9441. }
  9442. return summary;
  9443. }
  9444. } else {
  9445. /* 脚本一:读文本。**这一步不碰手机**,文件不对就立刻返回。 */
  9446. uids = readUids(settings.uidPath);
  9447. if (uids.length === 0) {
  9448. summary.errors.push('UID 文本里没有可用的账号(' +
  9449. (settings.uidPath || '(未设置路径)') + ')');
  9450. return summary;
  9451. }
  9452. }
  9453. summary.totalUids = uids.length;
  9454. /* ② 分两批:前 N 个关注,接着 M 个私信 —— 两批人不重叠 */
  9455. var followBatch = uids.slice(0, followMax);
  9456. var dmBatch = uids.slice(followMax, followMax + sendMax);
  9457. summary.followPlanned = followBatch.length;
  9458. summary.dmPlanned = dmBatch.length;
  9459. if (followBatch.length < followMax || dmBatch.length < sendMax) {
  9460. summary.warnings.push((settings.script === 's2' ? '粉丝' : 'UID') +
  9461. '只够 ' + uids.length + ' 个,少于计划(关注 ' +
  9462. followMax + ' + 私信 ' + sendMax + '),本轮按实际数量执行');
  9463. }
  9464. var total = followBatch.length + dmBatch.length;
  9465. log.step('dmflow', '私信任务开始(' +
  9466. (settings.script === 's2' ? '自己粉丝取材' : 'UID 文本取材') + '):' +
  9467. (settings.script === 's2' ? '粉丝' : 'UID') + ' ' + uids.length + ' 个;关注 ' +
  9468. followBatch.length + ' 个 + 私信 ' + dmBatch.length + ' 个;' +
  9469. '每个之后休息 ' + Math.round(settings.rest.minMs / 1000) + '~' +
  9470. Math.round(settings.rest.maxMs / 1000) + ' 秒');
  9471. /* 脚本二在取材时已经把 App 拉起来并停在「自己的主页」上了,别重复启动:
  9472. * launchApp 会重新发一次 startActivity(已在前台也一样),把页面状态打乱。
  9473. * 后面的 handleOne → openBySearch 第一步就是 ensureFeed,会自己回信息流。 */
  9474. if (!appReady) {
  9475. progress('启动应用', 0, total);
  9476. if (!TikTokAuto.flow.launchApp(config)) {
  9477. summary.errors.push('启动应用失败');
  9478. return summary;
  9479. }
  9480. if (!TikTokAuto.flow.ensureFeed(config)) {
  9481. summary.errors.push('未能进入信息流首页');
  9482. return summary;
  9483. }
  9484. }
  9485. var usedTexts = [];
  9486. var done = 0;
  9487. var consecutiveFail = 0;
  9488. var i = 0;
  9489. /* ③ 关注批次 */
  9490. for (i = 0; i < followBatch.length; i += 1) {
  9491. var fUid = followBatch[i];
  9492. progress('关注 ' + (i + 1) + '/' + followBatch.length + ' @' + fUid, done, total);
  9493. var fDetail = handleOne(config, fUid, 'follow', '', 'dmflow:follow');
  9494. fDetail.phase = 'follow';
  9495. summary.details.push(fDetail);
  9496. if (fDetail.skipped) {
  9497. summary.skipped += 1;
  9498. summary.warnings.push('关注 @' + fUid + ' 跳过:' + fDetail.reason);
  9499. consecutiveFail += 1;
  9500. } else if (fDetail.follow && fDetail.follow.ok) {
  9501. if (!fDetail.follow.skipped) {
  9502. summary.followed += 1;
  9503. }
  9504. consecutiveFail = 0;
  9505. } else {
  9506. summary.failed += 1;
  9507. summary.warnings.push('关注 @' + fUid + ' 失败:' +
  9508. ((fDetail.follow && fDetail.follow.reason) || '未知'));
  9509. consecutiveFail += 1;
  9510. }
  9511. done += 1;
  9512. if (consecutiveFail >= CONSECUTIVE_FAIL_LIMIT) {
  9513. summary.warnings.push('连续 ' + consecutiveFail +
  9514. ' 个账号都失败,提前结束(可能是未登录 / 被限流 / 页面改版)');
  9515. break;
  9516. }
  9517. /* 批次之间**不**休息 —— 关注是轻动作,休息留给私信更合理。
  9518. * 但用户明确要求"每处理完一个 UID 都休息",所以这里也休。 */
  9519. rest(config, 'dmflow:rest');
  9520. }
  9521. /* ④ 私信批次(换另一批人) */
  9522. var dmStart = 0;
  9523. if (consecutiveFail < CONSECUTIVE_FAIL_LIMIT) {
  9524. for (dmStart = 0; dmStart < dmBatch.length; dmStart += 1) {
  9525. var dUid = dmBatch[dmStart];
  9526. var text = pickText(settings.pool, usedTexts, 'dmflow:pick');
  9527. if (text === '') {
  9528. summary.warnings.push('私信文案池是空的,私信批次没法执行(检查「随机私信文本」那个文件)');
  9529. break;
  9530. }
  9531. progress('私信 ' + (dmStart + 1) + '/' + dmBatch.length + ' @' + dUid, done, total);
  9532. var dDetail = handleOne(config, dUid, 'dm', text, 'dmflow:dm');
  9533. dDetail.phase = 'dm';
  9534. dDetail.text = text;
  9535. summary.details.push(dDetail);
  9536. if (dDetail.skipped) {
  9537. summary.skipped += 1;
  9538. summary.warnings.push('私信 @' + dUid + ' 跳过:' + dDetail.reason);
  9539. consecutiveFail += 1;
  9540. } else if (dDetail.dm && dDetail.dm.ok) {
  9541. if (dDetail.dm.skipped) {
  9542. /* dryRun:演练成功(输入到位、发送键也定位到了),但没真发 */
  9543. summary.dryRunCount += 1;
  9544. } else {
  9545. summary.sent += 1;
  9546. usedTexts.push(text);
  9547. }
  9548. consecutiveFail = 0;
  9549. } else {
  9550. summary.failed += 1;
  9551. summary.warnings.push('私信 @' + dUid + ' 失败:' +
  9552. ((dDetail.dm && dDetail.dm.reason) || '未知'));
  9553. consecutiveFail += 1;
  9554. }
  9555. done += 1;
  9556. if (consecutiveFail >= CONSECUTIVE_FAIL_LIMIT) {
  9557. summary.warnings.push('连续 ' + consecutiveFail +
  9558. ' 个账号都失败,提前结束(可能是未登录 / 被限流 / 页面改版)');
  9559. break;
  9560. }
  9561. rest(config, 'dmflow:rest');
  9562. }
  9563. }
  9564. summary.ok = summary.followed > 0 || summary.sent > 0 || summary.dryRunCount > 0;
  9565. if (!summary.ok && summary.errors.length === 0) {
  9566. summary.errors.push('一个动作都没做成(关注 ' + summary.followed +
  9567. ' / 私信 ' + summary.sent + ')');
  9568. }
  9569. log.step('dmflow', '私信任务结束:关注 ' + summary.followed + ' 个,私信 ' +
  9570. summary.sent + ' 条' +
  9571. (summary.dryRun ? '(演练 ' + summary.dryRunCount + ' 条,未真发)' : '') +
  9572. ',跳过 ' + summary.skipped + ' 个,失败 ' + summary.failed + ' 个');
  9573. return summary;
  9574. }
  9575. return {
  9576. runSession: runSession,
  9577. readUids: readUids,
  9578. collectOwnFans: collectOwnFans,
  9579. pickText: pickText,
  9580. CONSECUTIVE_FAIL_LIMIT: CONSECUTIVE_FAIL_LIMIT
  9581. };
  9582. }());
  9583. /* ============================== src/main.js ============================== */
  9584. /**
  9585. * main.js — 入口与收尾
  9586. *
  9587. * 入口只负责三件事:读配置、开守护线程、跑流程。
  9588. * 收尾负责四件**必须保证**的事:
  9589. * 1. 无论成功失败都打印 `__END__`(否则 PC 侧执行器会一直等到超时);
  9590. * 2. 关掉守护线程(`threads.shutDownAll()`,否则进程不退出);
  9591. * 3. 打印一行机器可读的 RESULT,让 PC 侧能直接断言而不是解析自然语言日志;
  9592. * 4. 向界面宿主上报终态(被界面唤起时才有这一步,见 bridge.js)。
  9593. *
  9594. * 依赖:全部模块
  9595. */
  9596. TikTokAuto.main = (function () {
  9597. 'use strict';
  9598. var RESULT_MARKER = '__END__';
  9599. function emptySummary(reason) {
  9600. return {
  9601. ok: false,
  9602. attemptedVideos: 0,
  9603. postedComments: 0,
  9604. details: [],
  9605. errors: [reason]
  9606. };
  9607. }
  9608. /**
  9609. * 去掉占位原因。
  9610. * `emptySummary('未开始')` 的那条只在"一次都没跑起来"时才有意义;
  9611. * 流程已经跑过一段后再追加真实原因,留着它会让界面把"未开始"显示成第一条错误。
  9612. */
  9613. function dropPlaceholder(errors) {
  9614. var kept = [];
  9615. for (var i = 0; i < errors.length; i += 1) {
  9616. if (errors[i] !== '未开始') {
  9617. kept.push(errors[i]);
  9618. }
  9619. }
  9620. return kept;
  9621. }
  9622. /**
  9623. * 按 `config.task` 分派。
  9624. *
  9625. * 入口只有这一个分岔点:两条链路都走同一套"配置 → 守护线程 → 流程 → 终态上报",
  9626. * 差别只在中间调哪个 runSession。所以 `__END__`、RESULT、终态上报这些收尾逻辑
  9627. * **一份就够** —— 各写一份的话,两边的收尾行为迟早会不一致。
  9628. */
  9629. function runByTask(config) {
  9630. if (config.task === 'id') {
  9631. return TikTokAuto.idflow.runSession(config);
  9632. }
  9633. if (config.task === 'nurture') {
  9634. return TikTokAuto.nurture.runSession(config);
  9635. }
  9636. if (config.task === 'dm') {
  9637. return TikTokAuto.dmflow.runSession(config);
  9638. }
  9639. return TikTokAuto.flow.runSession(config);
  9640. }
  9641. /**
  9642. * @param injected PC 侧注入的配置覆盖项
  9643. * @returns 结构化结果
  9644. */
  9645. function run(injected) {
  9646. var log = TikTokAuto.logger;
  9647. var bridge = TikTokAuto.bridge;
  9648. var summary = emptySummary('未开始');
  9649. var guardState = null;
  9650. var config = null;
  9651. /* 是"用户叫停"还是"出错了",收尾时的上报状态不同 */
  9652. var stoppedByUser = false;
  9653. try {
  9654. log.step('main', '=== TikTok 自动互动开始 ===');
  9655. bridge.report({ status: 'running', phase: '准备', done: 0, total: 0 });
  9656. try {
  9657. config = TikTokAuto.config.resolve(injected);
  9658. } catch (configError) {
  9659. log.error('main', '配置无效:' + configError.message + ' / detail=' + JSON.stringify(configError.detail));
  9660. summary = emptySummary('配置无效:' + configError.message);
  9661. return summary;
  9662. }
  9663. /* 配置里带着"这次属于哪个 runId",校验它对不对是 config.resolve 的事;
  9664. * 这里只把它登记到状态里,让宿主在排障时一眼看出跑的是哪个任务。 */
  9665. bridge.setTask(config.task);
  9666. bridge.report({ phase: '启动应用', total: config.task === 'id' ? 0 : config.target.maxVideos });
  9667. guardState = TikTokAuto.popups.startGuard(config, log);
  9668. var guardUp = TikTokAuto.wait.waitFor('guard', function () {
  9669. return guardState.passes >= 1 ? true : null;
  9670. }, config.guard.firstScanTimeoutMs, 200);
  9671. if (!guardUp) {
  9672. log.warn('guard', '守护线程首次巡检未确认,继续执行(不阻塞主流程)');
  9673. }
  9674. summary = runByTask(config);
  9675. log.step('main', '=== 流程结束,耗时 ' + log.elapsedMs() + 'ms ===');
  9676. } catch (error) {
  9677. if (bridge.isStopSignal(error)) {
  9678. /* 停止是**正常收尾**,不是崩溃:不记 ERROR、不把堆栈当异常信息 */
  9679. stoppedByUser = true;
  9680. log.warn('main', '收到停止请求,提前结束本次运行');
  9681. /* ID 提取的统计在 idflow 里已经算好了(含刚刷盘的那个数),
  9682. * 这里取它 —— 否则界面会显示"提取 0 个",而文件里明明有内容。 */
  9683. var partial = TikTokAuto.idflow ? TikTokAuto.idflow.lastSummary() : null;
  9684. if (partial) {
  9685. summary = partial;
  9686. }
  9687. summary.ok = false;
  9688. summary.errors = dropPlaceholder(summary.errors || []);
  9689. summary.errors.push('用户停止');
  9690. } else {
  9691. var detail = error && error.stack ? String(error.stack) : String(error);
  9692. log.error('main', '未捕获异常:' + detail);
  9693. summary.ok = false;
  9694. summary.errors.push(String(error));
  9695. }
  9696. } finally {
  9697. TikTokAuto.popups.stopGuard(guardState, log);
  9698. log.result(summary);
  9699. /* 先把终态交给宿主,再收线程:顺序反了的话,界面可能永远等不到"结束",
  9700. * 状态条会一直显示"运行中"。
  9701. *
  9702. * ★ finish 必须包 try/finally:它自己一旦抛错(summary 结构异常之类),
  9703. * 心跳线程就永远不被收掉 —— 而这是 finally 里,没有下一层兜底,
  9704. * `shutDownAll` 与 `__END__` 都会被一起吞掉,宿主只能等 15 秒判死。 */
  9705. try {
  9706. bridge.finish(
  9707. stoppedByUser ? 'stopped' : (summary.ok ? 'finished' : 'failed'),
  9708. summary
  9709. );
  9710. } finally {
  9711. bridge.heartbeatStop();
  9712. }
  9713. // 顺序很重要:先让日志刷出去,再收线程,最后打结束标记
  9714. try {
  9715. threads.shutDownAll();
  9716. } catch (ignored) {
  9717. // 没有线程在跑时该调用可能抛错,忽略即可
  9718. }
  9719. console.log(RESULT_MARKER);
  9720. }
  9721. return summary;
  9722. }
  9723. return {
  9724. RESULT_MARKER: RESULT_MARKER,
  9725. run: run
  9726. };
  9727. }());
  9728. /* PC 侧下发时可替换下面这一行的 null 来注入配置覆盖项 */
  9729. var INJECTED_CONFIG = /*TTA_INJECTED_CONFIG*/null;
  9730. /* 被界面宿主唤起时(engines.execScriptFile 带 --config/--run-id 参数),
  9731. * fromExecArgv 会把参数翻译成「配置路径 + 桥的开关」;独立运行时是空操作。 */
  9732. TikTokAuto.main.run(TikTokAuto.bridge.fromExecArgv(INJECTED_CONFIG));
  9733. }());