| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394539553965397539853995400540154025403540454055406540754085409541054115412541354145415541654175418541954205421542254235424542554265427542854295430543154325433543454355436543754385439544054415442544354445445544654475448544954505451545254535454545554565457545854595460546154625463546454655466546754685469547054715472547354745475547654775478547954805481548254835484548554865487548854895490549154925493549454955496549754985499550055015502550355045505550655075508550955105511551255135514551555165517551855195520552155225523552455255526552755285529553055315532553355345535553655375538553955405541554255435544554555465547554855495550555155525553555455555556555755585559556055615562556355645565556655675568556955705571557255735574557555765577557855795580558155825583558455855586558755885589559055915592559355945595559655975598559956005601560256035604560556065607560856095610561156125613561456155616561756185619562056215622562356245625562656275628562956305631563256335634563556365637563856395640564156425643564456455646564756485649565056515652565356545655565656575658565956605661566256635664566556665667566856695670567156725673567456755676567756785679568056815682568356845685568656875688568956905691569256935694569556965697569856995700570157025703570457055706570757085709571057115712571357145715571657175718571957205721572257235724572557265727572857295730573157325733573457355736573757385739574057415742574357445745574657475748574957505751575257535754575557565757575857595760576157625763576457655766576757685769577057715772577357745775577657775778577957805781578257835784578557865787578857895790579157925793579457955796579757985799580058015802580358045805580658075808580958105811581258135814581558165817581858195820582158225823582458255826582758285829583058315832583358345835583658375838583958405841584258435844584558465847584858495850585158525853585458555856585758585859586058615862586358645865586658675868586958705871587258735874587558765877587858795880588158825883588458855886588758885889589058915892589358945895589658975898589959005901590259035904590559065907590859095910591159125913591459155916591759185919592059215922592359245925592659275928592959305931593259335934593559365937593859395940594159425943594459455946594759485949595059515952595359545955595659575958595959605961596259635964596559665967596859695970597159725973597459755976597759785979598059815982598359845985598659875988598959905991599259935994599559965997599859996000600160026003600460056006600760086009601060116012601360146015601660176018601960206021602260236024602560266027602860296030603160326033603460356036603760386039604060416042604360446045604660476048604960506051605260536054605560566057605860596060606160626063606460656066606760686069607060716072607360746075607660776078607960806081608260836084608560866087608860896090609160926093609460956096609760986099610061016102610361046105610661076108610961106111611261136114611561166117611861196120612161226123612461256126612761286129613061316132613361346135613661376138613961406141614261436144614561466147614861496150615161526153615461556156615761586159616061616162616361646165616661676168616961706171617261736174617561766177617861796180618161826183618461856186618761886189619061916192619361946195619661976198619962006201620262036204620562066207620862096210621162126213621462156216621762186219622062216222622362246225622662276228622962306231623262336234623562366237623862396240624162426243624462456246624762486249625062516252625362546255625662576258625962606261626262636264626562666267626862696270627162726273627462756276627762786279628062816282628362846285628662876288628962906291629262936294629562966297629862996300630163026303630463056306630763086309631063116312631363146315631663176318631963206321632263236324632563266327632863296330633163326333633463356336633763386339634063416342634363446345634663476348634963506351635263536354635563566357635863596360636163626363636463656366636763686369637063716372637363746375637663776378637963806381638263836384638563866387638863896390639163926393639463956396639763986399640064016402640364046405640664076408640964106411641264136414641564166417641864196420642164226423642464256426642764286429643064316432643364346435643664376438643964406441644264436444644564466447644864496450645164526453645464556456645764586459646064616462646364646465646664676468646964706471647264736474647564766477647864796480648164826483648464856486648764886489649064916492649364946495649664976498649965006501650265036504650565066507650865096510651165126513651465156516651765186519652065216522652365246525652665276528652965306531653265336534653565366537653865396540654165426543654465456546654765486549655065516552655365546555655665576558655965606561656265636564656565666567656865696570657165726573657465756576657765786579658065816582658365846585658665876588658965906591659265936594659565966597659865996600660166026603660466056606660766086609661066116612661366146615661666176618661966206621662266236624662566266627662866296630663166326633663466356636663766386639664066416642664366446645664666476648664966506651665266536654665566566657665866596660666166626663666466656666666766686669667066716672667366746675667666776678667966806681668266836684668566866687668866896690669166926693669466956696669766986699670067016702670367046705670667076708670967106711671267136714671567166717671867196720672167226723672467256726672767286729673067316732673367346735673667376738673967406741674267436744674567466747674867496750675167526753675467556756675767586759676067616762676367646765676667676768676967706771677267736774677567766777677867796780678167826783678467856786678767886789679067916792679367946795679667976798679968006801680268036804680568066807680868096810681168126813681468156816681768186819682068216822682368246825682668276828682968306831683268336834683568366837683868396840684168426843684468456846684768486849685068516852685368546855685668576858685968606861686268636864686568666867686868696870687168726873687468756876687768786879688068816882688368846885688668876888688968906891689268936894689568966897689868996900690169026903690469056906690769086909691069116912691369146915691669176918691969206921692269236924692569266927692869296930693169326933693469356936693769386939694069416942694369446945694669476948694969506951695269536954695569566957695869596960696169626963696469656966696769686969697069716972697369746975697669776978697969806981698269836984698569866987698869896990699169926993699469956996699769986999700070017002700370047005700670077008700970107011701270137014701570167017701870197020702170227023702470257026702770287029703070317032703370347035703670377038703970407041704270437044704570467047704870497050705170527053705470557056705770587059706070617062706370647065706670677068706970707071707270737074707570767077707870797080708170827083708470857086708770887089709070917092709370947095709670977098709971007101710271037104710571067107710871097110711171127113711471157116711771187119712071217122712371247125712671277128712971307131713271337134713571367137713871397140714171427143714471457146714771487149715071517152715371547155715671577158715971607161716271637164716571667167716871697170717171727173717471757176717771787179718071817182718371847185718671877188718971907191719271937194719571967197719871997200720172027203720472057206720772087209721072117212721372147215721672177218721972207221722272237224722572267227722872297230723172327233723472357236723772387239724072417242724372447245724672477248724972507251725272537254725572567257725872597260726172627263726472657266726772687269727072717272727372747275727672777278727972807281728272837284728572867287728872897290729172927293729472957296729772987299730073017302730373047305730673077308730973107311731273137314731573167317731873197320732173227323732473257326732773287329733073317332733373347335733673377338733973407341734273437344734573467347734873497350735173527353735473557356735773587359736073617362736373647365736673677368736973707371737273737374737573767377737873797380738173827383738473857386738773887389739073917392739373947395739673977398739974007401740274037404740574067407740874097410741174127413741474157416741774187419742074217422742374247425742674277428742974307431743274337434743574367437743874397440744174427443744474457446744774487449745074517452745374547455745674577458745974607461746274637464746574667467746874697470747174727473747474757476747774787479748074817482748374847485748674877488748974907491749274937494749574967497749874997500750175027503750475057506750775087509751075117512751375147515751675177518751975207521752275237524752575267527752875297530753175327533753475357536753775387539754075417542754375447545754675477548754975507551755275537554755575567557755875597560756175627563756475657566756775687569757075717572757375747575757675777578757975807581758275837584758575867587758875897590759175927593759475957596759775987599760076017602760376047605760676077608760976107611761276137614761576167617761876197620762176227623762476257626762776287629763076317632763376347635763676377638763976407641764276437644764576467647764876497650765176527653765476557656765776587659766076617662766376647665766676677668766976707671767276737674767576767677767876797680768176827683768476857686768776887689769076917692769376947695769676977698769977007701770277037704770577067707770877097710771177127713771477157716771777187719772077217722772377247725772677277728772977307731773277337734773577367737773877397740774177427743774477457746774777487749775077517752775377547755775677577758775977607761776277637764776577667767776877697770777177727773777477757776777777787779778077817782778377847785778677877788778977907791779277937794779577967797779877997800780178027803780478057806780778087809781078117812781378147815781678177818781978207821782278237824782578267827782878297830783178327833783478357836783778387839784078417842784378447845784678477848784978507851785278537854785578567857785878597860786178627863786478657866786778687869787078717872787378747875787678777878787978807881788278837884788578867887788878897890789178927893789478957896789778987899790079017902790379047905790679077908790979107911791279137914791579167917791879197920792179227923792479257926792779287929793079317932793379347935793679377938793979407941794279437944794579467947794879497950795179527953795479557956795779587959796079617962796379647965796679677968796979707971797279737974797579767977797879797980798179827983798479857986798779887989799079917992799379947995799679977998799980008001800280038004800580068007800880098010801180128013801480158016801780188019802080218022802380248025802680278028802980308031803280338034803580368037803880398040804180428043804480458046804780488049805080518052805380548055805680578058805980608061806280638064806580668067806880698070807180728073807480758076807780788079808080818082808380848085808680878088808980908091809280938094809580968097809880998100810181028103810481058106810781088109811081118112811381148115811681178118811981208121812281238124812581268127812881298130813181328133813481358136813781388139814081418142814381448145814681478148814981508151815281538154815581568157815881598160816181628163816481658166816781688169817081718172817381748175817681778178817981808181818281838184818581868187818881898190819181928193819481958196819781988199820082018202820382048205820682078208820982108211821282138214821582168217821882198220822182228223822482258226822782288229823082318232823382348235823682378238823982408241824282438244824582468247824882498250825182528253825482558256825782588259826082618262826382648265826682678268826982708271827282738274827582768277827882798280828182828283828482858286828782888289829082918292829382948295829682978298829983008301830283038304830583068307830883098310831183128313831483158316831783188319832083218322832383248325832683278328832983308331833283338334833583368337833883398340834183428343834483458346834783488349835083518352835383548355835683578358835983608361836283638364836583668367836883698370837183728373837483758376837783788379838083818382838383848385838683878388838983908391839283938394839583968397839883998400840184028403840484058406840784088409841084118412841384148415841684178418841984208421842284238424842584268427842884298430843184328433843484358436843784388439844084418442844384448445844684478448844984508451845284538454845584568457845884598460846184628463846484658466846784688469847084718472847384748475847684778478847984808481848284838484848584868487848884898490849184928493849484958496849784988499850085018502850385048505850685078508850985108511851285138514851585168517851885198520852185228523852485258526852785288529853085318532853385348535853685378538853985408541854285438544854585468547854885498550855185528553855485558556855785588559856085618562856385648565856685678568856985708571857285738574857585768577857885798580858185828583858485858586858785888589859085918592859385948595859685978598859986008601860286038604860586068607860886098610861186128613861486158616861786188619862086218622862386248625862686278628862986308631863286338634863586368637863886398640864186428643864486458646864786488649865086518652865386548655865686578658865986608661866286638664866586668667866886698670867186728673867486758676867786788679868086818682868386848685868686878688868986908691869286938694869586968697869886998700870187028703870487058706870787088709871087118712871387148715871687178718871987208721872287238724872587268727872887298730873187328733873487358736873787388739874087418742874387448745874687478748874987508751875287538754875587568757875887598760876187628763876487658766876787688769877087718772877387748775877687778778877987808781878287838784878587868787878887898790879187928793879487958796879787988799880088018802880388048805880688078808880988108811881288138814881588168817881888198820882188228823882488258826882788288829883088318832883388348835883688378838883988408841884288438844884588468847884888498850885188528853885488558856885788588859886088618862886388648865886688678868886988708871887288738874887588768877887888798880888188828883888488858886888788888889889088918892889388948895889688978898889989008901890289038904890589068907890889098910891189128913891489158916891789188919892089218922892389248925892689278928892989308931893289338934893589368937893889398940894189428943894489458946894789488949895089518952895389548955895689578958895989608961896289638964896589668967896889698970897189728973897489758976897789788979898089818982898389848985898689878988898989908991899289938994899589968997899889999000900190029003900490059006900790089009901090119012901390149015901690179018901990209021902290239024902590269027902890299030903190329033903490359036903790389039904090419042904390449045904690479048904990509051905290539054905590569057905890599060906190629063906490659066906790689069907090719072907390749075907690779078907990809081908290839084908590869087908890899090909190929093909490959096909790989099910091019102910391049105910691079108910991109111911291139114911591169117911891199120912191229123912491259126912791289129913091319132913391349135913691379138913991409141914291439144914591469147914891499150915191529153915491559156915791589159916091619162916391649165916691679168916991709171917291739174917591769177917891799180918191829183918491859186918791889189919091919192919391949195919691979198919992009201920292039204920592069207920892099210921192129213921492159216921792189219922092219222922392249225922692279228922992309231923292339234923592369237923892399240924192429243924492459246924792489249925092519252925392549255925692579258925992609261926292639264926592669267926892699270927192729273927492759276927792789279928092819282928392849285928692879288928992909291929292939294929592969297929892999300930193029303930493059306930793089309931093119312931393149315931693179318931993209321932293239324932593269327932893299330933193329333933493359336933793389339934093419342934393449345934693479348934993509351935293539354935593569357935893599360936193629363936493659366936793689369937093719372937393749375937693779378937993809381938293839384938593869387938893899390939193929393939493959396939793989399940094019402940394049405940694079408940994109411941294139414941594169417941894199420942194229423942494259426942794289429943094319432943394349435943694379438943994409441944294439444944594469447944894499450945194529453945494559456945794589459946094619462946394649465946694679468946994709471947294739474947594769477947894799480948194829483948494859486948794889489949094919492949394949495949694979498949995009501950295039504950595069507950895099510951195129513951495159516951795189519952095219522952395249525952695279528952995309531953295339534953595369537953895399540954195429543954495459546954795489549955095519552955395549555955695579558955995609561956295639564956595669567956895699570957195729573957495759576957795789579958095819582958395849585958695879588958995909591959295939594959595969597959895999600960196029603960496059606960796089609961096119612961396149615961696179618961996209621962296239624962596269627962896299630963196329633963496359636963796389639964096419642964396449645964696479648964996509651965296539654965596569657965896599660966196629663966496659666966796689669967096719672967396749675967696779678967996809681968296839684968596869687968896899690969196929693969496959696969796989699970097019702970397049705970697079708970997109711971297139714971597169717971897199720972197229723972497259726972797289729973097319732973397349735973697379738973997409741974297439744974597469747974897499750975197529753975497559756975797589759976097619762976397649765976697679768976997709771977297739774977597769777977897799780978197829783978497859786978797889789979097919792979397949795979697979798979998009801980298039804980598069807980898099810981198129813981498159816981798189819982098219822982398249825982698279828982998309831983298339834983598369837983898399840984198429843984498459846984798489849985098519852985398549855985698579858985998609861986298639864986598669867986898699870987198729873987498759876987798789879988098819882988398849885988698879888988998909891989298939894989598969897989898999900990199029903990499059906990799089909991099119912991399149915991699179918991999209921992299239924992599269927992899299930993199329933993499359936993799389939994099419942994399449945994699479948994999509951995299539954995599569957995899599960996199629963996499659966996799689969997099719972997399749975997699779978997999809981998299839984998599869987998899899990999199929993999499959996999799989999100001000110002100031000410005100061000710008100091001010011100121001310014100151001610017100181001910020100211002210023100241002510026100271002810029100301003110032100331003410035100361003710038100391004010041100421004310044100451004610047100481004910050100511005210053100541005510056100571005810059100601006110062100631006410065100661006710068100691007010071100721007310074100751007610077100781007910080100811008210083100841008510086100871008810089100901009110092100931009410095100961009710098100991010010101101021010310104101051010610107101081010910110101111011210113101141011510116101171011810119101201012110122101231012410125101261012710128101291013010131101321013310134101351013610137101381013910140101411014210143101441014510146101471014810149101501015110152101531015410155101561015710158101591016010161101621016310164101651016610167101681016910170101711017210173101741017510176101771017810179101801018110182101831018410185101861018710188101891019010191101921019310194101951019610197101981019910200102011020210203102041020510206102071020810209102101021110212102131021410215102161021710218102191022010221102221022310224102251022610227102281022910230102311023210233102341023510236102371023810239102401024110242102431024410245102461024710248102491025010251102521025310254102551025610257102581025910260102611026210263102641026510266102671026810269102701027110272102731027410275102761027710278102791028010281102821028310284102851028610287102881028910290102911029210293102941029510296102971029810299103001030110302103031030410305103061030710308103091031010311103121031310314103151031610317103181031910320103211032210323103241032510326103271032810329103301033110332103331033410335103361033710338103391034010341103421034310344103451034610347103481034910350103511035210353103541035510356103571035810359103601036110362103631036410365103661036710368103691037010371103721037310374103751037610377103781037910380103811038210383103841038510386103871038810389103901039110392103931039410395103961039710398103991040010401104021040310404104051040610407104081040910410104111041210413104141041510416104171041810419104201042110422104231042410425104261042710428104291043010431104321043310434104351043610437104381043910440104411044210443104441044510446104471044810449104501045110452104531045410455104561045710458104591046010461104621046310464104651046610467104681046910470104711047210473104741047510476104771047810479104801048110482104831048410485104861048710488104891049010491104921049310494104951049610497104981049910500105011050210503105041050510506105071050810509105101051110512105131051410515105161051710518105191052010521105221052310524105251052610527105281052910530105311053210533105341053510536105371053810539105401054110542105431054410545105461054710548105491055010551105521055310554105551055610557105581055910560105611056210563105641056510566105671056810569105701057110572105731057410575105761057710578105791058010581105821058310584105851058610587105881058910590105911059210593105941059510596 |
- /*! tiktok-auto v1.0.0
- * built 2026-11-01T06:48:56.247Z
- * 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
- * 由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。
- */
- (function () {
- var TikTokAuto = {};
- /* ============================== src/logger.js ============================== */
- /**
- * logger.js — 结构化日志
- *
- * 为什么单独成模块:设备脚本出错时,唯一能依赖的就是回传的日志。
- * 把日志格式固定下来(`[TTA][步骤名] 内容`),PC 侧就能按前缀过滤、按步骤计时,
- * 而不是靠人肉读一堆散乱的 console.log。
- *
- * 2026-10-13 增加**文件落盘通道**(sink):界面侧的「运行记录 → 详情的执行日志」
- * 要看的就是这些行。之所以不做成"界面去抓 console",是因为
- * console 是另一个进程的 stdout,界面根本读不到(见 bridge.js 的通道说明)。
- * sink 由 bridge 在启用宿主模式时装上,**独立运行时为空 → 零开销**。
- *
- * 依赖:无。始终通过 TikTokAuto.logger 取用,因此可以被放在 concat 顺序的最前面。
- */
- TikTokAuto.logger = (function () {
- 'use strict';
- var PREFIX = '[TTA]';
- var startedAt = Date.now();
- /** 文件落盘回调,由 bridge 注入:function(level, step, message) */
- var sink = null;
- function stamp() {
- return ('+' + (Date.now() - startedAt) + 'ms');
- }
- function write(level, step, message) {
- var line = PREFIX + stamp() + '[' + level + '][' + step + '] ' + message;
- console.log(line);
- if (sink) {
- /* 落盘失败绝不能影响流程:日志是"顺带记录的",不是"必须成功的"。 */
- try {
- sink(level, step, message);
- } catch (ignored) {
- /* 刻意吞掉:sink 抛错会把一次正常的日志调用变成流程中断 */
- }
- }
- }
- return {
- /** 装上文件落盘通道(传 null 卸载) */
- setSink: function (fn) {
- sink = typeof fn === 'function' ? fn : null;
- },
- /** 记录一次进入某步骤 */
- step: function (name, message) {
- write('STEP', name, message === undefined ? '' : message);
- },
- info: function (step, message) {
- write('INFO', step, message);
- },
- /** 可恢复的问题:值得记下来,但不中断流程 */
- warn: function (step, message) {
- write('WARN', step, message);
- },
- error: function (step, message) {
- write('ERROR', step, message);
- },
- /** 机器可读的结构化结果,PC 侧用它做断言 */
- result: function (payload) {
- console.log(PREFIX + 'RESULT ' + JSON.stringify(payload));
- },
- /** 从脚本开始到现在的耗时,用于定位"哪一步慢" */
- elapsedMs: function () {
- return Date.now() - startedAt;
- }
- };
- }());
- /* ============================== src/protocol.js ============================== */
- /* ===========================================================================
- * protocol.js —— 界面 ↔ 引擎 的运行期协议(唯一事实来源)
- *
- * 这个文件解决的是"两边各写一套枚举、靠魔法数字猜死活"的老问题。
- * 它只放**纯数据 + 纯判定**:不读文件、不写文件、不起线程、不看时间。
- * 所以界面侧与引擎侧可以各存一份完整拷贝,行为完全一致。
- *
- * ★ 两份拷贝的纪律:`tiktok-app/src/protocol.js` 与
- * `tiktok-auto/src/protocol.js` **除第 1 行(挂载点)外必须逐字节相同**。
- * 界面侧是 `var Protocol = …`,引擎侧是 `TikTokAuto.protocol = …`。
- * `tiktok-app/tools/protocol-check.js` 会剥掉第 1 行后逐字节比对,
- * 任何一边单独改都会立刻被抓出来。
- *
- * ★ 为什么状态判定要放在这里而不是各自实现:
- * `isTerminalStatus` 曾经只存在于界面(engine.js),引擎侧自己写
- * `'finished' / 'stopped' / 'failed'` 三元推导(main.js)。两边漏改一个
- * 就会出现"引擎以为结束了、界面还在等"这种零报错的悬挂。
- * =========================================================================== */
- TikTokAuto.protocol = (function () {
- 'use strict';
- /** 协议版本。加字段不改语义不用抬;改语义 / 改判定规则必须抬。 */
- var VERSION = 2;
- /* ------------------------------------------------------------ 状态枚举 */
- var STATUS = {
- STARTING: 'starting',
- RUNNING: 'running',
- STOPPING: 'stopping',
- FINISHED: 'finished',
- STOPPED: 'stopped',
- FAILED: 'failed'
- };
- /** 终态:到了这里就没有出边(再回到活跃态一律算 regress)。 */
- var TERMINAL = { finished: true, stopped: true, failed: true };
- /** 活跃态:运行条要显示、判活逻辑要关心的三档。 */
- var ACTIVE = { starting: true, running: true, stopping: true };
- /**
- * 合法迁移表:from → 允许去的 to。
- *
- * 空串代表"还没有状态"(本轮的第一次上报)。
- * `starting` 的出边特别宽:配置无效会在启动瞬间直接 failed,
- * 极短任务可能 starting 之后直接终态,这两种都是真实的。
- */
- var EDGES = {
- '': { starting: true },
- starting: {
- starting: true,
- running: true,
- stopping: true,
- finished: true,
- stopped: true,
- failed: true
- },
- running: {
- running: true,
- stopping: true,
- finished: true,
- stopped: true,
- failed: true
- },
- stopping: {
- stopping: true,
- finished: true,
- stopped: true,
- failed: true
- },
- finished: {},
- stopped: {},
- failed: {}
- };
- /* ------------------------------------------------------------ 阈值 */
- /**
- * 所有超时都在这里,界面侧从 `Protocol.SPEC.thresholds` 取,
- * 引擎侧心跳间隔也从这里取 —— 不允许在业务代码里再写字面量。
- *
- * 语义补充(光看名字看不出来的):
- * · beatFreshMs 只用于"加速判死"(见界面 pollEngine),**不产生文案**
- * · startAckSoftMs 只是日志与文案降级(「启动中(较慢)」),不改状态
- * · stopAckMs / stopFinishMs 是两段:先等"看见了停止请求",再等"真的收完尾"
- * · legacy* 是无心跳(v1)引擎的回退值,就是改造前在用的数字
- */
- var THRESHOLDS = {
- /* 心跳 */
- beatIntervalMs: 2000,
- beatFreshMs: 8000,
- beatDeadMs: 15000,
- /* 启动握手 */
- startAckSoftMs: 6000,
- startAckMaxMs: 15000,
- /* 停止两段 */
- stopAckMs: 3000,
- stopFinishMs: 10000,
- /* v1 回退组(老引擎 / 心跳线程起不来) */
- legacyChildGraceMs: 4000,
- legacyStopTimeoutMs: 8000,
- legacyNoBeatDeadMs: 30000
- };
- var SPEC = {
- version: VERSION,
- status: STATUS,
- terminal: TERMINAL,
- active: ACTIVE,
- edges: EDGES,
- thresholds: THRESHOLDS
- };
- /* ------------------------------------------------------------ 工具 */
- /* 单文件拼接后所有模块共享一个作用域,而 `Object.prototype.hasOwnProperty`
- * 的 call 形式会命中引擎侧构建黑名单里的那种写法(连注释都会被扫到),
- * 所以这里统一走 apply。 */
- function owns(object, key) {
- return Object.prototype.hasOwnProperty.apply(object, [key]) === true;
- }
- /** 把任意输入变成字符串状态;null / undefined → 空串(= 还没有状态)。 */
- function statusOf(value) {
- if (value === undefined || value === null) {
- return '';
- }
- return String(value);
- }
- /* ------------------------------------------------------------ 判定 */
- /**
- * 是否终态。
- * ★ 未知状态一律返回 false —— 界面绝不能因为读到一个不认识的 status
- * 就把正在跑的运行结束掉(前向兼容第一条)。
- */
- function isTerminal(status) {
- var key = statusOf(status);
- return owns(TERMINAL, key) === true && TERMINAL[key] === true;
- }
- /** 是否活跃态(运行条要显示的那三档)。未知状态同样返回 false。 */
- function isActive(status) {
- var key = statusOf(status);
- return owns(ACTIVE, key) === true && ACTIVE[key] === true;
- }
- /**
- * 三分类,给日志和排障用。
- * @returns 'active' | 'terminal' | 'unknown'
- */
- function levelOf(status) {
- var key = statusOf(status);
- if (isTerminal(key)) {
- return 'terminal';
- }
- if (isActive(key)) {
- return 'active';
- }
- return 'unknown';
- }
- /**
- * 迁移合法性。
- *
- * @returns 'ok' 表里有的合法迁移
- * 'idempotent' 原地重复上报(引擎允许重复写同一个状态)
- * 'regress' 从终态往外走 —— **必须拒绝**
- * 'unknown' 表里没有的组合,多半是前向兼容来的新状态,
- * 调用方应当"放宽"(不据此结束运行),
- * 只在日志里留一句
- */
- function legality(from, to) {
- var a = statusOf(from);
- var b = statusOf(to);
- if (a === b) {
- return 'idempotent';
- }
- if (isTerminal(a)) {
- return 'regress';
- }
- if (owns(EDGES, a) === true && owns(EDGES[a], b) === true) {
- return 'ok';
- }
- return 'unknown';
- }
- /* ------------------------------------------------------------ 导出 */
- return {
- VERSION: VERSION,
- STATUS: STATUS,
- SPEC: SPEC,
- statusOf: statusOf,
- isTerminal: isTerminal,
- isActive: isActive,
- levelOf: levelOf,
- legality: legality
- };
- })();
- /* ============================== src/bridge.js ============================== */
- /**
- * bridge.js — 与「界面宿主」之间的桥
- *
- * 为什么需要它:这个脚本有两种运行方式——
- * ① 独立运行(PC 侧下发、或直接打开产物):自己读配置,跑完打印 `__END__`;
- * ② 被界面宿主唤起(`engines.execScriptFile`,见 tiktok-app):
- * 配置由宿主生成,宿主还要能看见**进度**、能**叫停**。
- *
- * 两种方式共用同一套流程,差别只在「上下文从哪来、状态往哪去」,所以抽成这个模块:
- * 流程代码只调 `bridge.report()` / `bridge.checkStop()`,不必知道自己在被谁驱动。
- * **未启用时两者都是空操作**,独立运行零开销、零行为变化。
- *
- * 通道全部走**文件**,不走共享内存 —— 宿主与子引擎是两个独立的 ScriptEngine,
- * 脚本级全局变量互不可见(实测:父脚本读不到子引擎里的任何对象)。
- * 文件是这里唯一可靠的通道。
- *
- * 四个约定文件(默认都在 /sdcard/tiktok_app/ 下):
- * run-config.json 宿主 → 引擎:界面收集的完整配置快照(人可读,非本模块读取)
- * engine-config.json 宿主 → 引擎:映射后的引擎配置(由 --config 交给 config.resolve)
- * run-state.json 引擎 → 宿主:进度与终态(本模块写)
- * stop.flag 宿主 → 引擎:停止请求(本模块只读)
- *
- * ★ stop.flag 里放的是**本次运行的 runId**,不是空文件、也不是布尔。
- * 好处一:不必删除它(`files.remove` 命中 MCP 危险调用黑名单;删除本身也有竞态)。
- * 好处二:上一轮残留的标志不会误杀本轮 —— runId 不同,自然不匹配。
- *
- * 依赖:TikTokAuto.logger(调用时取,避免 concat 顺序耦合)
- */
- TikTokAuto.bridge = (function () {
- 'use strict';
- var DEFAULT_DIR = '/sdcard/tiktok_app';
- var DEFAULT_STATE = DEFAULT_DIR + '/run-state.json';
- var DEFAULT_STOP_FLAG = DEFAULT_DIR + '/stop.flag';
- var DEFAULT_LOG = DEFAULT_DIR + '/run-log.jsonl';
- /* 心跳:独立文件、独立写者。和 run-state 分开是因为后者由主流程按条目写,
- * 两个写者写同一个文件必然互相覆盖,而且频率差一个数量级。 */
- var DEFAULT_BEAT = DEFAULT_DIR + '/engine-beat.json';
- /**
- * 检查停止标志的最小间隔。
- * 每次 tick(默认 150ms)都去 stat 一次文件是没必要的高频 IO;
- * 500ms 的响应延迟对人来说与"立刻"无异。
- */
- var STOP_POLL_MIN_MS = 500;
- /** 心跳间隔的兜底值:protocol 模块没加载时也能跑(此时 v=0,宿主会走 v1 回退)。 */
- var BEAT_INTERVAL_FALLBACK_MS = 2000;
- var state = {
- enabled: false,
- runId: '',
- statePath: DEFAULT_STATE,
- stopFlagPath: DEFAULT_STOP_FLAG,
- logPath: DEFAULT_LOG,
- beatPath: DEFAULT_BEAT,
- lastStopCheckMs: 0,
- /* 本轮开始的时刻:宿主拿它算"启动握手超时",与 run-state.updatedAt 无关 */
- startedAt: 0,
- /* run-state 的写入序号,单调递增。宿主据此判断"同一毫秒内的两次写" */
- seq: 0
- };
- var last = null;
- /**
- * 心跳线程的运行态。
- *
- * ★ 三条硬规则(实现时踩过,写在这里防止被"优化"掉):
- * ① **不得调用 checkStop()** —— 它会在命中时 report(stopping) 并抛 StopSignal,
- * 抛进心跳线程就让"收尾那 10 秒"再也没有人写心跳,而那正是最需要它的时候。
- * ② **不得调用 report()** —— report 的 `last` 是跨线程共享的可变对象、整份覆盖写,
- * 心跳借道会把 phase 写回旧值。
- * ③ **用裸 sleep,不用 wait.tick()** —— 理由同 ①。
- * `wait.js` 头注释说"裸 sleep 只允许出现在本文件",这里是受控例外。
- */
- var beat = {
- thread: null,
- running: false,
- seq: 0,
- /* 看见停止请求的时刻。★ 一旦写入就**不再归零**(sticky):
- * 心跳是整份覆盖写,如果每次都从 0 开始,2 秒后就把 ack 抹掉了,
- * 而宿主是 1 Hz 轮询 —— 有概率整整错过一拍。 */
- seenAt: 0
- };
- /**
- * 「用户要求停止」。**刻意用异常抛出**:
- * 停止请求可能发生在任意深度的等待里(等页面就绪、等播放开始、等结果卡片……),
- * 逐层返回码需要改动几十处调用点且极容易漏掉一处就变成"点了停止但不停"。
- * 抛出后由 main 统一兜住,语义明确(名字里带 Signal,不是 Error 那种"出错了")。
- */
- function StopSignal() {
- this.name = 'StopSignal';
- this.message = '收到停止请求';
- this.stack = new Error(this.message).stack;
- }
- StopSignal.prototype = new Error();
- StopSignal.prototype.constructor = StopSignal;
- /**
- * 判断自有属性。
- * 刻意写成 `.apply(...)` 而不是「属性名 + 点 + call」的形式——见 config.js 里的说明:
- * MCP 桥的危险调用黑名单会按词边界匹配「call + 左括号」,`.apply` 语义等价却不会被误判。
- */
- function owns(object, key) {
- return Object.prototype.hasOwnProperty.apply(object, [key]) === true;
- }
- function isStopSignal(error) {
- return !!(error && error.name === 'StopSignal');
- }
- /**
- * 取引擎启动参数。
- * Rhino 会把传进来的数组包装成 `{"0":..,"1":..}` 这种对象(实测),
- * 所以既接受真数组,也按数字下标收集一份。
- */
- function readExecArgv() {
- var list = [];
- try {
- var argv = engines.myEngine().execArgv;
- if (argv instanceof Array) {
- return argv;
- }
- for (var i = 0; argv && argv[i] !== undefined; i += 1) {
- list.push(argv[i]);
- }
- } catch (ignored) {
- // 独立运行时 execArgv 为空/不可用,按"没有参数"处理
- }
- return list;
- }
- /** 解析 `--key value` 形式的参数(不支持 `--key=value`,保持单一写法) */
- function parseArgs(argv) {
- var options = {};
- for (var i = 0; i < argv.length; i += 1) {
- var token = String(argv[i]);
- if (token === '--config') {
- options.configPath = argv[i + 1];
- i += 1;
- } else if (token === '--state') {
- options.statePath = argv[i + 1];
- i += 1;
- } else if (token === '--stop-flag') {
- options.stopFlagPath = argv[i + 1];
- i += 1;
- } else if (token === '--run-id') {
- options.runId = argv[i + 1];
- i += 1;
- } else if (token === '--log') {
- options.logPath = argv[i + 1];
- i += 1;
- } else if (token === '--resume') {
- /* 裸开关,不取值:断点续跑由界面点「继续」时带上 */
- options.resume = true;
- } else if (token === '--checkpoint') {
- options.checkpointPath = argv[i + 1];
- i += 1;
- }
- }
- return options;
- }
- /**
- * 入口用:把命令行参数翻译成「配置覆盖项 + 桥的开关」。
- *
- * @param base 构建时注入的配置(可为 null)
- * @returns 应当交给 main.run 的注入项
- */
- function fromExecArgv(base) {
- var injected = base || {};
- var options = parseArgs(readExecArgv());
- if (options.runId) {
- enable(options);
- }
- if (options.configPath) {
- injected.runtime = injected.runtime || {};
- injected.runtime.configPath = options.configPath;
- }
- /* 续跑开关只改 `id.resume`,**不动 sources** ——
- * `merge` 是深合并,所以不会把配置里的取材清单覆盖掉。 */
- if (options.resume) {
- injected.id = injected.id || {};
- injected.id.resume = injected.id.resume || {};
- injected.id.resume.enabled = true;
- if (options.checkpointPath) {
- injected.id.resume.path = options.checkpointPath;
- }
- }
- return injected;
- }
- /** 本次运行的 runId(断点里要记下"这是哪次运行写的",界面靠它把记录与断点对上) */
- function currentRunId() {
- return state.runId || '';
- }
- /**
- * 日志落盘(界面侧「执行日志」的来源)。
- *
- * 一行一条 JSON(jsonl):界面按行解析,坏行跳过即可 ——
- * 比整份 JSON 更抗"写着写着被杀进程"(半行只丢一行,不会让整份文件不可读)。
- * 用 append 而不是重写:日志是**流**,每写一次整文件会让长流程变成 O(n²) 的 IO。
- */
- function appendLog(level, step, message) {
- try {
- files.append(state.logPath,
- JSON.stringify({
- t: Date.now(),
- level: level,
- step: step,
- text: (step ? '[' + step + '] ' : '') + message
- }) + '\n');
- } catch (error) {
- /* 落盘失败不该影响流程:logger 那边已经吞了一层,这里再兜一层 */
- }
- }
- /** 协议版本号。protocol 模块缺失时是 0 —— 宿主据此走 v1 回退,不会认错。 */
- function protocolVersion() {
- return TikTokAuto.protocol ? TikTokAuto.protocol.VERSION : 0;
- }
- function beatIntervalMs() {
- var spec = TikTokAuto.protocol ? TikTokAuto.protocol.SPEC : null;
- var ms = spec && spec.thresholds ? spec.thresholds.beatIntervalMs : 0;
- return ms > 0 ? ms : BEAT_INTERVAL_FALLBACK_MS;
- }
- /**
- * 写一拍心跳。
- *
- * 只做两件事:看一眼停止标志(记下"看见了")、整份重写心跳文件。
- * 不 report、不抛异常、不碰 run-state。
- */
- function heartbeatOnce() {
- var content = '';
- try {
- if (files.exists(state.stopFlagPath)) {
- content = String(files.read(state.stopFlagPath)).replace(/\s/g, '');
- }
- } catch (error) {
- /* 读不到就当"还没看见":下一拍再试,绝不因为一次读失败就清掉 seenAt */
- content = '';
- }
- if (content !== '' && content === state.runId) {
- /* sticky:只在第一次记时刻 */
- if (beat.seenAt === 0) {
- beat.seenAt = Date.now();
- }
- }
- beat.seq += 1;
- try {
- files.write(state.beatPath, JSON.stringify({
- v: protocolVersion(),
- runId: state.runId,
- seq: beat.seq,
- beatAt: Date.now(),
- stopSeenAt: beat.seenAt
- }));
- } catch (error) {
- /* 心跳写不进去不该影响流程:宿主那边会表现为"心跳过期",
- * 它有自己的兜底(判死 → 结束运行),不需要引擎来救 */
- }
- }
- /**
- * 起心跳线程。
- * @returns 是否真的起来了 —— 起不来时把它写进 capabilities,宿主会退回 v1 阈值
- */
- function heartbeatStart() {
- if (!state.enabled) {
- return false;
- }
- if (beat.running) {
- return true;
- }
- beat.running = true;
- beat.seq = 0;
- beat.seenAt = 0;
- /* 先同步写一拍:宿主在启动握手阶段就有东西可读,不用等 2 秒 */
- heartbeatOnce();
- var interval = beatIntervalMs();
- try {
- beat.thread = threads.start(function () {
- while (beat.running) {
- /* 先睡再写:开场那一拍上面已经写过,避免同一毫秒写两次 */
- sleep(interval);
- if (!beat.running) {
- break;
- }
- heartbeatOnce();
- }
- });
- } catch (error) {
- beat.running = false;
- beat.thread = null;
- log('warn', '心跳线程启动失败,宿主将退回无心跳判定:' + error);
- return false;
- }
- return true;
- }
- /** 停心跳。只是放下旗子,线程自己在下一拍前后退出(不强制中断)。 */
- function heartbeatStop() {
- beat.running = false;
- beat.thread = null;
- }
- function isHeartbeatUp() {
- return beat.running === true;
- }
- /** 打开桥。没有 runId 时**不启用**——runId 是停止标志比对的依据,缺了它无法判定是谁的请求。 */
- function enable(options) {
- state.enabled = true;
- state.runId = String(options.runId);
- state.statePath = options.statePath || DEFAULT_STATE;
- state.stopFlagPath = options.stopFlagPath || DEFAULT_STOP_FLAG;
- state.logPath = options.logPath || DEFAULT_LOG;
- state.beatPath = options.beatPath || DEFAULT_BEAT;
- state.lastStopCheckMs = 0;
- state.startedAt = Date.now();
- state.seq = 0;
- /* ★ 清空上一轮的日志:界面在结束时整份读走,
- * 不清的话 D3 上会混进上一次运行的行(而且越跑越长)。 */
- try {
- files.write(state.logPath, '');
- } catch (error) {
- /* 清不掉也不致命:界面会按 t 只取本轮时间窗内的行 */
- }
- if (TikTokAuto.logger && TikTokAuto.logger.setSink) {
- TikTokAuto.logger.setSink(appendLog);
- }
- /* ★ 顺序:先起心跳、拿结果,再写第一次状态。
- * 反过来会先告诉宿主"有心跳"再改成"没有",宿主可能在那两秒里按心跳判活,
- * 而实际上根本没有心跳线程。 */
- var heartbeatUp = heartbeatStart();
- last = {
- v: protocolVersion(),
- runId: state.runId,
- status: 'starting',
- phase: '',
- done: 0,
- total: 0,
- seq: 0,
- startedAt: state.startedAt,
- updatedAt: 0,
- /* task 此刻还不知道(要等 config.resolve),main 会调 setTask 补上 */
- engine: { task: '', capabilities: { heartbeat: heartbeatUp } }
- };
- report({});
- log('info', '已连接到界面宿主,runId=' + state.runId +
- ' / 心跳=' + (heartbeatUp ? '开' : '关'));
- }
- /**
- * 告诉宿主"这次跑的是哪个任务"。
- * 只是补一个字段并立刻落盘一次:宿主用它做排障展示,
- * 也让"引擎起来了但配置是错的"这种情况在状态里看得见。
- */
- function setTask(task) {
- if (!state.enabled || !last) {
- return;
- }
- if (!last.engine) {
- last.engine = { task: '', capabilities: { heartbeat: isHeartbeatUp() } };
- }
- last.engine.task = String(task || '');
- report({});
- }
- function isEnabled() {
- return state.enabled;
- }
- /**
- * 写一次状态。宿主每秒读一次这个文件。
- * 失败只记 WARN:状态上报失败不该让正在跑的流程崩掉。
- */
- function report(patch) {
- if (!state.enabled || !last) {
- return;
- }
- for (var key in patch) {
- if (owns(patch, key)) {
- last[key] = patch[key];
- }
- }
- state.seq += 1;
- last.v = protocolVersion();
- last.seq = state.seq;
- last.startedAt = state.startedAt;
- last.runId = state.runId;
- last.updatedAt = Date.now();
- try {
- files.write(state.statePath, JSON.stringify(last));
- } catch (error) {
- log('warn', '状态上报失败:' + error);
- }
- }
- /** 写终态。宿主靠它结束「运行中」并弹出结果。 */
- function finish(status, summary) {
- if (!state.enabled) {
- return;
- }
- var patch = {
- status: status,
- phase: '',
- summary: summary || null,
- message: describe(status, summary)
- };
- /* ID 提取的 `done` 是"已收个数",而最后一次 `report` 往往发生在收下一个人之前 ——
- * 于是终态里 done 会比实际少一个(实测 11 个却报 10)。
- * 收尾时按 summary 校正一次,免得界面/记录上的数字自相矛盾。 */
- if (summary && typeof summary.extractedTotal === 'number') {
- /* 续跑时 extracted 只是"本轮新增",累计数才和界面上的进度一致 */
- patch.done = summary.extractedTotal;
- } else if (summary && typeof summary.extracted === 'number') {
- patch.done = summary.extracted;
- }
- report(patch);
- log('info', '终态已上报:' + status);
- }
- /**
- * 养号的三个动作计数拼成一句话。
- * 只写"真的做了"的个数(跳过的不算),否则用户会以为自己点了 N 个赞、
- * 实际全是"本来就已经赞过"。
- */
- function nurtureActions(summary) {
- return '点赞 ' + summary.liked + ' / 关注 ' + summary.followed +
- ' / 评论 ' + summary.commented;
- }
- /** 给界面看的一句人话,不要求界面自己去解析 summary 结构 */
- function describe(status, summary) {
- /* ID 提取与视频链路的 summary 形状不同(提取个数 vs 视频个数),
- * 但界面只有"一句人话"这一个位置 —— 所以在这里分支,让两条链路都说得清楚。
- * 判据是 `extracted` 字段是否存在,与 task 解耦(summary 自己就带得动语义)。 */
- var isIdTask = summary && typeof summary.extracted === 'number';
- /* 养号有自己的形状(轮数 + 三个动作计数),既不是"提取 N 个"也不是"处理 N 个视频"。
- * 判据用 summary 自己带的 task 字段,不靠调用方传——这样以后加任务不用再动这里。 */
- var isNurture = summary && summary.task === 'nurture' && typeof summary.rounds === 'number';
- /* 私信:关注与私信是两个不同的动作,要分开说 */
- var isDmTask = summary && summary.task === 'dm' && typeof summary.sent === 'number';
- var dmTail = function () {
- var tail = '';
- if (summary.skipped > 0) { tail += ',跳过 ' + summary.skipped + ' 个'; }
- if (summary.failed > 0) { tail += ',失败 ' + summary.failed + ' 个'; }
- if (summary.dryRun) { tail += '(演练 ' + (summary.dryRunCount || 0) + ' 条,未真发)'; }
- /* 人是哪儿来的要说清:脚本二跑完只说"关注 3 个/私信 2 条",
- * 用户没法确认它到底是不是从自己的粉丝里取的人。 */
- if (summary.sourceKind === 'ownFans') {
- tail += '(取材自你自己的粉丝列表,' + (summary.totalUids || 0) + ' 人)';
- }
- return tail;
- };
- /* 续跑时"数字"要给累计口径,并明确说这是续跑 ——
- * 否则用户看到"提取 5 个"会以为之前那 37 个丢了。 */
- var total = summary && typeof summary.extractedTotal === 'number'
- ? summary.extractedTotal
- : (summary ? summary.extracted : 0);
- var resumedTag = summary && summary.resumed ? '(续跑)' : '';
- var thisRound = summary && summary.resumed && summary.extracted !== total
- ? ',本轮新增 ' + summary.extracted + ' 个' : '';
- if (status === 'stopped') {
- if (isDmTask) {
- return '已停止:关注 ' + summary.followed + ' 个 / 私信 ' +
- summary.sent + ' 条' + dmTail();
- }
- if (isNurture) {
- return '已停止:浏览 ' + summary.rounds + ' 轮 / ' + nurtureActions(summary);
- }
- return isIdTask
- ? '已停止' + resumedTag + ':累计 ' + total + ' 个 ID' + thisRound +
- '(可点「继续」接着跑)'
- : '已停止';
- }
- if (status === 'finished') {
- if (isDmTask) {
- return '完成:关注 ' + summary.followed + ' 个 / 私信 ' +
- summary.sent + ' 条' + dmTail();
- }
- if (isNurture) {
- /* 时长先到要说出来 —— 用户下一步该调的是「浏览时长」而不是别的 */
- var why = summary.stoppedBy === 'time' ? '(时长先到,轮数没用满)' : '';
- return '完成:浏览 ' + summary.rounds + ' 轮 / ' +
- nurtureActions(summary) + why;
- }
- if (isIdTask) {
- var tail = summary.skipped > 0 ? ',按条件跳过 ' + summary.skipped + ' 个' : '';
- /* 生成了 uid 映射就顺带说一句 —— 用户不一定知道还有第二个文件 */
- var mapped = summary.mapFile ? '(已生成 uid 映射)' : '';
- return '完成' + resumedTag + ':提取 ' + total + ' 个 ID' + thisRound + tail + mapped;
- }
- var watched = summary ? summary.attemptedVideos : 0;
- var posted = summary ? summary.postedComments : 0;
- /* 只写"真的做了"的:跳过(已赞/已关注)不计入,否则用户会以为
- * 自己点了 N 个赞,实际全是"本来就已经赞过"。 */
- var liked = summary && summary.likedVideos ? summary.likedVideos : 0;
- var followed = summary && summary.followedAuthors ? summary.followedAuthors : 0;
- var actions = [];
- if (liked > 0 || (summary && summary.likesRequested)) {
- actions.push('点赞 ' + liked);
- }
- if (followed > 0 || (summary && summary.followsRequested)) {
- actions.push('关注 ' + followed);
- }
- actions.push('评论 ' + posted);
- return '完成:处理 ' + watched + ' 个视频 / ' + actions.join(' / ');
- }
- var reason = summary && summary.errors && summary.errors.length > 0
- ? summary.errors[0]
- : '未知原因';
- return '未完成:' + reason;
- }
- /**
- * 停止检查。**由唯一的等待原语 `wait.tick` 调用**,
- * 于是所有长等待都能被打断,而流程代码一处都不用改。
- * 命中即抛 StopSignal。
- */
- function checkStop() {
- if (!state.enabled) {
- return;
- }
- var now = Date.now();
- if (now - state.lastStopCheckMs < STOP_POLL_MIN_MS) {
- return;
- }
- state.lastStopCheckMs = now;
- var content = '';
- try {
- if (!files.exists(state.stopFlagPath)) {
- return;
- }
- content = String(files.read(state.stopFlagPath)).replace(/\s/g, '');
- } catch (error) {
- log('warn', '读取停止标志失败:' + error);
- return;
- }
- if (content !== '' && content === state.runId) {
- report({ status: 'stopping', phase: '正在收尾', message: '收到停止请求,正在收尾' });
- log('warn', '收到停止请求(runId 匹配),中断当前等待');
- throw new StopSignal();
- }
- }
- function log(level, message) {
- if (TikTokAuto.logger && TikTokAuto.logger[level]) {
- TikTokAuto.logger[level]('bridge', message);
- }
- }
- return {
- DEFAULT_DIR: DEFAULT_DIR,
- DEFAULT_STATE: DEFAULT_STATE,
- DEFAULT_STOP_FLAG: DEFAULT_STOP_FLAG,
- DEFAULT_LOG: DEFAULT_LOG,
- DEFAULT_BEAT: DEFAULT_BEAT,
- StopSignal: StopSignal,
- isStopSignal: isStopSignal,
- readExecArgv: readExecArgv,
- parseArgs: parseArgs,
- fromExecArgv: fromExecArgv,
- isEnabled: isEnabled,
- checkStop: checkStop,
- currentRunId: currentRunId,
- protocolVersion: protocolVersion,
- setTask: setTask,
- heartbeatStart: heartbeatStart,
- heartbeatStop: heartbeatStop,
- isHeartbeatUp: isHeartbeatUp,
- /* 单独导出"写一拍":PC 自检要能在不真起线程的情况下驱动心跳,
- * 排障时也想手动补一拍。业务代码**不应该**调它(会打乱 seq 的含义)。 */
- heartbeatOnce: heartbeatOnce,
- report: report,
- finish: finish,
- describe: describe,
- appendLog: appendLog
- };
- }());
- /* ============================== src/config.js ============================== */
- /**
- * config.js — 配置的默认值、设备端加载、合并与校验
- *
- * 设计依据(Zakas《编写可维护的JavaScript》第 9 章「使用配置对象」):
- * 所有"会变的业务数据"——搜索关键词、评论文案、观看时长、超时阈值、开关——
- * 都集中在配置文件里;代码只负责读,不负责写死。
- * 于是**改文案/改时长不需要动代码**,也不需要重新构建。
- *
- * 配置来源优先级(后者覆盖前者):
- * 1. DEFAULTS(本文件,保证"没有配置文件也能跑")
- * 2. 设备上的 JSON 文件(默认 /sdcard/Download/tiktok-auto/config.json)
- * 3. 运行时注入的 payload(PC 侧下发脚本时携带,便于免推送做实验)
- *
- * 依赖:TikTokAuto.logger
- */
- TikTokAuto.config = (function () {
- 'use strict';
- var DEFAULT_DEVICE_PATH = '/sdcard/Download/tiktok-auto/config.json';
- var DEFAULTS = {
- /**
- * 任务类型。缺省 'video' 是**刻意的向后兼容**:
- * 老配置(PC 侧下发、设备上残留的 config.json)里没有这个字段,
- * 它们本来就是视频链路的,所以默认值必须还是 video,
- * 否则一次升级会把所有老用法打成"未知任务"。
- */
- task: 'video',
- target: {
- packageName: 'com.zhiliaoapp.musically',
- keyword: 'fashion',
- maxVideos: 1,
- resultTab: 'Top',
- pickStrategy: 'random'
- },
- /**
- * ID 提取(task = 'id')。形状与界面 `sources.js` 对齐:
- * `sources` 是**不定长清单**,每条自带起点与深度。
- */
- id: {
- sources: [],
- filter: {
- private: false,
- noAvatar: false,
- langRegion: 'any',
- nicknameCheck: 'any',
- follow: { min: 0, max: 0 },
- fans: { min: 0, max: 0 },
- likes: { min: 0, max: 0 },
- videos: { min: 0, max: 0 }
- },
- output: {
- dir: '/sdcard/Download/tiktok_app',
- historyPath: '/sdcard/Download/tiktok_app/uid_extracted.txt',
- dedupeHistory: true,
- dryRun: false,
- limit: 0,
- /**
- * 抓完之后是否把 username 换成 TikTok 的**数字 uid**,另存一份映射文件。
- *
- * 默认开:数字 uid 在 App 界面上任何地方都读不到,不在这里换就得事后另跑一次。
- * 每个约 2.5 秒(要下整页 SSR 约 388KB),所以只要 handle 的场景可以关掉省时间。
- */
- resolveUid: true
- },
- /**
- * 物理护栏。界面里 `limit = 0` 的语义是"不限",
- * 但真跑起来没有上限等于跑到没电 —— 所以引擎自己有个时长上限。
- * 到点时**如实报告**,不装作抓完了。
- */
- guard: {
- /** 抓取阶段最长多久(不含收尾) */
- maxRuntimeMs: 30 * 60 * 1000,
- /**
- * 解析数字 uid 阶段**自己的**预算。
- *
- * 为什么必须独立:抓取很容易把 `maxRuntimeMs` 用满
- * (实测 83 个候选 × 每人约 8 秒 = 30 分钟),
- * 而解析 uid 是**收尾工作**,恰恰在这之后才做。
- * 两者共用一个预算时,抓满 = 收尾一点时间都不剩 = 一个 uid 都换不到。
- */
- resolveMaxMs: 10 * 60 * 1000
- },
- /**
- * 断点续跑。默认**关**:只有界面明确点「继续」才会带 `--resume` 进来。
- *
- * 为什么不默认开:一份手写的 engine-config.json 如果默认就去读断点,
- * 会出现"我只想从头跑一次,结果它接着上次跑"这种反直觉行为。
- * 而且 `state:'none'`/`'done'` 时它本来也不会续 —— 但**默认关**更安全。
- *
- * path 留空则由 `output.dir` 派生(`checkpoint.pathOf`),
- * 这样用户改产物目录时断点跟着走,不会出现"产物在新目录、断点在旧目录"。
- */
- resume: {
- enabled: false,
- path: ''
- }
- },
- watch: {
- minMs: 10000,
- maxMs: 35000
- },
- /**
- * 点赞 / 关注:与 comments 平级的两个动作开关。
- *
- * 界面的「执行操作」是四项多选(关注 / 私信 / 点赞 / 评论),
- * 这四项各自映射到一个 enabled —— 用户没勾就不做,不能因为"顺手"就点上。
- *
- * 与 ID 提取里的 `id.filter.likes`(按获赞数量筛账号)**不是一回事**,
- * 只是名字撞了,层级不同。
- */
- likes: {
- enabled: true
- },
- follows: {
- enabled: true
- },
- comments: {
- /* 是否执行评论动作。关掉就是「只浏览不发评论」——
- * 界面把「执行操作」里的评论开关映射到这里:
- * 用户没勾评论就不该发,哪怕评论池配好了。 */
- enabled: true,
- pool: [
- 'Love this!',
- 'So pretty',
- 'Great style',
- 'This is amazing',
- 'Need this look'
- ],
- avoidRepeatWithinRun: true,
- strictPostCheck: false,
- verifyInList: true
- },
- /**
- * 养号(task = 'nurture'):在首页信息流里随机浏览 + 按概率互动。
- *
- * 与视频链路共用 `watch` / `comments` / `probe` 不够——养号有两处语义不同:
- * ① **没有目标**:视频链路是"搜关键词看 N 个",养号是"往下刷",
- * 所以没有 target.keyword / maxVideos,换成 rounds 与 browse 双约束
- * ② **单次观看时长要短**:视频链路为了"观看有效"默认 10~35 秒,
- * 养号模拟的是"划过去看一眼",5~25 秒之间随机更像真人
- *
- * 概率是**小数**(0.3 = 30%),界面那边是档位(r30),由 engine.js 换算。
- * 顶层 `comments` 段(文案池、是否校验)在这里同样生效——一次只跑一个任务,不会打架。
- */
- nurture: {
- /** 单次操作的**轮数**(界面「单次操作 10~30」)。与 browse 各自独立,谁先到谁停 */
- rounds: { min: 10, max: 30 },
- /** 浏览时长(界面「浏览时长 3~8 分钟」) */
- browse: { minMs: 3 * 60 * 1000, maxMs: 8 * 60 * 1000 },
- /** 单个视频看多久。养号是"划过去看一眼",不该按视频链路的 10~35 秒 */
- watch: { minMs: 5000, maxMs: 25000 },
- /** 做完一个动作后的随机停顿(真人不会点完立刻滑) */
- pause: { minMs: 800, maxMs: 2500 },
- likeRate: 0.30,
- commentRate: 0.10,
- followRate: 0.15
- },
- /**
- * UID 私信(task = 'dm')。
- *
- * 界面的「关注数量 100~200」「私信数量 20~30」是**两组独立额度**,
- * 作用在**两批完全不同的人**上:前 `follow.max` 个只关注,接下来
- * `send.max` 个只发私信。所以 `max` 是**确定的数字**(界面在区间里随机取一次
- * 再传下来),不是区间。
- *
- * 「休息时间」是**每处理完一个 UID 之后**的间隔,也在这里传确定的区间。
- */
- dm: {
- /**
- * 's1' = 已提 UID 私信(读文本文件)
- * 's2' = 自己粉丝私信(打开自己的主页 → 粉丝列表取材,不用 uidPath)
- */
- script: 's1',
- /** s1 的 UID 来源:每行一个,`@` 可有可无,`#` 开头算注释(s2 忽略) */
- uidPath: '/sdcard/Download/tiktok_app/uid_list.txt',
- follow: { enabled: true, max: 0 },
- send: { enabled: true, max: 0 },
- /** 每个 UID 之后休息多久(v9 起界面默认 10~40 秒) */
- rest: { minMs: 10000, maxMs: 40000 },
- /** 'follow_dm' | 'follow' | 'dm' —— 按什么去重(跨轮,读 dm_history.txt) */
- dedupeMode: 'follow_dm',
- /** 首视频筛选与首视频动作(脚本一的两个设置) */
- filterDate: '7d',
- firstAction: 'both',
- /** 私信文案池(界面「随机私信文本」文件里读出来的每一行) */
- pool: [],
- /** 评论文案池(首视频评论用,同样从文件读) */
- commentPool: [],
- /**
- * 物理护栏:时长到点就收尾,别指望"跑完自然停"。
- * 关注 150 + 私信 25,每个约 30~80 秒(搜索+进页+动作+休息),
- * 一轮 1.5~4 小时;给 5 小时上限兜住"UID 文件特别长"的情况。
- */
- guard: { maxRuntimeMs: 5 * 60 * 60 * 1000 }
- },
- timeouts: {
- appLaunchMs: 25000,
- pageReadyMs: 15000,
- /**
- * 搜索结果首屏 / 切页签重刷的等待上限。
- *
- * 2026-10-28 实测:关键词 `cat` 从点搜索到网格出现约 **18 秒**
- * (8 秒时截图还是空的,再等 10 秒才有卡)。原来 20 秒只剩 2 秒余量,
- * 网络稍慢就会判成"没有可用卡片"。放到 30 秒。
- */
- searchResultsMs: 30000,
- playerReadyMs: 20000,
- playbackStartMs: 12000,
- panelReadyMs: 8000,
- postMs: 15000,
- countRefreshMs: 6000
- },
- probe: {
- pollTickMs: 150,
- livenessIntervalMs: 2500,
- stallTolerance: 3,
- useAudio: true,
- usePixels: false
- },
- guard: {
- enabled: true,
- intervalMs: 1200,
- firstScanTimeoutMs: 4000,
- dismissLabels: [
- 'Not now', 'No thanks', 'Skip', 'Later', 'Cancel', 'Close',
- '稍后', '暂不', '取消', '关闭', '以后再说'
- ]
- },
- runtime: {
- configPath: DEFAULT_DEVICE_PATH,
- maxRuntimeMs: 0,
- dryRun: false,
- /* 本轮运行的标识。界面写进 engine-config.json,引擎把命令行里的
- * `--run-id` 拿来比对 —— 用来挡住"上一次的引擎迟到启动、
- * 却读到这一轮的配置并真的跑起来"。
- * 独立运行(没有宿主)时为 '',校验会自动跳过。 */
- runId: ''
- }
- };
- /** 配置错误:单独类型,便于上层区分"配置写错了"和"运行时炸了" */
- function ConfigError(message, detail) {
- this.name = 'ConfigError';
- this.message = message;
- this.detail = detail;
- this.stack = new Error(message).stack;
- }
- ConfigError.prototype = new Error();
- ConfigError.prototype.constructor = ConfigError;
- /**
- * 判断自有属性。
- *
- * 这里刻意写成 `.apply(...)` 而不是更常见的「属性名 + 点 + 名字为 call 的方法」那种形式。
- * 原因:MCP 桥会对下发的脚本做危险调用黑名单扫描,其中一条规则按词边界匹配
- * 「名字为 call 的方法 + 左括号」的片段。`.apply` 语义完全等价,却不会被误判成
- * 危险代码、从而让每次下发都多要一次人工确认。
- */
- function owns(object, key) {
- return Object.prototype.hasOwnProperty.apply(object, [key]) === true;
- }
- function isPlainObject(value) {
- return value !== null && typeof value === 'object' && !(value instanceof Array);
- }
- /** 深拷贝(Rhino 没有 Object.assign,也不该依赖它) */
- function clone(value) {
- if (value instanceof Array) {
- var list = [];
- for (var i = 0; i < value.length; i += 1) {
- list.push(clone(value[i]));
- }
- return list;
- }
- if (isPlainObject(value)) {
- var copy = {};
- for (var key in value) {
- if (owns(value, key)) {
- copy[key] = clone(value[key]);
- }
- }
- return copy;
- }
- return value;
- }
- /**
- * 深合并:对象逐键递归,数组整体替换。
- * 数组"整体替换"是刻意的——评论池这类配置如果被逐项合并,
- * 用户就永远删不掉默认评论了。
- */
- function merge(base, override) {
- if (!isPlainObject(override)) {
- return clone(override);
- }
- var result = clone(base);
- for (var key in override) {
- if (!owns(override, key)) {
- continue;
- }
- var value = override[key];
- if (isPlainObject(value) && isPlainObject(result[key])) {
- result[key] = merge(result[key], value);
- } else {
- result[key] = clone(value);
- }
- }
- return result;
- }
- function readDeviceJson(path) {
- var logger = TikTokAuto.logger;
- try {
- if (typeof files === 'undefined' || !files.exists(path)) {
- logger.info('config', '未找到配置文件,使用内置默认值:' + path);
- return null;
- }
- var raw = files.read(path);
- var parsed = JSON.parse(raw);
- logger.info('config', '已加载配置文件:' + path);
- return parsed;
- } catch (error) {
- // 配置写坏了必须让用户知道,但不应因此让整脚本无法启动——
- // 用默认值继续跑,同时把问题显著地抛到日志里。
- logger.error('config', '配置文件解析失败,改用默认值:' + error);
- return null;
- }
- }
- function validate(config) {
- /* —— ID 提取任务:校验的是"取材清单",不是搜索关键词 ——
- * 早先这个函数硬性要求 target.keyword 非空,那是视频链路的前提。
- * ID 提取可以不搜索(从粉丝列表、文本文件、当前屏幕取材),
- * 所以按 task 分支,而不是给关键词塞一个假默认值把校验糊过去。 */
- if (config.task === 'id') {
- if (!(config.id && config.id.sources instanceof Array)) {
- throw new ConfigError('id.sources 必须是数组(取材清单)', config.id && config.id.sources);
- }
- if (config.id.sources.length === 0) {
- throw new ConfigError('id.sources 不能为空,至少需要一条取材', 0);
- }
- var limit = Number(config.id.output && config.id.output.limit);
- if (limit < 0) {
- throw new ConfigError('id.output.limit 不能为负数', limit);
- }
- return config;
- }
- /* —— 养号任务:没有搜索目标,校验的是"轮数 / 时长 / 概率" ——
- * ★ 这一支必须放在 `var target = config.target` **之前**:
- * 下面那段会硬性要求 `target.keyword` 非空,那是视频链路的前提。
- * 养号根本不搜索,落到那里就会被判成"配置错误",而且报的是
- * "target.keyword 不能为空"这种让人完全摸不着头脑的原因。 */
- if (config.task === 'nurture') {
- var nurture = config.nurture || {};
- var rounds = nurture.rounds || {};
- var browse = nurture.browse || {};
- if (!(Number(rounds.min) >= 1)) {
- throw new ConfigError('nurture.rounds.min 必须 >= 1', rounds.min);
- }
- if (Number(rounds.max) < Number(rounds.min)) {
- throw new ConfigError('nurture.rounds.max 不能小于 rounds.min', rounds);
- }
- if (!(Number(browse.maxMs) > 0)) {
- throw new ConfigError('nurture.browse.maxMs 必须 > 0', browse.maxMs);
- }
- /* 要发评论就必须有文案 —— 养号页补了「评论文案文件」正是为了这件事。
- * 池子为空却把 commentRate 设成 10%,跑起来只会一轮轮地告警。 */
- if (config.comments && config.comments.enabled &&
- (!config.comments.pool || config.comments.pool.length === 0)) {
- throw new ConfigError('要发评论就必须有评论文案(comments.pool 为空)', 0);
- }
- return config;
- }
- /* —— UID 私信:没有搜索目标,校验的是"有没有活可干、数量是否合法" —— */
- if (config.task === 'dm') {
- var dm = config.dm || {};
- var followMax = Number(dm.follow && dm.follow.max);
- var sendMax = Number(dm.send && dm.send.max);
- var followOn = dm.follow && dm.follow.enabled === true;
- var sendOn = dm.send && dm.send.enabled === true;
- if (dm.script !== 's1' && dm.script !== 's2') {
- throw new ConfigError('dm.script 只能是 "s1" 或 "s2"', dm.script);
- }
- /* 脚本一靠外部文本取材,路径空着必然一个都读不到 —— 提前报,
- * 而不是先启动 App、跑到一半才发现"没有可用账号"。 */
- if (dm.script === 's1' && !dm.uidPath) {
- throw new ConfigError('脚本一必须指定 UID 文本(dm.uidPath 为空)', dm.uidPath);
- }
- if (!followOn && !sendOn) {
- throw new ConfigError('「进行关注」与「进行私信」都关着,没有可执行的动作',
- { follow: followOn, send: sendOn });
- }
- if (followOn && !(followMax >= 1)) {
- throw new ConfigError('dm.follow.max 必须 >= 1(开着关注却没有数量)', followMax);
- }
- if (sendOn && !(sendMax >= 1)) {
- throw new ConfigError('dm.send.max 必须 >= 1(开着私信却没有数量)', sendMax);
- }
- if (sendOn && (!dm.pool || dm.pool.length === 0)) {
- throw new ConfigError('要发私信就必须有文案(「随机私信文本」那个文件是空的)', 0);
- }
- if (!(Number(dm.rest && dm.rest.maxMs) > 0)) {
- throw new ConfigError('dm.rest.maxMs 必须 > 0', dm.rest && dm.rest.maxMs);
- }
- return config;
- }
- var target = config.target;
- if (!target.keyword || String(target.keyword).replace(/\s/g, '') === '') {
- throw new ConfigError('target.keyword 不能为空', target.keyword);
- }
- /* 「评论池为空」只有在**要发评论**时才算配置错误:
- * 只浏览不发评论是合法的一次运行(界面上没勾评论就是这种情形)。 */
- if (config.comments.enabled && config.comments.pool.length === 0) {
- throw new ConfigError('comments.pool 不能为空,至少需要一条评论文案', 0);
- }
- if (config.watch.minMs > config.watch.maxMs) {
- throw new ConfigError('watch.minMs 不能大于 watch.maxMs', {
- minMs: config.watch.minMs,
- maxMs: config.watch.maxMs
- });
- }
- if (config.target.maxVideos < 1) {
- throw new ConfigError('target.maxVideos 必须 >= 1', config.target.maxVideos);
- }
- return config;
- }
- /**
- * 配置与本次运行必须对得上。
- *
- * 挡的是这一种真实故障:上一轮的引擎因为冷编译慢、迟到启动,
- * 醒来时读到的已经是**这一轮**刚写的 engine-config —— 它照新配置跑起来,
- * 而界面以为这一轮还没起来(于是两份流程同时操作一台手机)。
- * 配置里绑一个 runId 就能让它当场收手。
- *
- * ★ 只在**双方都非空**时比较:独立运行时命令行没有 `--run-id`,
- * `bridge.currentRunId()` 是空串,此时必须放行,否则引擎永远跑不起来。
- */
- function assertRunIdMatches(config) {
- var wanted = config && config.runtime ? String(config.runtime.runId || '') : '';
- var current = '';
- if (TikTokAuto.bridge && TikTokAuto.bridge.currentRunId) {
- current = String(TikTokAuto.bridge.currentRunId() || '');
- }
- if (wanted === '' || current === '') {
- return;
- }
- if (wanted !== current) {
- throw new ConfigError('配置属于另一次运行(runId 不匹配)', {
- configRunId: wanted,
- engineRunId: current
- });
- }
- }
- /**
- * 解析最终生效的配置。
- *
- * @param injected PC 侧注入的覆盖项(可为 null)
- */
- function resolve(injected) {
- var logger = TikTokAuto.logger;
- var fromDefaults = clone(DEFAULTS);
- var path = (injected && injected.runtime && injected.runtime.configPath) || DEFAULTS.runtime.configPath;
- var fromDevice = readDeviceJson(path) || {};
- var config = merge(merge(fromDefaults, fromDevice), injected || {});
- validate(config);
- assertRunIdMatches(config);
- if (config.task === 'id') {
- logger.info('config', '生效配置(ID提取):' + JSON.stringify({
- sources: config.id.sources.length,
- filter: config.id.filter,
- limit: config.id.output.limit,
- dedupeHistory: config.id.output.dedupeHistory,
- dryRun: config.id.output.dryRun
- }));
- } else if (config.task === 'nurture') {
- /* 养号没有关键词,报 video 那套字段(keyword/maxVideos)等于什么都没说。
- * 排障时最需要的恰恰是"概率和轮数到底生效成了多少"。 */
- var nur = config.nurture;
- logger.info('config', '生效配置(养号):' + JSON.stringify({
- rounds: [nur.rounds.min, nur.rounds.max],
- browseMaxMin: Math.round(nur.browse.maxMs / 60000),
- watchMs: [nur.watch.minMs, nur.watch.maxMs],
- rates: {
- like: nur.likeRate,
- comment: nur.commentRate,
- follow: nur.followRate
- },
- commentEnabled: config.comments.enabled,
- commentCount: config.comments.pool.length,
- dryRun: config.runtime.dryRun
- }));
- } else if (config.task === 'dm') {
- /* 私信同样没有关键词,落到下面的 video 分支会打印
- * `{"keyword":"fashion",...}` —— 排障时完全误导(曾经真被这条日志带偏过,
- * 以为跑的是视频链路)。这里报"从哪儿取人、发多少、歇多久"。 */
- var dm = config.dm;
- logger.info('config', '生效配置(私信):' + JSON.stringify({
- script: dm.script,
- source: dm.script === 's2' ? '自己的粉丝列表'
- : ('UID 文本 ' + dm.uidPath),
- follow: dm.follow,
- send: dm.send,
- restSec: [Math.round(dm.rest.minMs / 1000), Math.round(dm.rest.maxMs / 1000)],
- dedupeMode: dm.dedupeMode,
- textCount: dm.pool.length,
- dryRun: config.runtime.dryRun
- }));
- } else {
- logger.info('config', '生效配置:' + JSON.stringify({
- keyword: config.target.keyword,
- maxVideos: config.target.maxVideos,
- resultTab: config.target.resultTab,
- watchMs: [config.watch.minMs, config.watch.maxMs],
- commentEnabled: config.comments.enabled,
- commentCount: config.comments.pool.length,
- dryRun: config.runtime.dryRun
- }));
- }
- return config;
- }
- return {
- DEFAULTS: DEFAULTS,
- DEFAULT_DEVICE_PATH: DEFAULT_DEVICE_PATH,
- readDeviceJson: readDeviceJson,
- merge: merge,
- resolve: resolve,
- ConfigError: ConfigError
- };
- }());
- /* ============================== src/wait.js ============================== */
- /**
- * wait.js — 唯一的等待原语
- *
- * 核心主张:**脚本里不允许出现裸 sleep 当作"等待"**。
- *
- * tick(轮询节拍) = "多久检查一次" → 允许存在,默认 150ms
- * sleep(盲等) = "赌它 X 秒后会好" → 禁止用作等待手段
- *
- * 为什么这是稳定性的关键:网络抖动时页面可能 300ms 才渲染,也可能 8s 才渲染。
- * 写 `sleep(2000)` 的脚本在快的时候白等 1.7s,在慢的时候直接失败;
- * 而 `waitFor(条件, 超时)` 在快的时候立刻返回,慢的时候一直等到条件成立。
- * 这把"网络有多慢"从"脚本能不能跑通"里彻底解耦。
- *
- * 依赖:TikTokAuto.logger(调用时取,避免 concat 顺序耦合)
- */
- TikTokAuto.wait = (function () {
- 'use strict';
- var DEFAULT_TICK_MS = 150;
- function now() {
- return Date.now();
- }
- /**
- * 轮询节拍。
- *
- * ★ 这里是**唯一**的等待出口,所以「用户点了停止」的检查放在这一处就够了 ——
- * 全流程(等待应用启动、等待播放、等待结果卡片、等待评论区……共几十处等待)
- * 都会经过 tick,于是都能被打断,业务代码一行都不用改。
- * bridge 未启用时是空操作(独立运行时零开销)。
- */
- function tick(ms) {
- if (TikTokAuto.bridge) {
- TikTokAuto.bridge.checkStop();
- }
- sleep(ms === undefined ? DEFAULT_TICK_MS : ms);
- }
- /**
- * 反复求值 predicate 直到它返回"真值"或超时。
- *
- * predicate 的返回值语义:
- * - null / false → 条件未满足,继续轮询
- * - 其它任何值(含 0!) → 条件已满足,原样返回该值
- * 之所以不用 `if (value)` 判断,是为了允许 predicate 合法地返回 0 / '' 这类"假值结果"。
- *
- * @returns 满足时的返回值;超时返回 null
- */
- function poll(predicate, timeoutMs, tickMs) {
- var deadline = now() + timeoutMs;
- var step = tickMs === undefined ? DEFAULT_TICK_MS : tickMs;
- var last = null;
- for (;;) {
- try {
- var value = predicate();
- if (value !== null && value !== false && value !== undefined) {
- return value;
- }
- last = value;
- } catch (error) {
- last = error;
- }
- if (now() >= deadline) {
- return null;
- }
- tick(Math.min(step, Math.max(1, deadline - now())));
- }
- }
- /**
- * 带日志与语义名字的 poll —— 业务代码应该用这个,而不是裸 poll。
- * 超时会记 WARN(而不是静默返回 null),这样"卡在哪一步"永远有据可查。
- */
- function waitFor(name, predicate, timeoutMs, tickMs) {
- var began = now();
- var value = poll(predicate, timeoutMs, tickMs);
- var cost = now() - began;
- if (value === null) {
- TikTokAuto.logger.warn('waitFor', name + ' 超时(等了 ' + cost + 'ms / 上限 ' + timeoutMs + 'ms)');
- } else {
- TikTokAuto.logger.info('waitFor', name + ' 就绪(' + cost + 'ms)');
- }
- return value;
- }
- /** 等待条件成立,并把成立时的值原样返回;语义等价于 waitFor */
- function until(condition, timeoutMs, tickMs) {
- return poll(condition, timeoutMs, tickMs);
- }
- /** 等待某个东西消失(例如弹窗、加载圈) */
- function untilGone(isPresent, timeoutMs, tickMs) {
- return poll(function () {
- return isPresent() ? null : true;
- }, timeoutMs, tickMs);
- }
- /**
- * 等待页面"安静"下来。
- *
- * 判据不是时间,而是**可见文案的拼接签名连续 N 次不变**。
- * 这比 sleep 可靠:加载中会不断有新节点出现,签名就会变;
- * 真正稳定后才返回,因此天然适配"网络慢 → 渲染慢"。
- *
- * @param sample () => string,页面签名
- * @param options { maxMs, tickMs, stableTimes }
- */
- function waitQuiet(sample, options) {
- var settings = options || {};
- var maxMs = settings.maxMs === undefined ? 8000 : settings.maxMs;
- var tickMs = settings.tickMs === undefined ? 250 : settings.tickMs;
- var stableTimes = settings.stableTimes === undefined ? 3 : settings.stableTimes;
- var deadline = now() + maxMs;
- var previous = null;
- var sameCount = 0;
- while (now() < deadline) {
- var current = sample();
- if (current === previous) {
- sameCount += 1;
- if (sameCount >= stableTimes) {
- return true;
- }
- } else {
- sameCount = 0;
- previous = current;
- }
- tick(tickMs);
- }
- return false;
- }
- return {
- DEFAULT_TICK_MS: DEFAULT_TICK_MS,
- tick: tick,
- poll: poll,
- waitFor: waitFor,
- until: until,
- untilGone: untilGone,
- waitQuiet: waitQuiet
- };
- }());
- /* ============================== src/layout.js ============================== */
- /**
- * layout.js — 对 AutoX 无障碍 API 的适配层(Boundary Adapter)
- *
- * 依据《代码整洁之道》第 8 章「边界」:
- * 第三方/平台 API 一定有自己的脾气(`auto.root` 是属性不是函数、
- * `text()` 是快照、离屏节点占了 40%+…)。把所有这些脾气**关在这一个文件里**,
- * 业务代码就只面对一组干净、语义明确的函数。
- * 平台 API 变了 → 只改这里。
- *
- * 依赖:TikTokAuto.logger、TikTokAuto.wait
- */
- TikTokAuto.layout = (function () {
- 'use strict';
- var SAFE_MARGIN = 8;
- function screen() {
- return {
- width: device.width,
- height: device.height
- };
- }
- /** 当前应用窗口的根节点。注意:是属性 `auto.root`,不是函数 `auto().root()` */
- function root() {
- return auto.root;
- }
- /** 前台包名。**不能**用 currentPackage()——软键盘弹出时它返回输入法包名 */
- function foregroundPackage() {
- var node = root();
- return node ? node.packageName() : null;
- }
- function findAll(selector) {
- return selector.find();
- }
- function findOnce(selector) {
- var collection = selector.find();
- return collection && collection.size() > 0 ? collection.get(0) : null;
- }
- /**
- * 收集整棵树的节点(含离屏)。
- * AutoX 的 `find()` 只从根往下找**可见**节点,拿不到离屏节点;
- * 而我们需要离屏信息来做"下面还有内容 → 该滚动"的判断。
- */
- function collectAll(maxNodes) {
- var limit = maxNodes || 1200;
- var result = [];
- var start = root();
- if (!start) {
- return result;
- }
- var stack = [start];
- while (stack.length > 0 && result.length < limit) {
- var node = stack.pop();
- if (!node) {
- continue; // 遍历过程中节点可能失效,child(i) 会返回 null
- }
- result.push(node);
- var count = node.childCount();
- for (var i = 0; i < count; i += 1) {
- stack.push(node.child(i));
- }
- }
- return result;
- }
- /** 可见性判据:优先用原生 isVisibleToUser(),退化为"与屏幕求交" */
- function isVisible(node) {
- try {
- if (typeof node.isVisibleToUser === 'function') {
- return node.isVisibleToUser();
- }
- } catch (ignored) {
- // 落到下面的几何判据
- }
- try {
- if (typeof node.visibleToUser === 'function') {
- return node.visibleToUser();
- }
- } catch (ignoredToo) {
- // 落到下面的几何判据
- }
- return visibleRect(node) !== null;
- }
- /** 与屏幕求交后的可见矩形;完全在屏幕外时返回 null */
- function visibleRect(node) {
- var box = node.bounds();
- var view = screen();
- var left = Math.max(box.left, 0);
- var top = Math.max(box.top, 0);
- var right = Math.min(box.right, view.width);
- var bottom = Math.min(box.bottom, view.height);
- if (right - left <= 0 || bottom - top <= 0) {
- return null;
- }
- return { left: left, top: top, right: right, bottom: bottom };
- }
- /**
- * 可点击坐标 = **可见交集矩形的中心**,再夹到安全区内。
- *
- * 为什么要交集而不是几何中心:被状态栏切掉一半的节点,几何中心可能落在 y=0
- * (点到了状态栏),交集中心才是真正能摸到的地方。
- */
- function center(node) {
- var rect = visibleRect(node);
- var view = screen();
- if (!rect) {
- return null;
- }
- var x = Math.round((rect.left + rect.right) / 2);
- var y = Math.round((rect.top + rect.bottom) / 2);
- return {
- x: Math.min(Math.max(x, SAFE_MARGIN), view.width - SAFE_MARGIN),
- y: Math.min(Math.max(y, SAFE_MARGIN), view.height - SAFE_MARGIN)
- };
- }
- /**
- * 去掉文案里的**不可见格式字符**(零宽空格、LTR/RTL 标记、软连字符等)。
- *
- * 这是真机踩出来的坑:评论列表头部的计数实际文本是 "150 comments",
- * 前面挂着一个 U+200E(LEFT-TO-RIGHT MARK)——肉眼完全看不出来,
- * 但 `/^\s*\d+ comments$/` 这种带锚点的正则就是匹配不上,
- * 于是"评论列表确认"这条最强证据永远拿不到,只能退化到弱证据。
- *
- * 放在适配层里统一处理:平台这类噪音只在这里吸掉一次,业务代码就干净了。
- */
- function sanitize(value) {
- if (value === null || value === undefined) {
- return '';
- }
- return String(value)
- .replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u2064\ufeff\u00ad]/g, '')
- .replace(/\s+/g, ' ')
- .trim();
- }
- function text(node) {
- var value = '';
- try {
- value = node.text();
- } catch (ignored) {
- value = '';
- }
- return sanitize(value);
- }
- function desc(node) {
- var value = '';
- try {
- value = node.desc();
- } catch (ignored) {
- value = '';
- }
- return sanitize(value);
- }
- /** 节点的"可读标签":文案优先,没有就看内容描述 */
- function label(node) {
- return text(node) || desc(node);
- }
- function resourceId(node) {
- try {
- var id = node.id();
- return id ? String(id) : '';
- } catch (ignored) {
- return '';
- }
- }
- function shortId(node) {
- var id = resourceId(node);
- var parts = id.split('/');
- return parts.length > 1 ? parts[parts.length - 1] : id;
- }
- function classNameOf(node) {
- try {
- return String(node.className());
- } catch (ignored) {
- return '';
- }
- }
- function shortClass(node) {
- var parts = classNameOf(node).split('.');
- return parts[parts.length - 1];
- }
- function isClickable(node) {
- try {
- return node.clickable() === true;
- } catch (ignored) {
- return false;
- }
- }
- function isScrollable(node) {
- try {
- return node.scrollable() === true;
- } catch (ignored) {
- return false;
- }
- }
- function isEditable(node) {
- return /EditText/i.test(classNameOf(node));
- }
- function parent(node) {
- try {
- return node.parent();
- } catch (ignored) {
- return null;
- }
- }
- /** 从 node 向上找到第一个可点击的祖先(不含自身) */
- function clickableAncestor(node, maxDepth) {
- var depth = 0;
- var limit = maxDepth === undefined ? 8 : maxDepth;
- var cursor = parent(node);
- while (cursor && depth < limit) {
- if (isClickable(cursor)) {
- return cursor;
- }
- cursor = parent(cursor);
- depth += 1;
- }
- return null;
- }
- /** 节点在屏幕上的矩形是否与给定纵向区间有明显重叠 */
- function overlapsBand(node, top, bottom, minRatio) {
- var rect = visibleRect(node);
- if (!rect) {
- return false;
- }
- var overlap = Math.min(rect.bottom, bottom) - Math.max(rect.top, top);
- if (overlap <= 0) {
- return false;
- }
- var ratio = minRatio === undefined ? 0.6 : minRatio;
- return overlap >= (rect.bottom - rect.top) * ratio;
- }
- /** 扁平化渲染,用于把当前界面一行一个控件打进日志(排障时非常有用) */
- function describe(nodes, limit) {
- var lines = [];
- var max = limit === undefined ? nodes.length : Math.min(limit, nodes.length);
- for (var i = 0; i < max; i += 1) {
- var node = nodes[i];
- var point = center(node);
- lines.push([
- shortClass(node),
- shortId(node),
- point ? '(' + point.x + ',' + point.y + ')' : '(offscreen)',
- isClickable(node) ? 'clk' : '-',
- JSON.stringify(label(node)).slice(0, 48)
- ].join(' '));
- }
- return lines.join(' | ');
- }
- /**
- * 页面签名 = 所有可见节点文案/描述的拼接。
- * 用途:判断"页面变没变""是否已经稳定",完全不依赖时间。
- */
- function signature() {
- var nodes = collectAll(1500);
- var parts = [];
- for (var i = 0; i < nodes.length; i += 1) {
- if (!isVisible(nodes[i])) {
- continue;
- }
- var value = label(nodes[i]);
- if (value) {
- parts.push(value);
- }
- }
- return parts.join('\u0001');
- }
- return {
- SAFE_MARGIN: SAFE_MARGIN,
- screen: screen,
- root: root,
- foregroundPackage: foregroundPackage,
- findAll: findAll,
- findOnce: findOnce,
- collectAll: collectAll,
- isVisible: isVisible,
- visibleRect: visibleRect,
- center: center,
- text: text,
- desc: desc,
- label: label,
- sanitize: sanitize,
- resourceId: resourceId,
- shortId: shortId,
- classNameOf: classNameOf,
- shortClass: shortClass,
- isClickable: isClickable,
- isScrollable: isScrollable,
- isEditable: isEditable,
- parent: parent,
- clickableAncestor: clickableAncestor,
- overlapsBand: overlapsBand,
- describe: describe,
- signature: signature
- };
- }());
- /* ============================== src/ui.js ============================== */
- /**
- * ui.js — 通用交互动作
- *
- * 这一层只回答一个问题:"怎么把一次『点击』做成功"。
- * 它不认识 TikTok,也不认识业务流程(那是 tiktok.js / flow.js 的事)——
- * 这是《代码整洁之道》第 3 章「只做一件事」的落地。
- *
- * 点击的四级降级(都走无障碍,**不需要 Root**):
- * 1. node.click()
- * 2. 最近的可点击祖先 click() ← 大多数"点不动"的真凶:文案节点本身不可点
- * 3. 手势 click(可见交集中心)
- * 4. 手势 Tap(可见交集中心)
- * 并且返回"真正点到了谁",让调用方可以核对,而不是默默点错地方。
- *
- * 依赖:TikTokAuto.logger、TikTokAuto.layout、TikTokAuto.wait
- */
- TikTokAuto.ui = (function () {
- 'use strict';
- /**
- * 在坐标上点一下。
- *
- * ★ 用无障碍坐标点击 `click(x, y)`,**不要**用 `gesture()`(2026-10-28 实测)
- *
- * 踩过的弯路,记下来免得再走:
- * 那天排查"点评论按钮没反应",一度以为 TikTok 吞掉了无障碍坐标点击,
- * 于是把这里改成 `gesture(120, [[x,y],[x,y]])` —— 主脚本里探针确实点开了,
- * 但**引擎(`execScriptFile` 起的子脚本)里 gesture 是无效的**:
- * 同坐标下 gesture / click / shell 三种方式全部失败,而 `click` 在主脚本里也有效。
- *
- * 真正的故障其实在别处(键盘没弹 → 输入框停在屏幕底部 → `panelInput()` 判据不命中),
- * 跟点击方式无关。引擎里后来一直用 `click` 打开评论面板,每次都能成功 —— 这就是证据。
- */
- function tapPoint(x, y) {
- click(x, y);
- }
- /**
- * 点击一个节点,逐级降级直到成功。
- * @returns {ok, via, targetCls, targetLabel, point}
- */
- /* ---- 悬浮球避让(规格书 §10.9③)----
- *
- * 全局悬浮球浮在**所有应用之上**(包括 TikTok),所以坐标手势如果正好落在球的
- * 窗口矩形里,点到的是球、不是 TikTok —— 实测就是这样:搜索建议列表的某一条
- * 正好压在球位,`click(x,y)` 把球的扇形面板点了出来(用户看到的是"面板自己弹出来")。
- *
- * 规格书给的缓解办法是「脚本侧点击区域避开球位」,这里就做这一件事:
- * ① 界面把球的窗口矩形写进 ball-rect.json(球的窗口比球大一圈,见 floatball.js);
- * ② 本模块在**走坐标手势之前**读它,命中就把落点挪到同节点内、球区之外的角落;
- * ③ 整块节点都在球区里(挪不开)→ 放弃这次点击并记 WARN,
- * 让日志能解释"这次为什么没点成",而不是默默点到了球上。
- *
- * ⚠️ 注意 `node.click()` 全程不受影响:那是无障碍点击,不产生屏幕坐标,
- * 压根碰不到悬浮窗 —— 所以避让只挂在手势这条兜底路径上。
- */
- var BALL_RECT_PATH = '/sdcard/tiktok_app/ball-rect.json';
- var ballRectCache = { at: 0, rect: null };
- function ballRect() {
- var now = Date.now();
- if (now - ballRectCache.at < 500) { return ballRectCache.rect; }
- ballRectCache.at = now;
- ballRectCache.rect = null;
- try {
- if (!files.exists(BALL_RECT_PATH)) { return null; }
- var raw = JSON.parse(String(files.read(BALL_RECT_PATH)));
- if (!raw || raw.hidden || raw.open) { return null; } /* 隐藏 / 扇形展开时不挡点击判断 */
- if (typeof raw.x !== 'number' || typeof raw.w !== 'number') { return null; }
- ballRectCache.rect = raw;
- } catch (error) {
- /* 读不到就当没有球:宁可偶尔点到球,也不要因为一个坏文件让整个脚本点不动 */
- ballRectCache.rect = null;
- }
- return ballRectCache.rect;
- }
- function inside(rect, x, y) {
- return x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h;
- }
- /**
- * 把落点挪出球区。挪不动返回 null。
- * 候选点是「节点四角内缩」:同一节点内的任何位置对这次点击都等价,
- * 而角落最可能远离屏幕中部的球位。
- */
- function shiftOffBall(point, node, rect) {
- if (!inside(rect, point.x, point.y)) { return point; }
- var layout = TikTokAuto.layout;
- /* 复用 layout.visibleRect:它已经处理了"可见区域/被遮挡"这些边界情况,
- * 自己再写一份 node.bounds() 会与 center() 的口径不一致。 */
- var b = layout.visibleRect(node);
- if (!b) { return null; }
- var pad = 6;
- var cands = [
- { x: b.left + pad, y: b.top + pad },
- { x: b.right - pad, y: b.top + pad },
- { x: b.left + pad, y: b.bottom - pad },
- { x: b.right - pad, y: b.bottom - pad }
- ];
- for (var i = 0; i < cands.length; i++) {
- if (cands[i].x > 0 && cands[i].y > 0 && !inside(rect, cands[i].x, cands[i].y)) {
- return cands[i];
- }
- }
- return null;
- }
- function clickNode(node, step) {
- var story = step || 'click';
- var layout = TikTokAuto.layout;
- var targetClass = layout.shortClass(node);
- var targetLabel = layout.label(node);
- if (node.click()) {
- return finish(story, true, 'node.click()', targetClass, targetLabel, layout.center(node));
- }
- var ancestor = layout.clickableAncestor(node);
- if (ancestor && ancestor.click()) {
- return finish(story, true, 'clickableAncestor.click()', targetClass, targetLabel, layout.center(ancestor));
- }
- var point = layout.center(node);
- if (point) {
- var rect = ballRect();
- if (rect) {
- var safe = shiftOffBall(point, node, rect);
- if (!safe) {
- return finish(story, false, 'blocked-by-floatball', targetClass, targetLabel, point);
- }
- point = safe;
- }
- /* 走 gesture 而不是 click —— 理由见 tapPoint 上面那段(TikTok 吞无障碍坐标点击)。 */
- tapPoint(point.x, point.y);
- return finish(story, true, 'gesture tap(visibleCenter)', targetClass, targetLabel, point);
- }
- return finish(story, false, 'no-clickable-target', targetClass, targetLabel, null);
- }
- function finish(step, ok, via, targetClass, targetLabel, point) {
- var logger = TikTokAuto.logger;
- var where = point ? '(' + point.x + ',' + point.y + ')' : '(n/a)';
- logger.info(step, '点击 ' + (ok ? '成功' : '失败') + ' via=' + via +
- ' target=' + targetClass + ' ' + JSON.stringify(targetLabel).slice(0, 40) + ' ' + where);
- return {
- ok: ok,
- via: via,
- targetClass: targetClass,
- targetLabel: targetLabel,
- point: point
- };
- }
- /**
- * 往一个**已知节点**写入文本,并重新查找节点回读校验。
- *
- * 必须回读:AutoX 的 UiObject 是 find 那一刻的快照,
- * setText 之后立刻 .text() 读到的还是旧值,直接判定"写入失败"是经典误判。
- */
- function typeInto(node, value, step) {
- var logger = TikTokAuto.logger;
- var layout = TikTokAuto.layout;
- var story = step || 'typeInto';
- if (!node) {
- logger.error(story, '输入目标为空');
- return { ok: false, via: 'no-target' };
- }
- /* 先让输入框**真正获得焦点**。
- *
- * ★ 两种手段都上(2026-10-28 实测):无障碍 ACTION_CLICK 对输入框时灵时不灵,
- * 坐标点击在子脚本(引擎)里也可能被 TikTok 吞掉。两个都试、都没有副作用
- * (点输入框只会让它聚焦)。
- *
- * **必须聚焦**,不只是为了输入 —— 输入框不聚焦就不会弹键盘,
- * 而首页信息流的评论面板里,**"发送"按钮只有在键盘弹起时才存在**。
- * 少了这一步,整条评论链路会断在"定位不到发送按钮",而日志完全看不出是这个原因。
- */
- try {
- node.click();
- } catch (ignoredClick) {
- /* 输入框不可点也没关系,下一种手段继续 */
- }
- TikTokAuto.wait.tick(150);
- var point = layout.center(node);
- if (point) {
- tapPoint(point.x, point.y);
- TikTokAuto.wait.tick(250);
- }
- node.setText(value);
- TikTokAuto.wait.tick(300);
- var actual = readEditableText(value);
- var ok = actual === value;
- if (!ok) {
- logger.warn(story, '回读不一致,重试一次。期望=' + JSON.stringify(value) + ' 实际=' + JSON.stringify(actual));
- node.setText(value);
- TikTokAuto.wait.tick(400);
- actual = readEditableText(value);
- ok = actual === value;
- }
- logger.info(story, '写入' + (ok ? '成功' : '失败') + ' 实际=' + JSON.stringify(actual));
- return { ok: ok, via: 'setText+readback', actual: actual };
- }
- /** 便捷版:传选择器,内部自己找节点 */
- function setText(selector, value, step) {
- return typeInto(TikTokAuto.layout.findOnce(selector), value, step);
- }
- /** 回读:取与期望值相等的输入框文本;没有就返回最长的那条(便于排查) */
- function readEditableText(expected) {
- var layout = TikTokAuto.layout;
- var collection = className('android.widget.EditText').find();
- var longest = '';
- for (var i = 0; i < collection.size(); i += 1) {
- var value = layout.text(collection.get(i));
- if (value === expected) {
- return value;
- }
- if (String(value).length > longest.length) {
- longest = value;
- }
- }
- return longest;
- }
- function isEditableText(expected) {
- return readEditableText(expected) === expected;
- }
- /**
- * 滑动。
- * 注意函数名不能叫 `swipe`——那会遮蔽 AutoX 的全局 `swipe()`,导致自我递归。
- */
- function dragScreen(x1, y1, x2, y2, durationMs) {
- swipe(x1, y1, x2, y2, durationMs === undefined ? 320 : durationMs);
- }
- /**
- * 返回上一页。
- * 注意:只在**明确的页面迁移**里调用(如 播放页 → 结果页)。
- * 永远不要把它当成"兜底清理"——那会把正常页面也退掉。
- */
- function goBack(step) {
- TikTokAuto.logger.info(step || 'back', '按下返回');
- back();
- }
- return {
- tapPoint: tapPoint,
- clickNode: clickNode,
- typeInto: typeInto,
- setText: setText,
- readEditableText: readEditableText,
- isEditableText: isEditableText,
- dragScreen: dragScreen,
- goBack: goBack
- };
- }());
- /* ============================== src/popups.js ============================== */
- /**
- * popups.js — 干扰弹窗的「检测 / 执行分离」
- *
- * 依据 AutoX 真机经验(也是《代码整洁之道》第 3 章的"指令查询分离"):
- * - 把「看」和「动」拆成两组函数。guard 线程负责"动",
- * 主流程只用只读的 popupPresent() 去**等**它清干净,绝不抢它的活。
- * - **绝不 back() 正常页面**。登录页/权限页的兜底必须命中"登录方式"这类语境关键词,
- * 否则在「我的」页看到"立即登录"就会把正常页面退掉。
- *
- * 对未知弹窗的策略是**记录而不是猜**:
- * 命中已知签名 → 点掉;看起来像弹窗但不在名单里 → 只记 WARN + dump 界面,
- * 由人来决定要不要加进名单。比"猜着点"安全得多。
- *
- * 依赖:TikTokAuto.logger、TikTokAuto.layout、TikTokAuto.ui、TikTokAuto.wait
- */
- TikTokAuto.popups = (function () {
- 'use strict';
- /**
- * 已知的阻塞型弹窗签名。
- * contains 里任一命中即认为出现;dismiss 决定点哪个按钮。
- */
- var KNOWN_DIALOGS = [
- {
- name: 'save-login-info',
- contains: ['Save login info', 'Save your login info', '记住登录'],
- dismiss: ['Not now', 'Cancel', '稍后', '取消']
- },
- {
- name: 'notifications-permission',
- contains: ['Turn on notifications', 'get notified', '打开通知'],
- dismiss: ['Not now', 'No thanks', 'Skip', '稍后', '暂不']
- },
- {
- name: 'rate-app',
- contains: ['Enjoying TikTok', 'Rate this app', 'rate your experience'],
- dismiss: ['Not now', 'No thanks', 'Later', '稍后']
- },
- {
- name: 'update-app',
- contains: ['Update TikTok', 'A new version'],
- dismiss: ['Not now', 'Later', '稍后']
- },
- {
- name: 'open-in-app',
- contains: ['Open in app', 'Continue in app'],
- dismiss: ['Cancel', 'Not now', '取消']
- }
- ];
- /** 只读:扫描当前应用窗口,返回命中的已知弹窗名 + 疑似未知弹窗 */
- function scan() {
- var layout = TikTokAuto.layout;
- var visibleLabels = collectVisibleLabels();
- var joined = visibleLabels.join(' \u0001 ');
- var hits = [];
- for (var i = 0; i < KNOWN_DIALOGS.length; i += 1) {
- var dialog = KNOWN_DIALOGS[i];
- if (containsAny(joined, dialog.contains)) {
- hits.push(dialog);
- }
- }
- return {
- known: hits,
- looksBlocking: hits.length > 0 || looksLikeUnknownModal(visibleLabels)
- };
- }
- function containsAny(haystack, needles) {
- for (var i = 0; i < needles.length; i += 1) {
- if (haystack.indexOf(needles[i]) !== -1) {
- return true;
- }
- }
- return false;
- }
- function collectVisibleLabels() {
- var layout = TikTokAuto.layout;
- var nodes = layout.collectAll(1200);
- var labels = [];
- for (var i = 0; i < nodes.length; i += 1) {
- if (!layout.isVisible(nodes[i])) {
- continue;
- }
- var value = layout.label(nodes[i]);
- if (value) {
- labels.push(value);
- }
- }
- return labels;
- }
- /**
- * "看起来像弹窗"的启发式判据(仅用于告警,不驱动动作):
- * 可见文案很少(弹窗通常只有标题 + 一两个按钮),却又出现了否定的动作词。
- * 正常页面(信息流/结果页)动辄几十条文案,不会命中。
- */
- function looksLikeUnknownModal(labels) {
- var dismissLabels = TikTokAuto.config
- ? TikTokAuto.config.DEFAULTS.guard.dismissLabels
- : ['Not now', 'Cancel', '稍后', '取消'];
- if (labels.length > 8) {
- return false;
- }
- return containsAny(labels.join('\u0001'), dismissLabels);
- }
- /** 只读:是否还有阻塞弹窗(主流程用它"等"guard 清场) */
- function popupPresent() {
- return scan().looksBlocking;
- }
- /** 执行:尝试点掉一个已知弹窗。返回是否真的动了手 */
- function dismissKnown(step) {
- var logger = TikTokAuto.logger;
- var layout = TikTokAuto.layout;
- var found = scan();
- if (found.known.length === 0) {
- return false;
- }
- for (var i = 0; i < found.known.length; i += 1) {
- var dialog = found.known[i];
- for (var j = 0; j < dialog.dismiss.length; j += 1) {
- var button = findExactLabel(dialog.dismiss[j]);
- if (button) {
- logger.info(step || 'popup', '关闭已知弹窗 ' + dialog.name + ' → 点击 ' + JSON.stringify(dialog.dismiss[j]));
- TikTokAuto.ui.clickNode(button, step || 'popup');
- TikTokAuto.wait.tick(400);
- return true;
- }
- }
- logger.warn(step || 'popup', '命中弹窗 ' + dialog.name + ' 但找不到关闭按钮,跳过');
- }
- return false;
- }
- /** 按精确文案/描述找可见节点;文案优先于描述 */
- function findExactLabel(value) {
- var layout = TikTokAuto.layout;
- var nodes = layout.collectAll(1200);
- for (var i = 0; i < nodes.length; i += 1) {
- if (layout.text(nodes[i]) === value && layout.isVisible(nodes[i])) {
- return nodes[i];
- }
- }
- for (var j = 0; j < nodes.length; j += 1) {
- if (layout.desc(nodes[j]) === value && layout.isVisible(nodes[j])) {
- return nodes[j];
- }
- }
- return null;
- }
- /**
- * 启动守护线程:周期性清掉已知弹窗。
- *
- * 返回一个句柄,其中 passes 计数让主流程确认"它已经上岗了"——
- * 否则主流程可能在守护线程第一次巡检之前就撞上弹窗。
- */
- function startGuard(config, logger) {
- var state = { passes: 0, dismissed: 0, stopped: false };
- if (!config.guard.enabled) {
- logger.info('guard', '守护线程已通过配置关闭');
- state.passes = 1;
- return state;
- }
- var thread = threads.start(function () {
- while (!state.stopped) {
- try {
- if (dismissKnown('guard')) {
- state.dismissed += 1;
- }
- state.passes += 1;
- } catch (error) {
- /* 用户叫停时会从 wait.tick 抛出 StopSignal。守护线程不该继续巡检,
- * 也不该每一轮都记一条 WARN 把日志刷满 —— 直接收工。 */
- if (TikTokAuto.bridge && TikTokAuto.bridge.isStopSignal(error)) {
- logger.info('guard', '收到停止请求,守护线程收工');
- break;
- }
- logger.warn('guard', '巡检异常:' + error);
- }
- sleep(config.guard.intervalMs);
- }
- });
- state.thread = thread;
- logger.info('guard', '守护线程已启动,间隔 ' + config.guard.intervalMs + 'ms');
- return state;
- }
- function stopGuard(state, logger) {
- if (!state) {
- return;
- }
- state.stopped = true;
- logger.info('guard', '守护线程已停止,累计清场 ' + state.dismissed + ' 次');
- }
- return {
- KNOWN_DIALOGS: KNOWN_DIALOGS,
- scan: scan,
- popupPresent: popupPresent,
- dismissKnown: dismissKnown,
- findExactLabel: findExactLabel,
- startGuard: startGuard,
- stopGuard: stopGuard
- };
- }());
- /* ============================== src/tiktok.js ============================== */
- /**
- * tiktok.js — TikTok 的应用知识(选择器 + 页面状态判定)
- *
- * 这个文件是**唯一**允许出现"TikTok 具体长什么样"的地方。
- * 换 App、或 TikTok 改版时,只动这里,flow.js 一行都不用改。
- *
- * 所有选择器都基于**真机实测**的锚点,并且在实测基础上刻意选了"语义锚点"而不是
- * 混淆过的资源 id——TikTok 的 id 形如 `e4g`/`cn6`/`e8x`,每次改版都会重排;
- * 而 "Read or add comments" 这类无障碍文案相当稳定。
- * 少数确实只能靠 id 的地方(发送按钮)走 config.selectors 覆盖,便于不改代码修。
- *
- * 实测锚点(1080x1920,com.zhiliaoapp.musically):
- * 信息流搜索入口 ImageView desc="Search" [968,50,1080,162]
- * 搜索页输入框 EditText (top < 12%H)
- * 搜索页提交 Button text="Search" (left > 80%W)
- * 结果页分类页签 text/desc ∈ {Top,Users,Videos,...} y ≈ 152..232
- * 结果页卡片 Button(卡片文案) width ≥ 35%W, top > 30%H
- * 播放页 "Read or add comments" + "Like video." 同时存在
- * 播放页评论入口 Button desc="Read or add comments. N comments"
- * 播放页内联输入 EditText desc/text="Add comment..."
- * 评论面板输入 EditText top ∈ [50%H,80%H]
- * 面板工具行 desc="Stickers" / "Mention someone" / "Microphone"
- * 面板发送按钮 工具行同高、最右、且 right ≤ W-8 的可点击 Button(实测 id=cn6)
- *
- * 依赖:TikTokAuto.logger、TikTokAuto.layout
- */
- TikTokAuto.tiktok = (function () {
- 'use strict';
- var PKG = 'com.zhiliaoapp.musically';
- var COMMENT_DESC_KEYS = ['Read or add comments', '评论'];
- var LIKE_DESC_KEYS = ['Like video.', '点赞'];
- /** 已点赞后同一个按钮的 desc(实测是 `Video liked`,不是 `Unlike`) */
- var LIKED_DESC_KEYS = ['Video liked', '已点赞', 'Unlike'];
- /** 关注按钮 desc 前缀(后面跟作者名)。`Followers` 会被 followButton 里单独排除。 */
- var FOLLOW_DESC_KEYS = ['Follow ', '关注'];
- var COMMENT_PLACEHOLDER = ['Add comment...', '添加评论', 'Add comment'];
- var TOOL_ROW_ANCHORS = ['Stickers', 'Mention someone', 'Microphone', '表情', '提及', '语音'];
- /**
- * 右侧竖排动作栏的 desc 前缀(作者 / 点赞 / 评论 / 收藏 / 分享 / 音乐)。
- * 用于把"和输入框同高的动作栏按钮"从发送键候选里排掉 —— 详见 isActionBarButton。
- */
- var ACTION_BAR_DESC_PREFIXES = [
- 'Follow', 'Following', 'Unfollow',
- 'Like video', 'Video liked',
- 'Read or add comments', 'Add or remove this video',
- 'Share video', 'Sound:'
- ];
- var RESULT_TABS = ['Top', 'Users', 'Videos', 'Sounds', 'Shop', 'LIVE', 'Places', 'Photos'];
- /**
- * 结果页卡片搜索区的上边界(占屏高比例)。
- *
- * 实测布局:搜索框 → 页签栏 y 152~232 → GridView 从 y=232 开始(屏高 1920)。
- * 取 0.13(=250)刚好卡在网格起始线下方,把搜索框和页签排掉。
- * 详见 videoCards() 的注释(用 0.3 会漏掉滚动后的上排卡片)。
- */
- var ROWS_TOP_RATIO = 0.13;
- var layout = TikTokAuto.layout; // 常量引用,模块加载顺序由 build.js 保证
- var logger = TikTokAuto.logger;
- function screenWidth() { return device.width; }
- function screenHeight() { return device.height; }
- function startsWithAny(value, keys) {
- for (var i = 0; i < keys.length; i += 1) {
- if (value.indexOf(keys[i]) === 0) {
- return true;
- }
- }
- return false;
- }
- function equalsAny(value, keys) {
- for (var i = 0; i < keys.length; i += 1) {
- if (value === keys[i]) {
- return true;
- }
- }
- return false;
- }
- function widthOf(node) {
- var box = node.bounds();
- return box.right - box.left;
- }
- function topOf(node) {
- return node.bounds().top;
- }
- function bottomOf(node) {
- return node.bounds().bottom;
- }
- /** 按"文案或描述命中"收集可见节点 */
- function findByLabel(selector, predicate) {
- var result = [];
- var collection = selector.find();
- for (var i = 0; i < collection.size(); i += 1) {
- var node = collection.get(i);
- if (predicate(node) && layout.isVisible(node)) {
- result.push(node);
- }
- }
- return result;
- }
- // ---------------------------------------------------------------- 信息流
- /** 信息流右上角的搜索入口 */
- function searchEntry() {
- var hits = findByLabel(className('android.widget.ImageView'), function (node) {
- var point = layout.center(node);
- return layout.desc(node) === 'Search' && point !== null &&
- point.y < screenHeight() * 0.12 && point.x > screenWidth() * 0.85;
- });
- return hits.length > 0 ? hits[0] : null;
- }
- /** 信息流就绪判据:底部导航存在 */
- function bottomNavPresent() {
- return layout.findOnce(desc('Home')) !== null && layout.findOnce(desc('Profile')) !== null;
- }
- /**
- * 底部导航的「Profile」项 —— 用来打开**自己的**主页(脚本二要自己的粉丝列表)。
- *
- * 判据刻意很松:`desc === 'Profile'` 精确匹配 + 中心落在屏幕下缘。
- * 不限定 class(它是 FrameLayout,和历史记忆里"首页顶部 Following 页签是
- * FrameLayout"同一族,限定 Button 会一个都找不到),也不认文案 ——
- * 底部导航这一项没有 text。
- *
- * 为什么还要判"在下缘":右侧竖排作者头像的 desc 是 `xxx profile`(小写、
- * 带用户名前缀),精确匹配 `Profile` 本来就排除掉了;但万一新版把它改成
- * 纯 `Profile`,这一条能挡住"点到了头像"这种最坏情况。
- */
- function profileNav() {
- var node = layout.findOnce(desc('Profile'));
- if (!node) {
- return null;
- }
- var point = layout.center(node);
- if (!point || point.y < screenHeight() * 0.88) {
- return null;
- }
- return node;
- }
- // ---------------------------------------------------------------- 搜索页
- /** 顶部搜索输入框(排除评论输入框:后者在屏幕下方) */
- function searchInput() {
- var hits = findByLabel(className('android.widget.EditText'), function (node) {
- return topOf(node) < screenHeight() * 0.12;
- });
- return hits.length > 0 ? hits[0] : null;
- }
- function searchSubmit() {
- var hits = findByLabel(className('android.widget.Button'), function (node) {
- var point = layout.center(node);
- return layout.text(node) === 'Search' && point !== null &&
- point.y < screenHeight() * 0.12 && point.x > screenWidth() * 0.8;
- });
- return hits.length > 0 ? hits[0] : null;
- }
- /** 搜索页特征:搜索历史行(带删除按钮)或"发现"按钮 */
- function searchHistoryPresent() {
- return layout.findOnce(desc('Close')) !== null || layout.findOnce(desc('Find')) !== null;
- }
- // ---------------------------------------------------------------- 结果页
- /** 结果页顶部分类页签 */
- function resultTab(name) {
- var hits = findByLabel(className('android.widget.TextView'), function (node) {
- return equalsAny(layout.text(node) || layout.desc(node), [name]) &&
- topOf(node) > screenHeight() * 0.06 && topOf(node) < screenHeight() * 0.16;
- });
- if (hits.length > 0) {
- return hits[0];
- }
- // 页签的文案节点本身常常不可点,真正可点的是外层 FrameLayout
- var frames = findByLabel(className('android.widget.FrameLayout'), function (node) {
- return equalsAny(layout.desc(node) || layout.text(node), [name]) &&
- topOf(node) > screenHeight() * 0.06 && topOf(node) < screenHeight() * 0.16;
- });
- return frames.length > 0 ? frames[0] : null;
- }
- function resultTabStripPresent() {
- var found = 0;
- for (var i = 0; i < RESULT_TABS.length; i += 1) {
- if (resultTab(RESULT_TABS[i])) {
- found += 1;
- }
- }
- return found >= 3;
- }
- /**
- * 从卡片文案锚点向上找"卡片容器"。
- * 为什么不能直接点文案:文案节点多数不可点,而它的可点祖先可能是**整屏容器**——
- * 点下去等于点在屏幕中间,什么也不会发生(实测踩过)。
- * 因此要求容器高度落在合理的卡片区间内,否则认为锚点自己就是最佳目标。
- */
- function cardContainer(anchor) {
- var height = screenHeight();
- var cursor = layout.clickableAncestor(anchor);
- var depth = 0;
- while (cursor && depth < 6) {
- var cardHeight = bottomOf(cursor) - topOf(cursor);
- /* 上限从 0.8 放宽到 1.05(2026-10-28):卡片滚动到贴着屏幕上下边缘时,
- * 容器会有一部分在屏幕外,`bounds()` 报的高度于是**被裁切得又大又怪**,
- * 实测超过 80% 后被这里挡掉 → 整排卡片变成"找不到容器" → 上层误判
- * "卡片用尽"。点击点由 `layout.center` 用 visibleRect 夹取,仍然安全。 */
- if (cardHeight >= height * 0.18 && cardHeight <= height * 1.05) {
- return cursor;
- }
- cursor = layout.clickableAncestor(cursor);
- depth += 1;
- }
- return anchor;
- }
- /**
- * 这个节点是不是在"结果网格"里(GridView / RecyclerView 的子孙)。
- *
- * 为什么需要:结果页上会浮出各种**不是视频卡但同样又宽又有文案**的东西 ——
- * 授权弹窗的 `Customize Settings` / `Confirm My Choices`、商品卡的 `Shop now`、
- * 「Users」分区的表头。它们能通过"宽 + 有文案"的判据,被当成卡片点开白等 20 秒。
- * 而真正的结果卡片一定在滚动容器里,弹窗不出现在其中。
- */
- function insideResultGrid(node) {
- var cursor = node;
- for (var depth = 0; depth < 8; depth += 1) {
- try {
- cursor = cursor.parent();
- } catch (ignored) {
- return false;
- }
- if (!cursor) {
- return false;
- }
- var name = '';
- try {
- name = String(cursor.className() || '');
- } catch (ignoredToo) {
- name = '';
- }
- if (name.indexOf('GridView') >= 0 || name.indexOf('RecyclerView') >= 0) {
- return true;
- }
- }
- return false;
- }
- /**
- * 当前页面是"照片轮播帖"吗(不是视频)?
- *
- * 真机实测(2026-10-28):照片帖的信息行是「`Photo` · `1w ago`」两个独立 TextView,
- * 视频帖是「`Video` · …」。照片帖**没有播放页**,`onPlayerPage()` 也认不出来,
- * 于是 openCard 会白等满 20 秒(playerReadyMs)再失败。
- *
- * 判据限定文案**完全等于** `Photo` 且位于屏幕下半部分(信息行),
- * 避免误伤说明文字里出现 Photo 的普通视频。
- */
- function photoPost() {
- var height = screenHeight();
- var hits = findByLabel(className('android.widget.TextView'), function (node) {
- return (layout.text(node) || '') === 'Photo' && topOf(node) > height * 0.5;
- });
- return hits.length > 0 ? hits[0] : null;
- }
- /**
- * 结果网格里当前可见的卡片(按屏幕从上到下)。
- *
- * 判据(2026-10-28 用 AutoX 无障碍树真机核对过):
- * - 卡片文案是一条"宽"控件(≥35%W),据此把右侧竖排的点赞/评论/分享按钮排掉;
- * - **必须在 GridView/RecyclerView 里**(排掉浮层/弹窗按钮,如 `Customize Settings`);
- * - **必须能找到一个"卡片尺寸"的可点容器**(`cardContainer` 找不到时会退回节点
- * 本身,那种情况说明它只是一个孤立按钮,不是卡片);
- * - 可见性看**容器**,不看文案。
- *
- * ★ 为什么不再要求"文案在屏幕内"(这次最关键的修正):
- * 卡片的文案 Button 在**卡片底部**,而卡片很高(实测一张 8,1242 → 536,1920+)。
- * 结果页上只要多一条通栏广告或一行筛选 chips,网格就被推下去,文案跟着落到
- * **屏幕外**(实测 y=2099 > 1920)。旧的 `bottomOf(node) <= height - 12` 于是把这些
- * **明明看得见、点得着**的卡片全部丢掉 —— 表现成"只处理了 1 个就说卡片用尽"。
- *
- * 正确做法:文案照读(无障碍树里它在,只是不在可视区),能不能点由**容器**决定;
- * 点击点由 `layout.center` 用 `visibleRect` 夹取,永远落在屏幕内。
- */
- function videoCards() {
- var width = screenWidth();
- var height = screenHeight();
- var gridTop = height * ROWS_TOP_RATIO; /* 页签栏底部,网格从这里开始 */
- var minVisible = height * 0.08; /* 露出来的太少就不点(点下去容易点到邻居) */
- var cards = [];
- var seen = {};
- var hits = findByLabel(className('android.widget.Button'), function (node) {
- return widthOf(node) >= width * 0.35 && insideResultGrid(node);
- });
- for (var i = 0; i < hits.length; i += 1) {
- var caption = layout.text(hits[i]) || layout.desc(hits[i]);
- if (!caption) {
- continue;
- }
- var container = cardContainer(hits[i]);
- if (!container || container === hits[i]) {
- /* 找不到卡片尺寸的可点容器 → 这是个孤立按钮(弹窗按钮、分区表头等) */
- continue;
- }
- var boxTop = topOf(container);
- var boxBottom = bottomOf(container);
- if (boxBottom <= gridTop) {
- /* 整个容器都在页签栏之上 —— 不是结果卡片 */
- continue;
- }
- if (boxTop >= height) {
- /* 完全在屏幕下方,看不见也点不着 */
- continue;
- }
- if (Math.min(boxBottom, height) - Math.max(boxTop, gridTop) < minVisible) {
- /* 只露出一条缝,点下去很容易落到相邻卡片上 */
- continue;
- }
- var key = caption + '@' + boxTop;
- if (seen[key]) {
- continue;
- }
- seen[key] = true;
- cards.push({
- anchor: hits[i],
- container: container,
- caption: caption,
- top: boxTop
- });
- }
- cards.sort(function (a, b) { return a.top - b.top; });
- return cards;
- }
- // ---------------------------------------------------------------- 播放页
- function commentButton() {
- var hits = findByLabel(className('android.widget.Button'), function (node) {
- return startsWithAny(layout.desc(node), COMMENT_DESC_KEYS);
- });
- return hits.length > 0 ? hits[0] : null;
- }
- function likeButton() {
- var hits = findByLabel(className('android.widget.Button'), function (node) {
- return startsWithAny(layout.desc(node), LIKE_DESC_KEYS);
- });
- return hits.length > 0 ? hits[0] : null;
- }
- /**
- * 「已点赞」状态的点赞按钮。
- *
- * ★ 实测(2026-10-28,TikTok 47.1.4):点过赞之后同一个 `Button` 的 desc
- * 从 `Like video. 2.4M likes` 变成 **`Video liked`**(不是 `Unlike`),
- * 而里面那个 ImageView 的 `selected` 从 false 变成 true。
- *
- * 两个判据都要留:desc 受本地化影响,selected 是状态位不受文案影响 ——
- * 任一命中就算已赞,比只认一个更耐改版。
- */
- function likedButton() {
- var hits = findByLabel(className('android.widget.Button'), function (node) {
- return startsWithAny(layout.desc(node), LIKED_DESC_KEYS);
- });
- return hits.length > 0 ? hits[0] : null;
- }
- /**
- * 点赞按钮里的图标节点(用它读 `selected`)。
- * 实测:未赞 selected=false,已赞 selected=true。
- */
- function likeIcon() {
- var button = likeButton() || likedButton();
- if (!button) {
- return null;
- }
- var icons = button.find(className('android.widget.ImageView'));
- return icons.size() > 0 ? icons.get(0) : null;
- }
- /** 'liked' | 'unliked' | 'unknown' */
- function likeState() {
- if (likedButton() !== null) {
- return 'liked';
- }
- if (likeButton() !== null) {
- return 'unliked';
- }
- /* 按钮本身找不到时,退回看图标状态位(有些版本用同一个 desc,只翻 selected) */
- var icon = null;
- try { icon = likeIcon(); } catch (ignored) { icon = null; }
- if (icon !== null && icon.selected()) {
- return 'liked';
- }
- return 'unknown';
- }
- /**
- * 播放页的「关注作者」按钮。
- *
- * 实测 desc 形如 `Follow Marcgagilang`(`Follow` + 空格 + 作者名),
- * 是 `android.widget.Button`。
- * ⚠️ 首页顶部那个 `Following` 页签是 **FrameLayout**,不是 Button,
- * 所以限定 className 就能把它排除掉,不必额外按坐标过滤。
- */
- function followButton() {
- var hits = findByLabel(className('android.widget.Button'), function (node) {
- var value = layout.desc(node);
- if (!startsWithAny(value, FOLLOW_DESC_KEYS)) {
- return false;
- }
- /* `Followers` 是粉丝列表的入口,不是关注按钮 —— 前缀匹配会误命中 */
- return value.indexOf('Followers') !== 0;
- });
- return hits.length > 0 ? hits[0] : null;
- }
- /**
- * 作者头像节点。用来区分「已关注(按钮消失)」和「页面还没渲染」。
- * 实测 desc 形如 `Marcgagilang profile`。
- */
- function authorAvatar() {
- var hits = findByLabel(className('android.widget.ImageView'), function (node) {
- var value = layout.desc(node);
- return value !== '' && / profile$/i.test(value);
- });
- return hits.length > 0 ? hits[0] : null;
- }
- /**
- * 信息流当前视频的指纹 —— **只用来判断"上滑有没有真的换视频"**。
- *
- * ★ 它**不能**当业务上的"视频身份"用(判断是不是同一个视频):
- * 点赞之后 `Like video. 340.2K likes` 会变成 `Video liked`,指纹必然变化。
- * 所以它天生只适合回答"变了没有",不适合回答"这是不是同一个"。
- *
- * 「会不会重复操作同一个视频」不靠指纹,靠状态:
- * · 已经点过赞 → `likeState() === 'liked'` → interact.like 自己会 skip
- * · 已经关注 → `followState() === 'following'` → interact.follow 自己会 skip
- * 那两条是地面真相,比任何拼出来的指纹都可靠。
- *
- * 信息流的树里**同时存在上/下两个视频**的节点(实测:上一个在 y=-702、
- * 当前在 y=1149、下一个在 y=3069),所以这里只能用已经过 `isVisible` 的
- * likeButton/authorAvatar/commentButton —— 它们只返回当前那个视频的节点。
- */
- function feedSignature() {
- var parts = [];
- var avatar = authorAvatar();
- if (avatar) {
- parts.push('a:' + layout.desc(avatar));
- }
- var like = likeButton() || likedButton();
- if (like) {
- parts.push('l:' + layout.desc(like));
- }
- var comment = commentButton();
- if (comment) {
- parts.push('c:' + layout.desc(comment));
- }
- return parts.join('|');
- }
- /**
- * 'following' | 'not-following' | 'unknown'
- *
- * ★ 「已关注」的唯一表现是**按钮整个消失**(实测),所以不能只看"找不到按钮"
- * —— 页面没渲染完也找不到。用作者头像是否存在来区分这两种情况:
- * 头像在、按钮不在 = 已关注;两个都不在 = 页面还没好。
- */
- function followState() {
- if (followButton() !== null) {
- return 'not-following';
- }
- if (authorAvatar() !== null) {
- return 'following';
- }
- return 'unknown';
- }
- /**
- * 播放页判据。
- *
- * 教训(实测踩到):**信息流首页也有 "Like video." 和 "Read or add comments" 这一竖排**,
- * 所以只看这两个会一路把首页判成播放页,整个流程直接跑歪。
- * 真正能区分"从搜索结果点进来的播放页"的是:
- * - 左上角有返回箭头(首页没有)
- * - 没有底部导航栏(首页有 Home/Friends/Inbox/Profile)
- * 四条同时成立才算播放页。
- */
- function onPlayerPage() {
- return commentButton() !== null &&
- likeButton() !== null &&
- backButton() !== null &&
- !bottomNavPresent();
- }
- function backButton() {
- var hits = findByLabel(className('android.widget.ImageView'), function (node) {
- return layout.desc(node) === 'Back';
- });
- return hits.length > 0 ? hits[0] : null;
- }
- /** 从 "Read or add comments. 150 comments" 里把 150 抠出来 */
- function commentCount() {
- var button = commentButton();
- if (!button) {
- return null;
- }
- var matched = layout.desc(button).match(/([\d][\d,.\s]*)\s*(?:comments?|条评论|评论)/i);
- if (!matched) {
- return null;
- }
- return parseInt(String(matched[1]).replace(/[^\d]/g, ''), 10);
- }
- /** 视频页底部的内联评论输入框(面板未打开时就是它) */
- function inlineInput() {
- var hits = findByLabel(className('android.widget.EditText'), function (node) {
- return equalsAny(layout.text(node) || layout.desc(node), COMMENT_PLACEHOLDER) &&
- topOf(node) > screenHeight() * 0.8;
- });
- return hits.length > 0 ? hits[0] : null;
- }
- /** 评论面板里的输入框(打开面板后会出现在屏幕中部) */
- function panelInput() {
- var screenH = screenHeight();
- /* ① 经典位置:键盘弹起之后,输入框会被顶到屏幕中部。 */
- var mid = findByLabel(className('android.widget.EditText'), function (node) {
- var top = topOf(node);
- return top > screenH * 0.45 && top < screenH * 0.85;
- });
- if (mid.length > 0) {
- return mid[0];
- }
- /* ② 键盘**没弹起**时,输入框停在屏幕底部(实测 y=1816 @1080×1920,即 0.95H),
- * 完全落不进上面那个区间。
- *
- * 这是首页信息流里特有的情形:点右侧评论按钮打开面板,输入框不会自动获得
- * 焦点、键盘也就不弹;而播放页里点的是内联输入框,它天生带焦点、键盘必弹。
- * 所以只按位置判的话,**信息流里每一次都会判成"面板没打开"** ——
- * 而面板明明已经开了,输入框就在底部等着被点。
- *
- * 这时只能靠"评论区头部计数"来确认面板确实开着(那是面板独有的元素),
- * 再把 EditText 认成输入框。视频链路上这条分支不会命中(① 已经返回了)。
- */
- if (listCommentCount() !== null) {
- var any = findByLabel(className('android.widget.EditText'), function () {
- return true;
- });
- if (any.length > 0) {
- return any[0];
- }
- }
- return null;
- }
- function toolRowAnchor() {
- for (var i = 0; i < TOOL_ROW_ANCHORS.length; i += 1) {
- var node = layout.findOnce(desc(TOOL_ROW_ANCHORS[i]));
- if (node && layout.isVisible(node)) {
- return node;
- }
- }
- return null;
- }
- /**
- * 评论发送按钮。
- *
- * 它没有可用的文案(实测其 content-desc 是一个无关的账号串),图标是粉色箭头,
- * 所以只能靠**结构**定位:
- * 面板里"工具行"(表情/提及/语音那一行)的同高区域里,
- * 最靠右、且不属于右侧竖排动作栏(动作栏 right == 屏宽)的那个可点击按钮。
- *
- * 优先用 config.selectors.sendButtonId(若配置里给了 id),
- * 这样 TikTok 改版时改配置即可,不用改代码。
- */
- /**
- * 这个节点是不是右侧竖排动作栏(点赞/评论/收藏/分享/作者/音乐)里的按钮。
- *
- * ★ 为什么需要它(2026-10-28):评论面板一打开,输入框会上移到屏幕中部,
- * 正好和那六个按钮**同高**,光靠坐标完全分不开 —— 实测按"输入框右端"
- * 找到的是 `Follow xxx` 和 `Like video.`,点下去是关注/点赞,评论根本没发。
- *
- * 而这六个按钮每一个都有明确的 desc 前缀,发送键没有。这是唯一稳定的分界。
- */
- function isActionBarButton(node) {
- var value = layout.desc(node);
- if (!value) {
- return false;
- }
- for (var i = 0; i < ACTION_BAR_DESC_PREFIXES.length; i += 1) {
- if (value.indexOf(ACTION_BAR_DESC_PREFIXES[i]) === 0) {
- return true;
- }
- }
- return false;
- }
- /**
- * 按"输入框那一行"找发送按钮。
- *
- * 判据只有两条,都落在几何事实上:
- * ① 节点的**中心 y** 落在输入框上下边界之间(= 同一行)
- * ② 节点的**右边界**贴着输入框右端(或更右)
- *
- * ★ 为什么不能用"输入框下方那一行"(2026-10-28 血泪):
- * 面板打开后,**播放页那套右侧竖排动作栏(点赞/评论/收藏/分享)的无障碍节点
- * 仍然留在树里**,y 范围(实测 1218~1822)与面板区域大幅重叠。
- * 按"下方取最右"会稳定命中**点赞按钮**(id=`g8g`),后果是:
- * 面板被收起 + 视频被点了个赞,而业务侧因为"面板关了"判定成评论发送成功。
- * 假阳性比失败更糟 —— 用户以为发出去了。
- *
- * 而"中心 y 在输入框行内"这条能干净地排掉右侧竖排:
- * 点赞按钮 1218~1338 的中心是 1278,落在输入框 1115~1222 之外。
- *
- * ★ 发送键**只在键盘弹起时才存在**。所以调用方必须先让输入框聚焦
- * (`ui.typeInto` 里已经做了),否则这里返回 null 是正常的 ——
- * 那种情况会如实报"定位不到发送按钮",不会乱点。
- */
- function sendButtonOnInputRow() {
- var input = panelInput();
- if (!input) {
- logger.warn('comment', '没有面板输入框,无法按行定位发送按钮');
- return null;
- }
- var ib = input.bounds();
- var inputMidY = (ib.top + ib.bottom) / 2;
- var screenW = screenWidth();
- var best = null;
- var bestDist = null;
- var groups = ['android.widget.Button', 'android.widget.ImageView'];
- for (var g = 0; g < groups.length; g += 1) {
- var hits = findByLabel(className(groups[g]), function (node) {
- /* ★ 先排掉右侧竖排动作栏(2026-10-28 血泪):
- * 面板打开后输入框会上移到中部,正好和那六个按钮**同高**,
- * 光靠几何分不开。但它们每一个都有明确的 desc 前缀,
- * 而发送键没有 —— 这是唯一稳定可靠的分界。 */
- if (isActionBarButton(node)) {
- return false;
- }
- var nb = node.bounds();
- var midY = (nb.top + nb.bottom) / 2;
- if (midY < ib.top || midY > ib.bottom) {
- return false; // 不在输入框这一行
- }
- if (nb.right < ib.right - 60) {
- return false; // 不在输入框右端
- }
- if (nb.left < screenW * 0.5) {
- return false; // 只看右半边
- }
- if (nb.bottom - nb.top < 20 || nb.right - nb.left < 20) {
- return false; // 太小的装饰不收
- }
- try {
- return node.clickable() === true;
- } catch (ignored) {
- return false;
- }
- });
- /* 同一行里可能有多个(清空、表情…),取**离输入框中心最近**的那个。
- * 用局部变量记距离 —— 绝不能往 AutoX 的 UiObject 上挂自定义属性:
- * 那是 Java 宿主对象,赋值会在运行期抛异常,而且崩得很安静
- * (界面只看到"未开始")。 */
- for (var i = 0; i < hits.length; i += 1) {
- var hb = hits[i].bounds();
- var dist = Math.abs((hb.top + hb.bottom) / 2 - inputMidY);
- if (bestDist === null || dist < bestDist) {
- bestDist = dist;
- best = hits[i];
- }
- }
- if (best) {
- break;
- }
- }
- if (!best) {
- /* 排障:输入框这一行右侧到底有什么 */
- logger.warn('comment', '输入框 b=[' + ib.left + ',' + ib.top + ',' +
- ib.right + ',' + ib.bottom + '] 这一行的右侧没有可点按钮(键盘没弹起?)');
- var probes = [];
- try {
- var probeClasses = ['android.widget.Button', 'android.widget.ImageView',
- 'android.view.View', 'android.widget.FrameLayout'];
- for (var p = 0; p < probeClasses.length; p += 1) {
- var cands = className(probeClasses[p]).find();
- for (var c = 0; c < cands.size(); c += 1) {
- var nb2 = cands.get(c).bounds();
- var mid2 = (nb2.top + nb2.bottom) / 2;
- if (mid2 < ib.top - 40 || mid2 > ib.bottom + 40) { continue; }
- if (nb2.left < screenW * 0.5) { continue; }
- probes.push(String(probeClasses[p]).split('.').pop() +
- '[' + nb2.left + ',' + nb2.top + ',' + nb2.right + ',' + nb2.bottom +
- ']' + (isActionBarButton(cands.get(c)) ? '(动作栏)' : ''));
- }
- }
- } catch (ignoredProbe) { }
- logger.warn('comment', '这一行右侧实际有:' + (probes.join(' ') || '(空)'));
- return null;
- }
- logger.info('comment', '发送按钮:按输入框这一行定位 id=' + layout.shortId(best) +
- ' desc=' + JSON.stringify(layout.desc(best)) +
- ' b=' + JSON.stringify(best.bounds()));
- return best;
- }
- function sendButton(config) {
- var override = config && config.selectors && config.selectors.sendButtonId;
- if (override) {
- var byId = layout.findOnce(id(override));
- if (byId && layout.isVisible(byId)) {
- logger.info('comment', '发送按钮:使用配置 id ' + override);
- return byId;
- }
- }
- /* 先用几何判据(输入框那一行 + 右端)—— 它对"工具行 desc 变成 @资源id"
- * 和"右侧动作栏节点残留在树里"这两个新版变化都免疫。 */
- var rowButton = sendButtonOnInputRow();
- if (rowButton) {
- return rowButton;
- }
- var anchor = toolRowAnchor();
- if (!anchor) {
- return null;
- }
- var bandTop = topOf(anchor);
- var bandBottom = bottomOf(anchor);
- var width = screenWidth();
- var best = null;
- var hits = findByLabel(className('android.widget.Button'), function (node) {
- var point = layout.center(node);
- if (point === null) {
- return false;
- }
- var box = node.bounds();
- if (box.top < bandTop || box.top > bandBottom) {
- return false; // 不在工具行高度带里
- }
- if (box.right > width - layout.SAFE_MARGIN) {
- return false; // 属于右侧竖排动作栏
- }
- return box.left >= width * 0.5; // 必须是右半边
- });
- for (var i = 0; i < hits.length; i += 1) {
- if (best === null || hits[i].bounds().left > best.bounds().left) {
- best = hits[i];
- }
- }
- if (best) {
- logger.info('comment', '发送按钮:结构化定位 id=' +
- layout.shortId(best) + ' b=' + JSON.stringify(best.bounds()));
- }
- return best;
- }
- // ---------------------------------------------------------------- 评论列表
- /**
- * 评论列表面板顶部的计数,形如 "150 comments"。
- *
- * 为什么需要它:实测发现**视频页那个评论角标不一定会立刻刷新**
- * (发完评论后仍读回旧值),而评论列表头部的计数是即时的。
- * 所以"计数自增"这条证据要用列表头部的那个,而不是角标的。
- */
- /**
- * 从评论区头部的文案里取出评论数。**两种格式都要认**:
- *
- * · 旧版:`150 comments` / `1,203 comments` —— 数字在前
- * · 新版:`Comments 785` —— 数字在后,而且与页签名在**同一个 TextView** 里
- * (2026-10 实测:评论区拆成了 `Comments` / `Reviews` 两个页签)
- *
- * ★ 只认旧格式的后果非常隐蔽:首页信息流里点开评论面板后,
- * `listCommentCount()` 返回 null → `panelInput()` 的第二条分支进不去 →
- * 整个面板被判成"没打开",而屏幕上明明已经有评论列表和输入框。
- * 日志里只会看到一句"面板没打开(该视频可能关闭了评论)",完全是误导。
- */
- function parseCommentCountLabel(value) {
- var text = String(value || '').replace(/^\s+|\s+$/g, '');
- var newer = text.match(/^Comments?\s+([\d][\d,.]*)$/i);
- if (newer) {
- return parseInt(String(newer[1]).replace(/[^\d]/g, ''), 10);
- }
- var legacy = text.match(/^([\d][\d,.]*)\s*(?:comments?|条评论)$/i);
- if (legacy) {
- return parseInt(String(legacy[1]).replace(/[^\d]/g, ''), 10);
- }
- return null;
- }
- function listCommentCount() {
- var hits = findByLabel(className('android.widget.TextView'), function (node) {
- return parseCommentCountLabel(layout.text(node)) !== null;
- });
- if (hits.length === 0) {
- return null;
- }
- return parseCommentCountLabel(layout.text(hits[0]));
- }
- /**
- * 评论面板是否处于打开状态。
- * 注意:点内联输入框打开的是"快捷面板"(有中部输入框),
- * 点评论角标打开的是"列表面板"(没有中部输入框,但有 "N comments" 头部)。
- * 两种都要认,否则 back() 会漏按、面板关不掉。
- */
- function commentPanelOpen() {
- return panelInput() !== null || listCommentCount() !== null;
- }
- /**
- * 评论列表里是否出现了某条完全一致的文案。
- *
- * 这里刻意**不设数量上限**:早先为了省事写成"只看前 25 条可见文本",
- * 结果遍历顺序是"先视频页、后评论面板",25 条全被视频页的文案占满了,
- * 评论条目根本没轮到,于是最强的那条证据永远拿不到。
- * 可见 TextView 也就几百个,全扫一遍的开销远小于"判错"的代价。
- */
- function commentListContains(value) {
- var hits = findByLabel(className('android.widget.TextView'), function (node) {
- return layout.text(node) === value;
- });
- return hits.length > 0;
- }
- /** 当前屏幕上所有可见的文本文案(按屏幕顺序) */
- function visibleTexts(limit) {
- var hits = findByLabel(className('android.widget.TextView'), function () { return true; });
- var texts = [];
- for (var i = 0; i < hits.length; i += 1) {
- var value = layout.text(hits[i]);
- if (value && value.length >= 2) {
- texts.push(value);
- }
- if (limit && texts.length >= limit) {
- break;
- }
- }
- return texts;
- }
- // ============================================================ ID 提取
- /*
- * 下面这一整段是 2026-10-20 在真机上逐页 dump 出来的锚点
- * (Pixel 5a / 1080x1920 / TikTok 47.1.4 / 英文界面)。**每条都是实测值**,
- * 不是猜的 —— 猜出来的选择器在这个项目里从来没有一次是对的。
- *
- * ★ 一个重要发现:这一片的**资源 id 是语义化的**(`tv_username` / `txt_desc` /
- * `user_avatar`),与信息流那批混淆 id(`e4g`/`cn6`)完全不同。
- * 原因是这些页面来自 TikTok 的搜索/社交业务模块,用的是另一套布局。
- * 所以这里优先按 id 定位,文案只作为兜底(改版时 id 比文案更稳)。
- *
- * 实测到的三处文本陷阱:
- * ① 用户名被 **FID 隔离符** 包裹:显示 `ajman_fashion_week`(U+2068/U+2069),
- * 肉眼看不见,`layout.sanitize` 也不剥(那不是零宽字符)→ 统一走 `parse.username`;
- * ② 搜索用户列表里 **`tv_aweme_id` 装的是昵称**(id 名违反直觉),`tv_username` 才是 uid;
- * ③ 粉丝列表与搜索列表的同义控件**id 不同**:
- * 搜索列表 `tv_username`/`tv_desc`,粉丝列表 `txt_desc`(uid)/`txt_user_name`(昵称)。
- */
- /** 是否在用户主页:有一个文案形如 "@xxx" 的可点 Button,且下面跟着三组数字 */
- function profileUsernameNode() {
- var hits = findByLabel(className('android.widget.Button'), function (node) {
- var value = layout.text(node);
- return value.charAt(0) === '@' && value.length > 1 &&
- value.length < 40 && topOf(node) < screenHeight() * 0.3;
- });
- return hits.length > 0 ? hits[0] : null;
- }
- /** 用户主页的昵称(在 @xxx 上面那一行) */
- function profileNicknameNode() {
- var userNode = profileUsernameNode();
- if (!userNode) {
- return null;
- }
- var userTop = topOf(userNode);
- var hits = findByLabel(className('android.widget.Button'), function (node) {
- var box = node.bounds();
- return box.top < userTop && box.bottom > userTop - screenHeight() * 0.12 &&
- layout.text(node) !== '' && layout.text(node).charAt(0) !== '@';
- });
- return hits.length > 0 ? hits[0] : null;
- }
- /** 用户页顶部三组数字的标签文案。英文界面实测为这三个词。 */
- var STAT_LABELS = {
- 'Following': 'follow',
- 'Followers': 'fans',
- 'Likes': 'likes',
- '关注': 'follow',
- '粉丝': 'fans',
- '获赞': 'likes',
- 'Abonnements': 'follow',
- 'Abonnés': 'fans',
- "J'aime": 'likes'
- };
- /**
- * 用户页顶部三组数字:`{ follow: 343, fans: 594, likes: 2491 }`。
- *
- * 结构(实测):每个数字是一个 LinearLayout,里面两个 TextView ——
- * 数字在上、标签在下。所以按"标签找容器、再取容器里的数字"读。
- * 注意**没有视频数**:这个页面只显示 关注/粉丝/获赞 三组,这是平台行为。
- */
- function statNumbers() {
- var out = { follow: null, fans: null, likes: null };
- var parse = TikTokAuto.parse;
- var labels = findByLabel(className('android.widget.TextView'), function () { return true; });
- for (var i = 0; i < labels.length; i += 1) {
- var labelText = layout.text(labels[i]);
- var key = STAT_LABELS[labelText];
- if (!key) {
- continue;
- }
- /* 标签必须在页面上部(三组数字区),否则可能是别处长文本里恰好含这个词 */
- if (topOf(labels[i]) > screenHeight() * 0.25) {
- continue;
- }
- var container = parentOf(labels[i]);
- var value = container ? numberInside(container) : null;
- if (value === null) {
- value = parse.count(labelText);
- }
- if (out[key] === null) {
- out[key] = value;
- }
- }
- return out;
- }
- function parentOf(node) {
- try {
- return node.parent();
- } catch (ignored) {
- return null;
- }
- }
- /** 在一个容器里找那个"纯数字"的 TextView,解析成整数 */
- function numberInside(container) {
- var parse = TikTokAuto.parse;
- var found = null;
- var walk = [container];
- var guard = 0;
- while (walk.length > 0 && guard < 30) {
- guard += 1;
- var node = walk.shift();
- if (!node) {
- continue;
- }
- var value = layout.text(node);
- if (value !== '' && /^[\d,.\s]+[kKmMbBwW万萬亿億]?$/.test(value)) {
- var parsed = parse.count(value);
- if (parsed !== null && (found === null || parsed > found)) {
- found = parsed;
- }
- }
- var count = 0;
- try { count = node.childCount(); } catch (ignored) { count = 0; }
- for (var i = 0; i < count; i += 1) {
- try { walk.push(node.child(i)); } catch (ignoredToo) { /* 遍历中失效就跳过 */ }
- }
- }
- return found;
- }
- /** 用户主页的个签:页面中上部一段较长的可点文本(实测是个 Button,不是 TextView) */
- function profileSignatureNode() {
- var hits = findByLabel(className('android.widget.Button'), function (node) {
- var value = layout.text(node);
- var box = node.bounds();
- return value.length >= 4 && value.charAt(0) !== '@' &&
- box.top > screenHeight() * 0.22 && box.top < screenHeight() * 0.34 &&
- (box.right - box.left) > screenWidth() * 0.4;
- });
- return hits.length > 0 ? hits[0] : null;
- }
- /**
- * 私密账号提示。
- * 实测:私密账号的主页会在视频区位置显示一行提示("This account is private" 等)。
- * 这里只在**视频网格明显不存在**时才认这条,避免把正常账号的某条视频文案误判成提示。
- */
- function privateHint() {
- var keys = ['private', "isn't available", 'no videos', 'No videos'];
- var texts = visibleTexts(0);
- for (var i = 0; i < texts.length; i += 1) {
- for (var j = 0; j < keys.length; j += 1) {
- if (String(texts[i]).toLowerCase().indexOf(String(keys[j]).toLowerCase()) >= 0) {
- return texts[i];
- }
- }
- }
- return '';
- }
- /** 用户主页的视频网格格子数(判断"有没有内容"用,不用于视频量门槛) */
- function videoGridCount() {
- var hits = findByLabel(className('android.widget.FrameLayout'), function (node) {
- var point = layout.center(node);
- return point !== null && point.y > screenHeight() * 0.33;
- });
- return hits.length;
- }
- /** 用户主页上的「关注/粉丝」入口(点它打开列表页) */
- function statEntry(which) {
- var wanted = which === 'following' ? 'follow' : 'fans';
- var labels = findByLabel(className('android.widget.TextView'), function () { return true; });
- for (var i = 0; i < labels.length; i += 1) {
- if (STAT_LABELS[layout.text(labels[i])] !== wanted) {
- continue;
- }
- if (topOf(labels[i]) > screenHeight() * 0.25) {
- continue;
- }
- /* 可点的是外层容器,标签自己不可点 */
- return layout.clickableAncestor(labels[i]) || parentOf(labels[i]);
- }
- return null;
- }
- /**
- * 粉丝/关注列表页的页签("Following 343" / "Followers 594" / "Suggested")。
- *
- * 实测这个列表是**从用户页点进来的一张覆盖页**,顶部有三个页签,
- * 可以直接在两个页签之间切换 —— 所以"先收粉丝再收关注"不必来回退。
- */
- function userListTabs() {
- var out = [];
- var hits = findByLabel(className('android.widget.TextView'), function (node) {
- var value = layout.text(node);
- return /^(Following|Followers|Suggested|关注|粉丝|推荐)\b/.test(value) &&
- topOf(node) < screenHeight() * 0.16;
- });
- for (var i = 0; i < hits.length; i += 1) {
- var value = layout.text(hits[i]);
- var key = value.indexOf('Follower') === 0 || value.indexOf('粉丝') === 0 ? 'fans'
- : (value.indexOf('Suggested') === 0 || value.indexOf('推荐') === 0 ? 'suggested' : 'following');
- out.push({
- key: key,
- label: value,
- node: layout.clickableAncestor(hits[i]) || hits[i]
- });
- }
- return out;
- }
- /** 当前列表页选中的是哪个页签(用 selected 属性判,实测可用) */
- function selectedListTab() {
- var tabs = userListTabs();
- for (var i = 0; i < tabs.length; i += 1) {
- var selected = false;
- try {
- selected = tabs[i].node.selected() === true || tabs[i].node.isSelected() === true;
- } catch (ignored) {
- selected = false;
- }
- if (selected) {
- return tabs[i].key;
- }
- }
- /* selected 读不到时退回文案判断:选中态的页签文案与未选中态无差异,
- * 所以这里只能返回 unknown,由调用方用"点完再看内容变没变"确认。 */
- return 'unknown';
- }
- /**
- * 粉丝/关注列表里的每一行。
- *
- * 实测结构:`LinearLayout`(整行可点)> `TextView txt_user_name`(昵称)
- * + `TextView txt_desc`(**uid**)+ `Button ubp`(Follow)。
- * 注意与搜索用户列表**id 不同**(那边是 tv_username/tv_desc)。
- *
- * 返回的 `node` 是行容器,点它进用户页;`x` 是"安全点击的横向位置"——
- * 靠左,避开右侧的 Follow 按钮(实测按钮左边界 x≈872)。
- */
- function userListRows() {
- var parse = TikTokAuto.parse;
- var rows = [];
- var uidNodes = findByLabel(className('android.widget.TextView'), function (node) {
- return resourceIdOf(node).indexOf('txt_desc') >= 0 && layout.text(node) !== '';
- });
- /* 有的版本 uid 放在 txt_user_name、昵称放在 txt_desc,所以按"哪个像 uid"再兜一层 */
- if (uidNodes.length === 0) {
- uidNodes = findByLabel(className('android.widget.TextView'), function (node) {
- return resourceIdOf(node).indexOf('txt_user_name') >= 0 &&
- parse.looksLikeUsername(layout.text(node));
- });
- }
- for (var i = 0; i < uidNodes.length; i += 1) {
- var uidNode = uidNodes[i];
- var uid = parse.username(layout.text(uidNode));
- if (!parse.looksLikeUsername(uid)) {
- continue;
- }
- var row = findRowContainer(uidNode);
- var box = row ? row.bounds() : uidNode.bounds();
- rows.push({
- uid: uid,
- nickname: nicknameOf(uidNode, row),
- node: row || uidNode,
- top: box.top,
- x: Math.round(box.left + (box.right - box.left) * 0.3),
- y: Math.round((box.top + box.bottom) / 2)
- });
- }
- rows.sort(function (a, b) { return a.top - b.top; });
- return rows;
- }
- function resourceIdOf(node) {
- return layout.resourceId(node);
- }
- /** 从 uid 文本往上找"整行"容器(高度落在一行的高度区间内) */
- function findRowContainer(uidNode) {
- var cursor = uidNode;
- var depth = 0;
- while (cursor && depth < 5) {
- var box = cursor.bounds();
- var rowHeight = box.bottom - box.top;
- if (layout.isClickable(cursor) && rowHeight >= screenHeight() * 0.05 &&
- rowHeight <= screenHeight() * 0.12) {
- return cursor;
- }
- cursor = parentOf(cursor);
- depth += 1;
- }
- return layout.clickableAncestor(uidNode) || parentOf(uidNode);
- }
- /** 行里的昵称:找同一容器内的另一个 TextView(不是 uid 那个) */
- function nicknameOf(uidNode, row) {
- if (!row) {
- return '';
- }
- var found = '';
- var walk = [row];
- var guard = 0;
- while (walk.length > 0 && guard < 40) {
- guard += 1;
- var node = walk.shift();
- if (!node) {
- continue;
- }
- var id = resourceIdOf(node);
- var value = layout.text(node);
- if (value !== '' && (id.indexOf('txt_user_name') >= 0 || id.indexOf('tv_aweme_id') >= 0)) {
- found = value;
- break;
- }
- var count = 0;
- try { count = node.childCount(); } catch (ignored) { count = 0; }
- for (var i = 0; i < count; i += 1) {
- try { walk.push(node.child(i)); } catch (ignoredToo) { /* 跳过 */ }
- }
- }
- return found;
- }
- /**
- * 搜索结果的「用户」列表行。
- *
- * 实测结构:`Button`(整行可点)> `TextView tv_username`(uid)
- * + `TextView tv_aweme_id`(**昵称**,id 名违反直觉)+ `TextView tv_desc`("594 followers · 2.5K likes")。
- *
- * 额外好处:`tv_desc` 里就有粉丝数与获赞数 —— 所以按数量筛选时
- * **这一条来源不必逐个打开用户页**(其余来源没有这个信息)。
- */
- function usersResultRows() {
- var parse = TikTokAuto.parse;
- var rows = [];
- var uidNodes = findByLabel(className('android.widget.TextView'), function (node) {
- return resourceIdOf(node).indexOf('tv_username') >= 0 && layout.text(node) !== '';
- });
- for (var i = 0; i < uidNodes.length; i += 1) {
- var uid = parse.username(layout.text(uidNodes[i]));
- if (!parse.looksLikeUsername(uid)) {
- continue;
- }
- var box = uidNodes[i].bounds();
- var desc = '';
- var nickname = '';
- var rowNode = null;
- /* 向上两层找行容器,再在容器里读昵称与计数 */
- var cursor = uidNodes[i];
- for (var up = 0; up < 4 && cursor; up += 1) {
- cursor = parentOf(cursor);
- if (cursor && layout.isClickable(cursor)) {
- rowNode = cursor;
- break;
- }
- }
- if (rowNode) {
- var children = findByLabelInside(rowNode, className('android.widget.TextView'));
- for (var j = 0; j < children.length; j += 1) {
- var id = resourceIdOf(children[j]);
- var value = layout.text(children[j]);
- if (id.indexOf('tv_aweme_id') >= 0) {
- nickname = value;
- } else if (id.indexOf('tv_desc') >= 0) {
- desc = value;
- }
- }
- var rowBox = rowNode.bounds();
- box = { left: rowBox.left, top: rowBox.top, right: rowBox.right, bottom: rowBox.bottom };
- }
- rows.push({
- uid: uid,
- nickname: nickname,
- node: rowNode || uidNodes[i],
- top: box.top,
- fans: parseFollowerLine(desc, 'followers'),
- likes: parseFollowerLine(desc, 'likes'),
- x: Math.round(box.left + (box.right - box.left) * 0.3),
- y: Math.round((box.top + box.bottom) / 2)
- });
- }
- rows.sort(function (a, b) { return a.top - b.top; });
- return rows;
- }
- /** 在一个节点内部找符合条件的子孙(不含自身之外的范围) */
- function findByLabelInside(rootNode, selector) {
- var out = [];
- var collection = selector.find();
- for (var i = 0; i < collection.size(); i += 1) {
- var node = collection.get(i);
- var cursor = node;
- var depth = 0;
- var inside = false;
- while (cursor && depth < 12) {
- if (cursor === rootNode) {
- inside = true;
- break;
- }
- cursor = parentOf(cursor);
- depth += 1;
- }
- if (inside) {
- out.push(node);
- }
- }
- return out;
- }
- /**
- * 从 "594 followers · 2.5K likes" 里分别抠出两个数。
- * 分隔符实测是 " · "(中点),但不同语言/版本可能不同,所以按**关键词**切而不是按分隔符。
- */
- function parseFollowerLine(text, which) {
- var parse = TikTokAuto.parse;
- if (!text) {
- return null;
- }
- var parts = String(text).split(/[·•|]/);
- for (var i = 0; i < parts.length; i += 1) {
- var part = parts[i];
- var lower = part.toLowerCase();
- if (which === 'followers' && (lower.indexOf('follower') >= 0 || lower.indexOf('粉丝') >= 0)) {
- return parse.count(part);
- }
- if (which === 'likes' && (lower.indexOf('like') >= 0 || lower.indexOf('获赞') >= 0)) {
- return parse.count(part);
- }
- }
- return null;
- }
- /** 播放页底部的作者 uid(实测:`Button l2m` 的 text = "@2131823375") */
- function playerAuthorUid() {
- var parse = TikTokAuto.parse;
- var hits = findByLabel(className('android.widget.Button'), function (node) {
- var value = layout.text(node);
- return value.charAt(0) === '@' && parse.looksLikeUsername(value);
- });
- return hits.length > 0 ? parse.username(layout.text(hits[0])) : '';
- }
- /**
- * 评论区里的每一条:**只有昵称,没有 uid**(实测 2026-10-20)。
- *
- * 所以"从评论区取评论者"必须逐个点头像/昵称进用户页 —— 成本比列表高一个数量级。
- * 这里返回每个人可点的节点(头像 ImageView `blm`)与昵称,供上层决定怎么进。
- */
- function commentRowers() {
- var out = [];
- var avatars = findByLabel(className('android.widget.ImageView'), function (node) {
- return resourceIdOf(node).indexOf('blm') >= 0;
- });
- for (var i = 0; i < avatars.length; i += 1) {
- var box = avatars[i].bounds();
- /* 头像必须在评论列表区域内(屏幕中下部),不是视频右上角的作者头像 */
- if (box.top < screenHeight() * 0.38) {
- continue;
- }
- out.push({
- node: avatars[i],
- top: box.top,
- x: Math.round((box.left + box.right) / 2),
- y: Math.round((box.top + box.bottom) / 2)
- });
- }
- out.sort(function (a, b) { return a.top - b.top; });
- return out;
- }
- /** 结果页的某个页签当前是否选中(Users 页签用) */
- function resultTabSelected(name) {
- var node = resultTab(name);
- if (!node) {
- return false;
- }
- try {
- return node.selected() === true || node.isSelected() === true;
- } catch (ignored) {
- return false;
- }
- }
- /**
- * ID 提取这条链路自己在用的页面判定。
- *
- * **刻意与 pageState() 分开**:pageState 是视频链路的判据,
- * 往里加分支就有可能把"播放页 vs 结果页"的老判定带歪(方案 §12 明确要求回归)。
- * 新开一个函数,视频链路一行都不用动。
- *
- * @returns 'profile' | 'user-list' | 'users-results' | 'comment-panel' | 'search' | 'feed' | 'other'
- */
- function idPageState() {
- if (commentPanelOpen()) {
- return 'comment-panel';
- }
- if (profileUsernameNode() !== null) {
- return 'profile';
- }
- if (userListTabs().length >= 2 && userListRows().length > 0) {
- return 'user-list';
- }
- if (usersResultRows().length > 0) {
- return 'users-results';
- }
- if (searchInput() !== null) {
- return 'search';
- }
- if (bottomNavPresent()) {
- return 'feed';
- }
- return 'other';
- }
- // ---------------------------------------------------------------- 页面状态
- /**
- * 判定当前处于哪个页面。
- * 顺序即优先级:播放页最特征化先判,信息流最宽松最后判。
- */
- function pageState() {
- // 评论面板要最先判:面板一盖上来,播放页的返回箭头就被遮住了,
- // 于是 onPlayerPage() 会变 false,整个页面掉进 unknown 再也出不来
- // (实测踩到:脚本自己打开评论列表校验后没关掉,下一轮就彻底迷路)。
- if (commentPanelOpen()) {
- return 'comment-panel';
- }
- if (onPlayerPage()) {
- return 'player';
- }
- if (resultTabStripPresent()) {
- return 'results';
- }
- // 搜索页判据:顶部有输入框,且**没有**结果页的页签条。
- // 早先还要求存在搜索历史行,结果"历史为空 / 已被清空"时整页被判成 unknown,
- // 于是 ensureFeed 一路僵住。页签条的有无已经足够区分搜索页和结果页。
- if (searchInput() !== null && !resultTabStripPresent() && !bottomNavPresent()) {
- return 'search';
- }
- if (searchEntry() !== null && bottomNavPresent()) {
- return 'feed';
- }
- return 'unknown';
- }
- /** 排障用:把当前可见的关键节点打出来 */
- function describeScreen(tag) {
- var nodes = [];
- var classes = ['android.widget.Button', 'android.widget.EditText', 'android.widget.ImageView', 'android.widget.TextView'];
- for (var i = 0; i < classes.length; i += 1) {
- var collection = className(classes[i]).find();
- for (var j = 0; j < collection.size(); j += 1) {
- if (layout.isVisible(collection.get(j))) {
- nodes.push(collection.get(j));
- }
- }
- }
- logger.info(tag || 'screen', layout.describe(nodes, 40));
- }
- return {
- PKG: PKG,
- pageState: pageState,
- searchEntry: searchEntry,
- searchInput: searchInput,
- searchSubmit: searchSubmit,
- searchHistoryPresent: searchHistoryPresent,
- bottomNavPresent: bottomNavPresent,
- profileNav: profileNav,
- resultTab: resultTab,
- resultTabStripPresent: resultTabStripPresent,
- videoCards: videoCards,
- commentButton: commentButton,
- likeButton: likeButton,
- likedButton: likedButton,
- likeIcon: likeIcon,
- likeState: likeState,
- followButton: followButton,
- authorAvatar: authorAvatar,
- feedSignature: feedSignature,
- parseCommentCountLabel: parseCommentCountLabel,
- followState: followState,
- onPlayerPage: onPlayerPage,
- photoPost: photoPost,
- backButton: backButton,
- commentCount: commentCount,
- inlineInput: inlineInput,
- panelInput: panelInput,
- toolRowAnchor: toolRowAnchor,
- sendButton: sendButton,
- listCommentCount: listCommentCount,
- commentPanelOpen: commentPanelOpen,
- commentListContains: commentListContains,
- visibleTexts: visibleTexts,
- describeScreen: describeScreen,
- /* —— ID 提取(2026-10-20 真机实测锚点,见上面的长注释) —— */
- profileUsernameNode: profileUsernameNode,
- profileNicknameNode: profileNicknameNode,
- profileSignatureNode: profileSignatureNode,
- statNumbers: statNumbers,
- statEntry: statEntry,
- privateHint: privateHint,
- videoGridCount: videoGridCount,
- userListTabs: userListTabs,
- selectedListTab: selectedListTab,
- userListRows: userListRows,
- usersResultRows: usersResultRows,
- playerAuthorUid: playerAuthorUid,
- commentRowers: commentRowers,
- resultTabSelected: resultTabSelected,
- idPageState: idPageState
- };
- }());
- /* ============================== src/player.js ============================== */
- /**
- * player.js — 「看视频」这件事:播放存活探测 + 有界观看
- *
- * 需求原话是"随机观看 10-35 秒,且不单靠 sleep"。
- * 为什么裸 sleep 不行:sleep(20000) 只保证**墙上时钟**走了 20 秒,
- * 它不关心这 20 秒里视频是否卡在加载、是否弹了窗、是否被切走。
- * 网络一抖,sleep 出来的"观看"就是假的。
- *
- * 这里的做法:把"观看"实现成一个**带守卫的截止时间循环**——
- * 1. 先等"播放真的开始"(有界等待,而不是假定它已经开始);
- * 2. 观看期间持续做两件事:
- * a) 校验"还在播放页"(页面身份漂移就立刻记账,连续漂移就放弃并上报);
- * b) 按固定节拍采样**播放存活证据**,连续多次无证据即判定卡顿,尝试一次唤醒;
- * 3. 循环里那个 sleep 是**轮询节拍**(多久检查一次),不是"等待多少秒"。
- *
- * 播放存活证据(按可用性排序,可配置):
- * - useAudio : AudioManager.isMusicActive() —— 不依赖截图权限,实测可用
- * - usePixels: captureScreen() 两点像素差 —— 最强,但 Android 14 需要 MediaProjection 授权
- * 两者都不可用时,脚本**不会假装成功**:会退化为"页面守卫",并在结果里
- * 明确标记 probeMode='page-guard-only',让上层知道这次的"观看"可信度较低。
- *
- * 依赖:TikTokAuto.logger、TikTokAuto.layout、TikTokAuto.tiktok、TikTokAuto.wait、TikTokAuto.ui
- */
- TikTokAuto.player = (function () {
- 'use strict';
- var PIXEL_SAMPLE_POINTS = [
- [0.5, 0.35], [0.5, 0.5], [0.5, 0.65], [0.25, 0.45], [0.75, 0.45]
- ];
- // ------------------------------------------------------------------ 证据源
- function createAudioProbe() {
- var manager = null;
- try {
- if (typeof context !== 'undefined' && context) {
- manager = context.getSystemService('audio');
- }
- } catch (error) {
- manager = null;
- }
- if (!manager) {
- return null;
- }
- return {
- name: 'audio',
- read: function () {
- try {
- return manager.isMusicActive() === true;
- } catch (error) {
- return false;
- }
- }
- };
- }
- function createPixelProbe() {
- var first = null;
- function grab() {
- var image = captureScreen();
- if (!image) {
- return null;
- }
- var width = image.getWidth();
- var height = image.getHeight();
- var samples = [];
- for (var i = 0; i < PIXEL_SAMPLE_POINTS.length; i += 1) {
- var x = Math.floor(width * PIXEL_SAMPLE_POINTS[i][0]);
- var y = Math.floor(height * PIXEL_SAMPLE_POINTS[i][1]);
- samples.push(image.pixel(x, y));
- }
- return samples.join(',');
- }
- try {
- first = grab();
- } catch (error) {
- return null; // 没有 MediaProjection 授权,直接放弃这条证据源
- }
- if (first === null) {
- return null;
- }
- return {
- name: 'pixels',
- read: function () {
- try {
- var now = grab();
- return now !== null && now !== first;
- } catch (error) {
- return false;
- }
- }
- };
- }
- /**
- * 组装可用的探测源。
- * 返回的 probeMode 会一路带到最终结果里——**不要**把降级悄悄藏起来。
- */
- function createProbe(config) {
- var sources = [];
- if (config.probe.useAudio) {
- var audio = createAudioProbe();
- if (audio) {
- sources.push(audio);
- }
- }
- if (config.probe.usePixels) {
- var pixels = createPixelProbe();
- if (pixels) {
- sources.push(pixels);
- }
- }
- return {
- mode: sources.length === 0 ? 'page-guard-only' : sources.map(function (s) { return s.name; }).join('+'),
- degraded: sources.length === 0,
- /** 任一证据源报"在动"就算活着 */
- alive: function () {
- for (var i = 0; i < sources.length; i += 1) {
- if (sources[i].read()) {
- return true;
- }
- }
- return false;
- }
- };
- }
- // ------------------------------------------------------------------ 等待播放
- /** 等播放真正开始(而不是假定点开就在播) */
- function waitPlaybackStart(config, step) {
- var logger = TikTokAuto.logger;
- var probe = createProbe(config);
- if (probe.degraded) {
- logger.warn(step, '没有可用的播放证据源(无音频/无截图权限),退化为页面守卫模式');
- return { started: false, probe: probe };
- }
- var started = TikTokAuto.wait.until(function () {
- return probe.alive() ? true : null;
- }, config.timeouts.playbackStartMs, config.probe.pollTickMs);
- if (started) {
- logger.info(step, '检测到播放已开始(证据源 ' + probe.mode + ')');
- } else {
- logger.warn(step, '等待 ' + config.timeouts.playbackStartMs + 'ms 未检测到播放证据,仍继续(可能是静音视频)');
- }
- return { started: started === true, probe: probe };
- }
- // ------------------------------------------------------------------ 观看
- function randomInt(min, max) {
- return Math.floor(Math.random() * (max - min + 1)) + min;
- }
- /**
- * 观看视频。
- *
- * @param config 生效配置
- * @param options {
- * step,
- * guard, // 页面守卫,默认"还在播放页"(见下)
- * minMs/maxMs // 覆盖 config.watch 的观看时长区间
- * }
- * @returns 观看报告(对象,不是布尔——上层需要知道"为什么/可信度如何")
- *
- * ★ 为什么 `guard` 必须可注入(2026-10-28 真机实测):
- * 默认守卫是 `onPlayerPage()`,而它的判据里要求**有返回箭头、没有底部导航栏**——
- * 这两条在首页信息流上正好相反(没有返回箭头、有 Home/Profile 导航)。
- * 所以在信息流里跑 watch 时,默认守卫会恒为 false,3 次采样(约 3 秒)就
- * 以 `left-player-page` 提前退出,一个视频都看不完。
- * 养号传 `pageState() === 'feed'` 进来即可,视频链路不传、行为完全不变。
- */
- function watch(config, options) {
- var settings = options || {};
- var step = settings.step || 'watch';
- var logger = TikTokAuto.logger;
- var tiktok = TikTokAuto.tiktok;
- var guard = settings.guard || function () { return tiktok.onPlayerPage(); };
- var minMs = settings.minMs === undefined ? config.watch.minMs : settings.minMs;
- var maxMs = settings.maxMs === undefined ? config.watch.maxMs : settings.maxMs;
- var targetMs = randomInt(minMs, maxMs);
- var start = waitPlaybackStart(config, step);
- var probe = start.probe;
- var beganAt = Date.now();
- var deadline = beganAt + targetMs;
- var tickMs = config.probe.pollTickMs;
- var nextLivenessAt = beganAt + config.probe.livenessIntervalMs;
- var stalls = 0;
- var offPage = 0;
- var maxOffPage = 3;
- var nudges = 0;
- var aborted = null;
- logger.info(step, '开始观看,计划 ' + targetMs + 'ms(区间 ' +
- minMs + '-' + maxMs + 'ms),证据源=' + probe.mode);
- while (Date.now() < deadline) {
- // (a) 页面身份守卫:不在播放页就不是"在看视频"
- if (!guard()) {
- offPage += 1;
- logger.warn(step, '已离开播放页(第 ' + offPage + ' 次检测)');
- if (offPage >= maxOffPage) {
- aborted = 'left-player-page';
- break;
- }
- } else {
- offPage = 0;
- }
- // (b) 播放存活采样
- if (Date.now() >= nextLivenessAt) {
- nextLivenessAt = Date.now() + config.probe.livenessIntervalMs;
- if (!probe.degraded) {
- if (probe.alive()) {
- stalls = 0;
- } else {
- stalls += 1;
- logger.warn(step, '未检测到播放证据(连续 ' + stalls + '/' + config.probe.stallTolerance + ')');
- if (stalls >= config.probe.stallTolerance) {
- // 只尝试一次唤醒,不无限戳屏幕——那会把页面点出别的东西
- if (nudges < 1) {
- nudge(step);
- nudges += 1;
- stalls = 0;
- } else {
- logger.warn(step, '判定卡顿且唤醒无效,提前结束观看');
- aborted = 'stalled';
- break;
- }
- }
- }
- }
- }
- TikTokAuto.wait.tick(tickMs);
- }
- var actualMs = Date.now() - beganAt;
- var report = {
- requestedMs: targetMs,
- actualMs: actualMs,
- probeMode: probe.mode,
- degraded: probe.degraded,
- nudges: nudges,
- aborted: aborted,
- completed: aborted === null && actualMs >= targetMs - tickMs
- };
- if (report.completed) {
- logger.info(step, '观看完成 ' + actualMs + 'ms / 目标 ' + targetMs + 'ms');
- } else {
- logger.warn(step, '观看未按计划完成:' + JSON.stringify(report));
- }
- return report;
- }
- /** 轻点视频区域一次,尝试恢复播放(不会点到右侧动作栏) */
- function nudge(step) {
- var layout = TikTokAuto.layout;
- var point = { x: Math.floor(layout.screen().width * 0.5), y: Math.floor(layout.screen().height * 0.4) };
- TikTokAuto.logger.info(step, '尝试轻点画面恢复播放 @(' + point.x + ',' + point.y + ')');
- TikTokAuto.ui.tapPoint(point.x, point.y);
- TikTokAuto.wait.tick(300);
- }
- /** 等播放页就绪(点开卡片之后、开始计时之前) */
- function waitPlayerReady(config, step) {
- var logger = TikTokAuto.logger;
- var tiktok = TikTokAuto.tiktok;
- /* 先花最多 4 秒分辨"打开的是不是视频"。
- *
- * 搜索结果里混着照片轮播帖,它的信息行是 `Photo · 1w ago`,**永远不会有播放页**,
- * 而 `onPlayerPage()` 也认不出来 —— 不提前判掉就要白等满 playerReadyMs(20 秒)。
- * 真机实测:一轮 4 个目标里撞到 1 个照片帖,就白白浪费掉 20 秒。
- *
- * 认出是视频(或两种都不是)之后仍走原来的等待,不改变正常路径的行为。
- */
- var kind = TikTokAuto.wait.waitFor(step + ':kind', function () {
- if (tiktok.onPlayerPage()) {
- return 'player';
- }
- if (tiktok.photoPost()) {
- return 'photo';
- }
- return null;
- }, 4000, config.probe.pollTickMs);
- if (kind === 'photo') {
- logger.info(step, '打开的是照片帖(没有播放页),跳过');
- return false;
- }
- var ready = TikTokAuto.wait.waitFor(step, function () {
- return tiktok.onPlayerPage() ? true : null;
- }, config.timeouts.playerReadyMs, config.probe.pollTickMs);
- if (ready) {
- TikTokAuto.wait.waitQuiet(function () {
- return TikTokAuto.layout.signature();
- }, { maxMs: 3000, tickMs: 250, stableTimes: 3 });
- }
- return ready === true;
- }
- return {
- createProbe: createProbe,
- waitPlaybackStart: waitPlaybackStart,
- waitPlayerReady: waitPlayerReady,
- watch: watch,
- randomInt: randomInt
- };
- }());
- /* ============================== src/comment.js ============================== */
- /**
- * comment.js — 评论:挑文案 → 打开面板 → 输入 → 发送 → **验证真的发出去了**
- *
- * 关键设计:发送后必须**验证**,而且要"多条证据同时成立"才敢说成功。
- * 只看着"面板关掉了"就宣布成功,是这类脚本最典型的假阳性来源
- * (面板也可能因为别的原因关掉,而评论被平台拒了)。
- *
- * 实测有效的两条证据:
- * 1. 评论计数自增: "Read or add comments. 149 comments" → "150 comments"
- * 2. 输入框被清空并恢复占位符 "Add comment...",且面板收起
- * 默认以"计数自增"为强证据;计数有延迟时退回到第 2 条(可用
- * comments.strictPostCheck 强制只认第 1 条)。
- *
- * 依赖:TikTokAuto.logger、TikTokAuto.layout、TikTokAuto.ui、TikTokAuto.wait、TikTokAuto.tiktok
- */
- TikTokAuto.comment = (function () {
- 'use strict';
- var PLACEHOLDER = 'Add comment...';
- /** 从评论池里随机挑一条,尽量不重复(池子用完了就允许重复并告警) */
- function pickText(config, used, step) {
- var pool = config.comments.pool;
- var candidates = [];
- for (var i = 0; i < pool.length; i += 1) {
- if (!config.comments.avoidRepeatWithinRun || used.indexOf(pool[i]) === -1) {
- candidates.push(pool[i]);
- }
- }
- if (candidates.length === 0) {
- TikTokAuto.logger.warn(step || 'comment', '评论池已用尽,本轮允许重复');
- candidates = pool;
- }
- return candidates[Math.floor(Math.random() * candidates.length)];
- }
- /** 打开评论面板:点内联输入框 / 点评论按钮 → 等面板输入框出现 */
- function openPanel(config, step) {
- var story = step || 'openCommentPanel';
- var logger = TikTokAuto.logger;
- var tiktok = TikTokAuto.tiktok;
- if (tiktok.panelInput() !== null) {
- logger.info(story, '评论面板已经是打开状态');
- return true;
- }
- /* 两个入口,按页面形态选:
- * · 播放页(从搜索结果/主页点进来的)底部有一条内联输入框 "Add comment..."
- * · **首页信息流底部是导航栏**(Home/Friends/Inbox/Profile),没有内联输入框
- * —— 只能点右侧那个 "Read or add comments. N comments" 按钮。
- * 以前只有第一种,于是养号在信息流里发评论会直接报"找不到评论入口"。
- */
- var entry = tiktok.inlineInput();
- var via = 'inline-input';
- if (!entry) {
- entry = tiktok.commentButton();
- via = 'comment-button';
- }
- if (!entry) {
- logger.error(story, '找不到评论入口(内联输入框与评论按钮都没有)');
- return false;
- }
- logger.info(story, '打开评论面板(入口=' + via + ')');
- var ready = false;
- if (via === 'comment-button') {
- ready = openByCommentButton(config, entry, story);
- if (!ready) {
- /* 换一种点击手段再试 —— 入口节点可能已经失效(页面重排过) */
- entry = tiktok.commentButton();
- if (entry) {
- TikTokAuto.ui.clickNode(entry, story);
- }
- }
- } else {
- TikTokAuto.ui.clickNode(entry, story);
- }
- if (ready) {
- return true;
- }
- var opened = TikTokAuto.wait.waitFor(story, function () {
- return tiktok.panelInput() !== null ? true : null;
- }, config.timeouts.panelReadyMs, config.probe.pollTickMs);
- if (!opened) {
- /* 把当时所有输入框的位置记下来。"点了没反应"和"面板开了但认不出输入框"
- * 是两种完全不同的故障,而在日志里它们长得一模一样 —— 没有这行就只能靠猜。 */
- var seen = [];
- try {
- var ets = className('android.widget.EditText').find();
- for (var e = 0; e < ets.size(); e += 1) {
- var box = ets.get(e).bounds();
- seen.push(box.top + '~' + box.bottom);
- }
- logger.warn(story, '面板没打开;此刻 EditText ' + ets.size() +
- ' 个 @ ' + seen.join(', ') + '(判据区间 ' +
- Math.floor(TikTokAuto.layout.screen().height * 0.45) + '~' +
- Math.floor(TikTokAuto.layout.screen().height * 0.85) + ')');
- } catch (ignored) {
- logger.warn(story, '面板没打开;连输入框都列举失败');
- }
- /* 有的视频关闭了评论 / 点到了别的地方。把话说清楚,
- * 否则上层只看到"评论发不出去"却不知道为什么。 */
- logger.warn(story, '点了入口但面板没打开(该视频可能关闭了评论)');
- }
- return opened === true;
- }
- /**
- * 通过点右侧那个 "Read or add comments" 按钮打开面板(首页信息流里唯一的入口)。
- *
- * ★ 两个都必须做,缺一个就会"点击成功但面板死活不出来"(2026-10-28 真机实测):
- *
- * ① **点之前先等页面安静**。watch 刚结束、或者面板刚收起时,这一下点击会被吞掉。
- * 症状极其迷惑:点击返回成功、`panelInput()` 却 8 秒都等不到;而隔一秒手动点
- * 同一个坐标,400ms 就出面板。这和 `verifyInCommentList` 里"发布后立刻点角标
- * 会被吞"是同一类问题,处理手法也一样(settle + 重试)。
- *
- * ② **不能用 node.click()**。那个无障碍 ACTION_CLICK 返回 true,但 TikTok 不响应。
- * 这一竖条按钮又窄(宽 128px)位置又固定,坐标点击才可靠。
- */
- function openByCommentButton(config, entry, story) {
- var logger = TikTokAuto.logger;
- var tiktok = TikTokAuto.tiktok;
- var attempts = 0;
- var maxAttempts = 2;
- var ready = false;
- while (!ready && attempts < maxAttempts) {
- attempts += 1;
- TikTokAuto.wait.waitQuiet(function () {
- return TikTokAuto.layout.signature();
- }, { maxMs: 1500, tickMs: 200, stableTimes: 2 });
- var point = TikTokAuto.layout.center(entry);
- if (point) {
- logger.info(story, '第 ' + attempts + ' 次坐标点击评论入口 @(' +
- point.x + ',' + point.y + ')');
- TikTokAuto.ui.tapPoint(point.x, point.y);
- } else {
- logger.warn(story, '算不出评论入口的中心点,改用无障碍点击');
- TikTokAuto.ui.clickNode(entry, story);
- }
- ready = TikTokAuto.wait.waitFor(story, function () {
- return tiktok.panelInput() !== null ? true : null;
- }, 4000, config.probe.pollTickMs) === true;
- if (!ready) {
- /* 页面可能重排过,旧节点已失效 —— 重新找一个再试 */
- entry = tiktok.commentButton();
- if (!entry) {
- logger.warn(story, '重试时找不到评论入口了');
- break;
- }
- }
- }
- return ready;
- }
- /** 收起面板(只在确认面板处于打开状态时调用,绝不无脑 back) */
- function closePanel(step) {
- var tiktok = TikTokAuto.tiktok;
- if (!tiktok.commentPanelOpen()) {
- return false;
- }
- TikTokAuto.logger.info(step || 'closeCommentPanel', '收起评论面板');
- back();
- TikTokAuto.wait.until(function () {
- return tiktok.commentPanelOpen() ? null : true;
- }, 4000, 200);
- return true;
- }
- /**
- * 发送一条评论。
- *
- * @returns {ok, via, text, countBefore, countAfter, reason}
- */
- function post(config, text, step) {
- var story = step || 'postComment';
- var logger = TikTokAuto.logger;
- var tiktok = TikTokAuto.tiktok;
- var countBefore = tiktok.commentCount();
- var before = { countBefore: countBefore };
- if (!openPanel(config, story)) {
- return { ok: false, via: 'panel-not-opened', text: text, reason: '评论面板未打开' };
- }
- var input = tiktok.panelInput();
- var typed = TikTokAuto.ui.typeInto(input, text, story);
- if (!typed.ok) {
- return { ok: false, via: 'type-failed', text: text, reason: '输入框回读不一致' };
- }
- if (config.runtime.dryRun) {
- logger.warn(story, 'dryRun 模式:已输入但**不发送**,清空并收起面板。待发文案=' + JSON.stringify(text));
- var restore = tiktok.panelInput();
- if (restore) {
- restore.setText('');
- }
- TikTokAuto.wait.tick(300);
- closePanel(story);
- return { ok: true, via: 'dry-run', text: text, countBefore: countBefore, countAfter: countBefore, skipped: true };
- }
- var button = tiktok.sendButton(config);
- if (!button) {
- return { ok: false, via: 'send-button-not-found', text: text, reason: '定位不到发送按钮' };
- }
- /* ★ 发送键必须用**坐标点击**(2026-10-28 实测):
- * 它是个纯图标按钮,无障碍 ACTION_CLICK 打上去会落到祖先容器 ——
- * 实测点到了屏幕正中 (540,1465),那是评论列表的内容区,等于什么都没发。
- * 而它形状固定、位置刚从输入框推出来,坐标点最准。 */
- var sendPoint = TikTokAuto.layout.center(button);
- if (sendPoint) {
- logger.info(story, '坐标点击发送键 @(' + sendPoint.x + ',' + sendPoint.y + ')');
- TikTokAuto.ui.tapPoint(sendPoint.x, sendPoint.y);
- } else {
- TikTokAuto.ui.clickNode(button, story);
- }
- return awaitOutcome(config, text, before, story);
- }
- /**
- * 等待并判定"评论是否真的发出去了",输出**带置信度**的结论。
- *
- * 证据分三级,从强到弱:
- * 高 comment-list —— 评论列表里真的出现了这条文案(地面真相)
- * 中 comment-count —— 列表头部计数自增
- * 低 panel-closed —— 面板收起 + 输入框复位为占位符
- *
- * 为什么不能只用计数(实测踩到):视频页那个评论角标不一定会立刻刷新,
- * 明明发成功却读回旧值。反过来,只看"面板关了"又可能被平台拒评骗过。
- * 所以用 waitFor 逐级升级证据,并把最终置信度**如实**报给上层。
- */
- function awaitOutcome(config, text, before, step) {
- var logger = TikTokAuto.logger;
- var tiktok = TikTokAuto.tiktok;
- // 必要条件:面板收起且输入框复位
- var closed = TikTokAuto.wait.waitFor(step, function () {
- if (tiktok.commentPanelOpen()) {
- return null;
- }
- /* 播放页要等内联输入框真的回来 —— 这比"面板关了"更严(说明回到了视频页)。
- * 但**信息流底部是导航栏、本来就没有内联输入框**:那里再等它就永远等不到,
- * 明明发成功了也会被判成"面板没有收起"。所以那种形态只认"回到了 feed"。
- */
- if (tiktok.inlineInput() !== null) {
- return true;
- }
- return tiktok.pageState() === 'feed' ? true : null;
- }, config.timeouts.postMs, config.probe.pollTickMs);
- if (closed === null) {
- logger.error(step, '发送后面板未收起,疑似未发布成功');
- closePanel(step);
- return { ok: false, via: 'panel-still-open', text: text, reason: '发送后面板没有收起' };
- }
- // 中级证据:评论数自增。
- // **必须"等"而不是"看一眼"**——实测角标有延迟:发布返回后立刻读还是旧值,
- // 大约 1~2 秒后才 +1。看一眼就下结论的话,明明发成功了也会被判成弱证据。
- var counted = TikTokAuto.wait.waitFor(step, function () {
- var after = tiktok.commentCount();
- if (after === null || before.countBefore === null) {
- return null;
- }
- return after !== before.countBefore ? after : null;
- }, config.timeouts.countRefreshMs, 400);
- if (counted !== null) {
- logger.info(step, '评论已发布(计数自增)' + before.countBefore + '→' + counted + ' ' + JSON.stringify(text));
- return {
- ok: true, via: 'comment-count', confidence: 'medium', text: text,
- countBefore: before.countBefore, countAfter: counted
- };
- }
- // 高级证据:去评论列表里找这条文案(地面真相)
- if (config.comments.verifyInList) {
- var inList = verifyInCommentList(config, text, before, step);
- if (inList) {
- logger.info(step, '评论已发布(列表确认 via=' + inList.via + ')内容=' +
- JSON.stringify(text) + ' 计数=' + inList.count);
- return {
- ok: true, via: 'comment-list', confidence: 'high', text: text,
- countBefore: before.countBefore, countAfter: inList.count
- };
- }
- if (config.comments.strictPostCheck) {
- logger.error(step, '严格校验开启但评论列表里找不到该文案,判定为失败');
- return {
- ok: false, via: 'not-found-in-list', text: text,
- countBefore: before.countBefore, countAfter: tiktok.commentCount(),
- reason: '评论列表里找不到刚发布的文案'
- };
- }
- }
- logger.warn(step, '评论已发布,但只有弱证据(面板收起)。计数=' +
- before.countBefore + '→' + tiktok.commentCount() + ' 内容=' + JSON.stringify(text));
- return {
- ok: true, via: 'panel-closed-and-input-reset', confidence: 'low', text: text,
- countBefore: before.countBefore, countAfter: tiktok.commentCount()
- };
- }
- /**
- * 打开评论列表,找这条评论是否在列表里。
- * 返回 {count} 或 null。无论成败都会把面板收回去,不污染后续步骤。
- *
- * 为什么要"重试 + 等页面安静":紧跟着发布之后点评论角标,面板还在收起动画里,
- * 这一下点击会被吞掉(实测:列表死活不出来,等到超时)。
- * 所以每次点击前先等签名稳定,并且允许最多点三次。
- */
- function verifyInCommentList(config, text, before, step) {
- var tiktok = TikTokAuto.tiktok;
- var logger = TikTokAuto.logger;
- var clickAttempts = 0;
- var maxClicks = 3;
- var deadline = Date.now() + config.timeouts.panelReadyMs;
- while (Date.now() < deadline) {
- var headerCount = tiktok.listCommentCount();
- if (headerCount !== null) {
- // 最强:列表里真的出现了这条文案
- if (tiktok.commentListContains(text)) {
- closePanel(step);
- return { via: 'text-in-list', count: headerCount };
- }
- // 次强:列表头部计数变了(说明确实多了一条)
- if (before.countBefore !== null && headerCount !== before.countBefore) {
- closePanel(step);
- return { via: 'list-count', count: headerCount };
- }
- } else if (clickAttempts < maxClicks) {
- settle();
- var button = tiktok.commentButton();
- if (!button) {
- logger.warn(step, '找不到评论角标,跳过列表校验');
- return null;
- }
- clickAttempts += 1;
- logger.info(step, '第 ' + clickAttempts + ' 次打开评论列表');
- TikTokAuto.ui.clickNode(button, step);
- }
- TikTokAuto.wait.tick(400);
- }
- logger.warn(step, '评论列表校验未通过(点了 ' + clickAttempts + ' 次,列表计数=' +
- tiktok.listCommentCount() + ',发布前计数=' + before.countBefore + ')');
- closePanel(step);
- return null;
- }
- /** 等页面签名稳定下来(收起动画结束),再去做下一次点击 */
- function settle() {
- TikTokAuto.wait.waitQuiet(function () {
- return TikTokAuto.layout.signature();
- }, { maxMs: 1500, tickMs: 200, stableTimes: 2 });
- }
- return {
- PLACEHOLDER: PLACEHOLDER,
- pickText: pickText,
- openPanel: openPanel,
- closePanel: closePanel,
- post: post,
- awaitOutcome: awaitOutcome,
- verifyInCommentList: verifyInCommentList
- };
- }());
- /* ============================== src/interact.js ============================== */
- /**
- * interact.js — 播放页的两个轻量互动动作:**点赞** 与 **关注作者**
- *
- * ## 为什么单独一个模块
- * 它们和评论一样是"在播放页上点一下、然后验证真的生效",但比评论简单得多:
- * 不用开面板、不用输入、不用回读。所以不塞进 comment.js(那是评论发布的地盘)。
- *
- * ## 实测锚点(2026-10-28 / TikTok 47.1.4 / 1080×1920)
- *
- * | 元素 | 类 | content-desc | 备注 |
- * |---|---|---|---|
- * | 点赞(未赞) | Button | `Like video. 2.4M likes` | 里面的 ImageView selected=false |
- * | 点赞(已赞) | Button | **`Video liked`** | ImageView selected=**true** |
- * | 关注 | Button | `Follow <作者名>` | 已关注时**整个按钮消失** |
- * | 作者头像 | ImageView | `<作者名> profile` | 用来区分"已关注"和"页面没渲染" |
- *
- * 点赞按钮和分享按钮**共用同一个 resource-id**(`g8g`),所以只能靠 desc 区分,
- * 不要试图用 id 定位。
- *
- * ## 两条安全原则
- *
- * ① **状态不明就不点。** 读不到"当前是否已赞"时,点一下可能把已赞的取消掉 ——
- * 和用户意图正好相反。宁可跳过并如实记录。
- * ② **点了必须验。** 点击成功不代表生效(可能被弹窗挡、可能点偏)。
- * 只有状态真的变了才算数,否则记失败,不虚报。
- *
- * 依赖:TikTokAuto.tiktok / ui / wait / logger
- */
- TikTokAuto.interact = (function () {
- 'use strict';
- function logger() { return TikTokAuto.logger; }
- /**
- * 给当前视频点赞。
- *
- * @returns { ok, skipped?, via, reason? }
- */
- function like(step) {
- var tiktok = TikTokAuto.tiktok;
- var tag = step || 'like';
- var state = tiktok.likeState();
- if (state === 'liked') {
- return { ok: true, skipped: true, via: 'already-liked', reason: '这个视频已经点过赞' };
- }
- if (state !== 'unliked') {
- return {
- ok: false, via: 'state-unknown',
- reason: '读不到点赞状态,跳过(不盲点:可能是已赞,点一下会变成取消)'
- };
- }
- var button = tiktok.likeButton();
- if (!button) {
- return { ok: false, via: 'button-missing', reason: '找不到点赞按钮' };
- }
- TikTokAuto.ui.clickNode(button, tag);
- /* 验证:desc 从 `Like video.` 变成 `Video liked`(或图标 selected 翻转) */
- var done = TikTokAuto.wait.waitFor(tag + ':verify', function () {
- return tiktok.likeState() === 'liked' ? true : null;
- }, 6000, 300);
- if (done === true) {
- logger().info(tag, '点赞成功(状态已变为已赞)');
- return { ok: true, via: 'state-changed' };
- }
- logger().warn(tag, '点赞后状态没有变化(可能被弹窗挡住或点偏了)');
- return { ok: false, via: 'not-verified', reason: '点击后没有变成已赞' };
- }
- /**
- * 关注当前视频的作者。
- *
- * @returns { ok, skipped?, via, reason? }
- */
- function follow(step) {
- var tiktok = TikTokAuto.tiktok;
- var tag = step || 'follow';
- var state = tiktok.followState();
- if (state === 'following') {
- return { ok: true, skipped: true, via: 'already-following', reason: '已经关注过这个作者' };
- }
- if (state !== 'not-following') {
- /* 头像和按钮都读不到 = 页面还没渲染好。
- * 这时不点:点到空白处可能触发别的动作(播放/暂停),没有意义。 */
- return {
- ok: false, via: 'page-not-ready',
- reason: '作者头像与关注按钮都读不到,页面可能还没渲染完'
- };
- }
- var button = tiktok.followButton();
- if (!button) {
- return { ok: false, via: 'button-missing', reason: '找不到关注按钮' };
- }
- TikTokAuto.ui.clickNode(button, tag);
- /* 验证:关注按钮**整个消失**(实测就是这种表现,不是改成 Following) */
- var done = TikTokAuto.wait.waitFor(tag + ':verify', function () {
- return tiktok.followState() === 'following' ? true : null;
- }, 6000, 300);
- if (done === true) {
- logger().info(tag, '关注成功(关注按钮已消失)');
- return { ok: true, via: 'button-gone' };
- }
- logger().warn(tag, '关注后按钮仍在,可能没生效');
- return { ok: false, via: 'not-verified', reason: '点击后关注按钮仍然存在' };
- }
- return {
- like: like,
- follow: follow
- };
- }());
- /* ============================== src/flow.js ============================== */
- /**
- * flow.js — 业务流程编排
- *
- * 这里只写"先做什么、再做什么、失败了怎么办",不写"怎么点"(ui.js)
- * 也不写"TikTok 长什么样"(tiktok.js)。
- * 每一段都是一次"进入某页面 → 等它真的就绪 → 做事 → 校验"的小闭环,
- * 因此任何一步卡住都能从日志里立刻看出卡在哪。
- *
- * 收敛原则(《代码整洁之道》第 3 章):每个函数只做一件事,且**只在一个抽象层级上**。
- * 所以下面几乎没有嵌套循环和条件,读起来就是需求本身。
- *
- * 依赖:TikTokAuto.logger / tiktok / player / comment / ui / wait / popups
- */
- TikTokAuto.flow = (function () {
- 'use strict';
- function logger() { return TikTokAuto.logger; }
- // ------------------------------------------------------------ 启动与就位
- function launchApp(config) {
- var step = 'launch';
- var pkg = config.target.packageName;
- /* ★ 已经在前台就**绝对别动它**。
- *
- * `app.launchPackage()` 不是"切到前台",而是"重新打开这个 app" ——
- * 即使 app 已经在前台,它也会再发一次 startActivity,把叠在主界面上的
- * 页面(视频播放页、评论面板、用户列表)清掉,退回首页。
- *
- * 对「当前屏幕」类取材这是致命的:用户手动把 TikTok 停在评论区,
- * 那正是这个取材源的全部前提,而引擎一启动就把它退成了 feed,
- * 然后再报"取材设置为当前屏幕,但当前不在播放页"。
- * 实测(2026-10-22):日志里 `launch 就绪(56ms)` 看着毫秒级完成、
- * 什么都没干,实际上页面已经被重置了 —— 时间是 56ms,副作用却不可逆。
- */
- if (TikTokAuto.layout.foregroundPackage() === pkg) {
- logger().info(step, '已在前台,保持当前页面不动');
- return true;
- }
- logger().step(step, '启动 ' + pkg);
- app.launchPackage(pkg);
- var ready = TikTokAuto.wait.waitFor(step, function () {
- return TikTokAuto.layout.foregroundPackage() === pkg ? true : null;
- }, config.timeouts.appLaunchMs, config.probe.pollTickMs);
- return ready === true;
- }
- /**
- * 保证停在信息流首页。
- *
- * 早先的写法有两个坑(实测踩到):
- * 1. 一上来就 `waitFor(feed, 15s)` —— 明明已经在播放页,却先白等 15 秒;
- * 2. 循环里最后按完 back 不回头复查,4 次退完刚好差一步,然后误判"退不回去"去重启。
- * 现在改成:**先看再动**,每退一步都等页面真的换了(按签名变化判断,不是固定 sleep),
- * 并且只在确认当前不是首页时才按返回——避免把正常页面退掉或退出 App。
- */
- function ensureFeed(config) {
- var step = 'ensureFeed';
- var log = logger();
- var tiktok = TikTokAuto.tiktok;
- var tiktokLayout = TikTokAuto.layout;
- var deadline = Date.now() + 25000;
- var unknownRounds = 0;
- while (Date.now() < deadline) {
- var state = tiktok.pageState();
- if (state === 'feed') {
- log.info(step, '已处于信息流首页');
- return true;
- }
- if (state === 'unknown') {
- // 页面可能还在渲染。但连续多轮都认不出来,说明有东西盖在上面
- // (弹窗、半透明遮罩、异常页),这时**必须**试着按返回脱离,
- // 否则就是原地等到超时、然后"重启大法"。
- unknownRounds += 1;
- if (unknownRounds >= 4) {
- log.warn(step, '页面状态连续 ' + unknownRounds + ' 轮未知,按返回尝试脱离');
- back();
- TikTokAuto.wait.waitQuiet(function () {
- return tiktokLayout.signature();
- }, { maxMs: 2000, tickMs: 200, stableTimes: 2 });
- unknownRounds = 0;
- } else {
- TikTokAuto.wait.tick(700);
- }
- continue;
- }
- unknownRounds = 0;
- log.info(step, '当前在 ' + state + ',返回上一页');
- back();
- TikTokAuto.wait.waitQuiet(function () {
- return tiktokLayout.signature();
- }, { maxMs: 2500, tickMs: 200, stableTimes: 2 });
- }
- log.warn(step, '多次返回仍未到首页,重新启动应用');
- launchApp(config);
- return TikTokAuto.wait.waitFor(step, function () {
- return tiktok.pageState() === 'feed' ? true : null;
- }, config.timeouts.pageReadyMs, config.probe.pollTickMs) === true;
- }
- // ------------------------------------------------------------ 搜索
- function openSearchPage(config) {
- var step = 'openSearch';
- var entry = TikTokAuto.tiktok.searchEntry();
- if (!entry) {
- logger().error(step, '首页找不到搜索入口');
- return false;
- }
- TikTokAuto.ui.clickNode(entry, step);
- return TikTokAuto.wait.waitFor(step, function () {
- if (TikTokAuto.tiktok.pageState() === 'search') {
- return true;
- }
- // 结果页也算"搜索这条链走通了",直接复用
- return TikTokAuto.tiktok.pageState() === 'results' ? true : null;
- }, config.timeouts.pageReadyMs, config.probe.pollTickMs) === true;
- }
- function submitKeyword(config) {
- var step = 'submitKeyword';
- var tiktok = TikTokAuto.tiktok;
- if (tiktok.pageState() === 'results') {
- logger().info(step, '已经在结果页,跳过输入');
- return true;
- }
- var input = tiktok.searchInput();
- if (!input) {
- logger().error(step, '找不到搜索输入框');
- return false;
- }
- TikTokAuto.ui.clickNode(input, step);
- TikTokAuto.wait.tick(250);
- var typed = TikTokAuto.ui.typeInto(tiktok.searchInput() || input, config.target.keyword, step);
- if (!typed.ok) {
- return false;
- }
- var submit = tiktok.searchSubmit();
- if (!submit) {
- logger().error(step, '找不到搜索提交按钮');
- return false;
- }
- TikTokAuto.ui.clickNode(submit, step);
- return TikTokAuto.wait.waitFor(step, function () {
- return tiktok.pageState() === 'results' ? true : null;
- }, config.timeouts.searchResultsMs, config.probe.pollTickMs) === true;
- }
- /**
- * 等结果网格真的渲染出卡片(而不是页面框架出来了就算完)。
- *
- * @param timeoutMs 可选。不传用 `searchResultsMs`(20 秒,够首屏/切页签重刷)。
- * 主循环里"滚动之前"的那次只值几秒 —— 到底时不该白等 20 秒。
- * @returns 卡片数组(成功)或 null(超时)
- */
- function waitForCards(config, timeoutMs) {
- var step = 'waitForCards';
- return TikTokAuto.wait.waitFor(step, function () {
- var cards = TikTokAuto.tiktok.videoCards();
- return cards.length > 0 ? cards : null;
- }, timeoutMs || config.timeouts.searchResultsMs, config.probe.pollTickMs);
- }
- function selectResultTab(config) {
- var step = 'selectResultTab';
- var name = config.target.resultTab;
- if (!name) {
- return false;
- }
- var tiktok = TikTokAuto.tiktok;
- /* ★ 已经是目标页签就**不要点**(2026-10-28 实测)。
- *
- * 「Top」是结果页的默认页签,而我们默认配置的 resultTab 也是 Top ——
- * 等于每次运行都会点一次当前页签。点已选中的页签会让 TikTok 重刷整个网格:
- * 卡片先消失、再重新渲染,中间那段时间 videoCards() 返回 0。
- * 后面 `waitQuiet` 又判断不出"列表正在重刷"(签名和点之前一样),
- * 于是直接进了主循环 → 读到 0 张卡 → 滚一下 → 还是 0 → 报「已无更多卡片」。
- * 整个流程零报错地失败,看着像"这个关键词没有视频"。
- */
- if (tiktok.resultTabSelected(name)) {
- logger().info(step, '页签已经是 ' + name + ',跳过点击(点了会重刷列表)');
- return true;
- }
- var tab = tiktok.resultTab(name);
- if (!tab) {
- logger().warn(step, '找不到页签 ' + name + ',沿用当前页签');
- return false;
- }
- logger().info(step, '切换到页签 ' + name);
- TikTokAuto.ui.clickNode(tab, step);
- /* 切页签一定会重刷网格 → 判据必须是"卡片真的重新出现了",
- * 不能再用 waitQuiet(它会把"还在加载"当成"已经安静")。 */
- if (!waitForCards(config)) {
- logger().warn(step, '切到 ' + name + ' 后没等到卡片');
- return false;
- }
- return true;
- }
- /** 结果页滚动加载更多卡片 */
- function scrollResults(config) {
- var layout = TikTokAuto.layout;
- var screen = layout.screen();
- logger().info('scrollResults', '结果页下拉加载更多');
- TikTokAuto.ui.dragScreen(
- Math.floor(screen.width * 0.25), Math.floor(screen.height * 0.8),
- Math.floor(screen.width * 0.25), Math.floor(screen.height * 0.35),
- 400
- );
- TikTokAuto.wait.waitQuiet(function () {
- return TikTokAuto.layout.signature();
- }, { maxMs: 4000, tickMs: 250, stableTimes: 3 });
- }
- // ------------------------------------------------------------ 选片与观看
- /**
- * 从当前屏的卡片里挑一张没试过的。
- *
- * @param visited 已处理过的 caption 列表(用于跨屏去重)
- * @param tried 已尝试过的**容器矩形**列表
- *
- * ★ 为什么还要按矩形去重(2026-10-28 修):
- * 同一张卡片的文案在不同时刻会读到**不同的截断**:一次是 `swagger #fashion …`,
- * 另一次是 `… #fashion #outfitinspo #fyp`(前面被省略号截掉)。按 caption 去重认不出
- * 是同一张,于是"点开→不是视频→跳过→又点到它"循环 11 次才被 guard 拦住。
- * 容器矩形在没滚动时是稳定的,用它兜住这一层。
- *
- * 也去掉了"全都试过就退回全量再挑一遍"的兜底 —— 那个兜底正是死循环的入口。
- * 全试过时返回 null,由调用方决定是滚动还是收工。
- */
- function chooseCard(config, cards, visited, tried) {
- var fresh = [];
- for (var i = 0; i < cards.length; i += 1) {
- if (visited.indexOf(cards[i].caption) !== -1) {
- continue;
- }
- if (cardAlreadyTried(cards[i], tried)) {
- continue;
- }
- fresh.push(cards[i]);
- }
- if (fresh.length === 0) {
- return null;
- }
- if (config.target.pickStrategy === 'first') {
- return fresh[0];
- }
- return fresh[Math.floor(Math.random() * fresh.length)];
- }
- /** 容器矩形是否已在"试过"名单里(容差 40px,容忍布局微抖) */
- function cardAlreadyTried(card, tried) {
- if (!tried || tried.length === 0) {
- return false;
- }
- var box = null;
- try {
- box = card.container.bounds();
- } catch (ignored) {
- return false;
- }
- if (!box) {
- return false;
- }
- for (var i = 0; i < tried.length; i += 1) {
- var one = tried[i];
- if (Math.abs(one.left - box.left) <= 40 &&
- Math.abs(one.top - box.top) <= 40 &&
- Math.abs(one.right - box.right) <= 40) {
- return true;
- }
- }
- return false;
- }
- /** 把一张卡的容器矩形记进"试过"名单 */
- function rememberCard(card, tried) {
- try {
- var box = card.container.bounds();
- if (box) {
- tried.push({ left: box.left, top: box.top, right: box.right });
- }
- } catch (ignored) {
- /* 记不上就记不上,caption 那一层去重仍在 */
- }
- }
- function openCard(config, card) {
- var step = 'openCard';
- logger().step(step, '打开视频:' + JSON.stringify(String(card.caption).slice(0, 40)));
- TikTokAuto.ui.clickNode(card.container, step);
- return TikTokAuto.player.waitPlayerReady(config, step);
- }
- /**
- * 从播放页回到结果页。
- *
- * 必须允许连按多次:校验评论时会把评论列表打开,此时状态是 comment-panel,
- * 一次 back 只够收起面板,得再退一次才真正回到结果页。
- */
- function returnToResults(config) {
- var step = 'returnToResults';
- var log = logger();
- var tiktok = TikTokAuto.tiktok;
- var tiktokLayout = TikTokAuto.layout;
- var deadline = Date.now() + config.timeouts.pageReadyMs;
- var backs = 0;
- var unknownStreak = 0;
- while (Date.now() < deadline && backs < 6) {
- var state = tiktok.pageState();
- if (state === 'results') {
- return true;
- }
- if (state === 'unknown') {
- /* ★ 连续 unknown 也要按返回(2026-10-28 修)。
- *
- * 原来是"unknown 就只等 500ms、绝不按返回",于是卡在
- * **照片轮播帖 / 商品页**这类 pageState 认不出的页面时,
- * 15 秒预算会被 500ms 的等待耗完,一次返回都没按过,
- * 最后报「未能回到结果页,跳过后续取片」——整个循环就此中断。
- *
- * 页面刚切换的头 1.5 秒确实是 unknown(还在渲染),所以先让两次;
- * 之后就必须动。
- */
- unknownStreak += 1;
- if (unknownStreak < 3) {
- TikTokAuto.wait.tick(500);
- continue;
- }
- } else {
- unknownStreak = 0;
- }
- backs += 1;
- log.info(step, '当前在 ' + state + ',返回上一页');
- back();
- TikTokAuto.wait.waitQuiet(function () {
- return tiktokLayout.signature();
- }, { maxMs: 2500, tickMs: 200, stableTimes: 2 });
- }
- log.warn(step, '未能回到结果页,跳过后续取片');
- return false;
- }
- // ------------------------------------------------------------ 主流程
- /**
- * 上报进度给界面宿主(未接宿主时是空操作,见 bridge.js)。
- * 只在这里包一层是为了让下面的流程读起来仍是"业务语言",
- * 不必到处重复 status:'running' 这种传输细节。
- */
- function progress(phase, done, total) {
- TikTokAuto.bridge.report({
- status: 'running',
- phase: phase,
- done: done,
- total: total
- });
- }
- /**
- * 跑一轮完整会话:启动 → 搜索 → 逐个视频「看 → 评」。
- * @returns 结构化结果,会被 main.js 打印成一行 RESULT 供 PC 侧断言
- */
- function runSession(config) {
- var summary = {
- ok: false,
- keyword: config.target.keyword,
- requestedVideos: config.target.maxVideos,
- attemptedVideos: 0,
- postedComments: 0,
- /* 四个动作各自的成功计数:只有"真的做了"才加,跳过不算 */
- likedVideos: 0,
- followedAuthors: 0,
- /* 用户是否勾了这两个动作 —— 用来决定结果文案里要不要提它们
- * (勾了但全是"已赞"时显示 0 是有意义的信息;没勾就干脆不提) */
- likesRequested: config.likes && config.likes.enabled === true,
- followsRequested: config.follows && config.follows.enabled === true,
- details: [],
- /* 不算失败但值得说的事(例:搜索结果里混着商品卡,点开不是视频就跳过)。
- * 必须在这里初始化 —— 后面直接 push,不初始化就是运行期 TypeError,
- * 而 AutoX 上这种异常不弹窗、不报错,只表现成"流程莫名停住"。 */
- warnings: [],
- errors: []
- };
- var total = config.target.maxVideos;
- progress('\u542f\u52a8\u5e94\u7528', 0, total);
- if (!launchApp(config)) {
- summary.errors.push('启动应用失败');
- return summary;
- }
- progress('\u8fdb\u5165\u4fe1\u606f\u6d41', 0, total);
- if (!ensureFeed(config)) {
- summary.errors.push('未能进入信息流首页');
- return summary;
- }
- progress('\u641c\u7d22 ' + config.target.keyword, 0, total);
- if (!openSearchPage(config) || !submitKeyword(config)) {
- summary.errors.push('搜索流程失败');
- return summary;
- }
- if (!waitForCards(config)) {
- summary.errors.push('搜索结果里没有可用卡片');
- return summary;
- }
- selectResultTab(config);
- var visited = [];
- var tried = [];
- var usedComments = [];
- var guard = 0;
- while (summary.attemptedVideos < config.target.maxVideos && guard < config.target.maxVideos * 3) {
- guard += 1;
- progress('\u6b63\u5728\u5904\u7406\u7b2c ' + (summary.attemptedVideos + 1) + '/' + total + ' \u4e2a\u89c6\u9891',
- summary.attemptedVideos, total);
- var cards = TikTokAuto.tiktok.videoCards();
- if (cards.length === 0) {
- /* ★ 先"等"再"滚"(2026-10-28 修)。
- *
- * 读到 0 张卡有两种可能:① 真的没有更多了 ② 列表正在重排/重刷
- * (切页签、刚翻过一页、图片加载中)。直接滚会把 ② 当成 ① 而收工。
- * 这里只给 5 秒 —— 真的到底时不该白等满 20 秒。
- */
- cards = waitForCards(config, 5000) || [];
- }
- if (cards.length === 0) {
- scrollResults(config);
- cards = waitForCards(config) || [];
- if (cards.length === 0) {
- /* ★ 卡片用尽不是"错误"(2026-10-28 改)。
- *
- * 搜索结果少的时候(例:关键词 cat 只有 3~4 张视频卡,
- * 中间还夹着 1 张商品卡)会正常用完。以前这里 push 一条 errors,
- * 于是"处理了 3 个视频、目标 4 个"看起来像出了故障。
- *
- * 真正该报错的是**一个都没收到** —— 那说明选词/解析真有问题。
- */
- if (summary.attemptedVideos === 0) {
- summary.errors.push('结果页没有可用的视频卡片');
- } else {
- summary.warnings.push('结果页卡片已用尽:本轮处理 ' +
- summary.attemptedVideos + ' 个(目标 ' + total +
- ' 个),换个关键词或调小目标数量');
- }
- break;
- }
- }
- var card = chooseCard(config, cards, visited, tried);
- /* 当前屏的卡片全试过了(含"试过但打不开"的)→ 滚一屏再看。
- * 绝不能像旧版那样"全都试过就退回全量再挑" —— 那是死循环的入口:
- * 同一张照片帖会被反复点开、反复跳过,直到 guard 用完。 */
- if (!card) {
- scrollResults(config);
- cards = waitForCards(config) || [];
- card = chooseCard(config, cards, visited, tried);
- if (!card) {
- if (summary.attemptedVideos === 0) {
- summary.errors.push('结果页没有可用的视频卡片');
- } else {
- summary.warnings.push('结果页卡片已用尽:本轮处理 ' +
- summary.attemptedVideos + ' 个(目标 ' + total +
- ' 个),换个关键词或调小目标数量');
- }
- break;
- }
- }
- visited.push(card.caption);
- rememberCard(card, tried);
- if (!openCard(config, card)) {
- /* ★ 打不开 ≠ 出错(2026-10-28 实测)。
- *
- * 搜索结果里混着两种"长得像视频卡"的东西:
- * · 广告**视频** —— 点进去是正常播放页(有 Ad 标记 + Shop now),能正常看
- * · 照片轮播帖 / 商品卡 —— 根本没有播放页
- * (照片帖约 2 秒就能判出来,见 player.waitPlayerReady 的 :kind 预判)
- *
- * 以前这里 push 一条 errors,于是"4 个视频里有 1 张照片帖"会让整轮
- * 记录看起来像出了故障。现在改成:记一条 warning、跳过、继续下一张。
- */
- summary.warnings.push('跳过非视频卡片:' + String(card.caption).slice(0, 30));
- logger().info('flow', '点开后没有播放页,当作非视频卡片跳过:' +
- String(card.caption).slice(0, 30));
- returnToResults(config);
- continue;
- }
- var detail = { caption: String(card.caption).slice(0, 60) };
- detail.watch = TikTokAuto.player.watch(config, { step: 'watch' });
- /* 点赞 / 关注:都在播放页上点一下就好,不必跳转。
- * 顺序放在评论**之前** —— 评论会打开面板盖住动作栏,
- * 之后再点赞就得先把面板收掉,多一步且更容易出错。 */
- if (config.likes && config.likes.enabled) {
- detail.like = TikTokAuto.interact.like('like');
- if (detail.like.ok && !detail.like.skipped) {
- summary.likedVideos += 1;
- }
- } else {
- detail.like = { ok: true, skipped: true, reason: '本次未启用点赞动作' };
- }
- if (config.follows && config.follows.enabled) {
- detail.follow = TikTokAuto.interact.follow('follow');
- if (detail.follow.ok && !detail.follow.skipped) {
- summary.followedAuthors += 1;
- }
- } else {
- detail.follow = { ok: true, skipped: true, reason: '本次未启用关注动作' };
- }
- /* 没勾「评论」时只浏览:这不是失败,是一种合法的运行模式
- * (界面上的「执行操作」没勾评论就是这个意思)。 */
- if (config.comments.enabled) {
- var text = TikTokAuto.comment.pickText(config, usedComments, 'comment');
- detail.comment = TikTokAuto.comment.post(config, text, 'comment');
- if (detail.comment.ok && !detail.comment.skipped) {
- usedComments.push(text);
- summary.postedComments += 1;
- }
- } else {
- detail.comment = { ok: true, skipped: true, reason: '本次未启用评论动作' };
- }
- summary.attemptedVideos += 1;
- summary.details.push(detail);
- progress('\u5df2\u5b8c\u6210 ' + summary.attemptedVideos + '/' + total,
- summary.attemptedVideos, total);
- returnToResults(config);
- }
- summary.ok = summary.attemptedVideos > 0;
- return summary;
- }
- return {
- launchApp: launchApp,
- ensureFeed: ensureFeed,
- openSearchPage: openSearchPage,
- submitKeyword: submitKeyword,
- waitForCards: waitForCards,
- selectResultTab: selectResultTab,
- scrollResults: scrollResults,
- chooseCard: chooseCard,
- openCard: openCard,
- returnToResults: returnToResults,
- runSession: runSession
- };
- }());
- /* ============================== src/nurture.js ============================== */
- /**
- * nurture.js — 养号:在首页信息流里随机浏览 + 按概率点赞 / 关注 / 评论
- *
- * 与视频链路(flow.js)的区别只有一句话:**没有目标**。
- * 视频链路是"搜关键词 → 点进指定数量的视频",养号是"打开信息流 → 一个接一个往下刷"。
- * 所以这里复用了 flow 的应用级动作(`launchApp` / `ensureFeed`)与
- * player / interact / comment 三个能力模块,自己只负责**编排节奏**。
- *
- * ## 双约束:轮数与时长,谁先到谁停
- *
- * 界面给的是「单次操作 10~30」(轮数)与「浏览时长 3~8 分钟」。两者**各自独立**,
- * 不是"用一项算出另一项":
- * · 轮数先满 —— 真人式收工,正常结束
- * · 时长先到 —— 可能刚好卡在某个视频中间,如实说明"做了 N 轮,没用满轮数"
- * 所以结果里记 `stoppedBy`,不假装两个都达标。
- *
- * ## 为什么每轮都要重新确认"还在信息流"
- *
- * 实测信息流里会滑到这些东西:**照片轮播帖**(没有播放)、**带货卡**
- * (`Creator earns commission`)、以及可能的**直播**。前两种滑走就好,
- * 直播会让 `pageState()` 变成别的值、上滑也未必切得动。
- * 每轮开工前确认一次,不对就交回 `ensureFeed` 拉回(它自己会按返回,多次失败会重启应用)。
- *
- * 依赖:TikTokAuto.flow / player / interact / comment / tiktok / wait / bridge
- */
- TikTokAuto.nurture = (function () {
- 'use strict';
- /** 连续这么多次上滑都换不了视频,就认为卡死了 */
- var SWIPE_FAIL_LIMIT = 3;
- function logger() { return TikTokAuto.logger; }
- /** 概率命中。rate 是 0~1;0 或非法值一律不命中(宁可不做,不可乱做) */
- function hit(rate) {
- var value = Number(rate);
- if (!(value > 0)) {
- return false;
- }
- return Math.random() < value;
- }
- function progress(phase, done, total) {
- TikTokAuto.bridge.report({
- status: 'running',
- phase: phase,
- done: done,
- total: total
- });
- }
- /**
- * 上滑换下一个视频。
- *
- * 判据是"**新内容真的出现了**",不是"滑动手势发出去了"。
- * 手势会被吞:网络慢、正在加载、恰好卡在上一帧,都会出现"滑了但没动"。
- */
- function nextVideo() {
- var tiktok = TikTokAuto.tiktok;
- var view = TikTokAuto.layout.screen();
- var before = tiktok.feedSignature();
- TikTokAuto.ui.dragScreen(
- Math.floor(view.width * 0.5),
- Math.floor(view.height * 0.78),
- Math.floor(view.width * 0.5),
- Math.floor(view.height * 0.28),
- 280
- );
- var changed = TikTokAuto.wait.waitFor('nextVideo', function () {
- var now = tiktok.feedSignature();
- if (now === '' || now === before) {
- return null;
- }
- return true;
- }, 6000, 250);
- return changed === true;
- }
- /** 做完一个动作后随机停顿一下(真人不会点完立刻滑) */
- function pause(config) {
- var lo = config.nurture.pause.minMs;
- var hi = config.nurture.pause.maxMs;
- TikTokAuto.wait.tick(TikTokAuto.player.randomInt(lo, hi));
- }
- /**
- * 一轮:看一个视频 + 按概率互动。
- *
- * @returns detail(结构与 video 链路的 details[] 同族,便于记录页复用)
- */
- function oneRound(config, index, usedComments, summary) {
- var tiktok = TikTokAuto.tiktok;
- var settings = config.nurture;
- var detail = { index: index + 1 };
- /* 照片帖没有播放页,也没有"播放中"这回事 —— 拿 watch 去等
- * `playbackStartMs`(12 秒)纯属白等。短停留即可,点赞/关注照样能做。 */
- var photo = tiktok.photoPost() !== null;
- detail.photoPost = photo;
- if (photo) {
- var dwell = TikTokAuto.player.randomInt(1500, 4000);
- TikTokAuto.wait.tick(dwell);
- detail.watch = { completed: true, actualMs: dwell, probeMode: 'photo-post' };
- summary.photoPosts += 1;
- } else {
- detail.watch = TikTokAuto.player.watch(config, {
- step: 'watch',
- /* 信息流没有返回箭头、有底部导航 —— `onPlayerPage()` 在这里恒为 false,
- * 不注入这个守卫的话 watch 会在 3 秒内以 left-player-page 自杀。 */
- guard: function () { return tiktok.pageState() === 'feed'; },
- minMs: settings.watch.minMs,
- maxMs: settings.watch.maxMs
- });
- }
- summary.watchedMs += detail.watch.actualMs || 0;
- /* 动作顺序:点赞 → 关注 → **评论放最后**。
- * 评论会打开面板盖住右侧动作栏,之后再点赞就得多一步收面板。 */
- if (hit(settings.likeRate)) {
- summary.likeHits += 1;
- detail.like = TikTokAuto.interact.like('like');
- if (detail.like.ok && !detail.like.skipped) {
- summary.liked += 1;
- }
- pause(config);
- }
- if (hit(settings.followRate)) {
- summary.followHits += 1;
- detail.follow = TikTokAuto.interact.follow('follow');
- if (detail.follow.ok && !detail.follow.skipped) {
- summary.followed += 1;
- }
- pause(config);
- }
- if (config.comments.enabled && hit(settings.commentRate)) {
- summary.commentHits += 1;
- var text = TikTokAuto.comment.pickText(config, usedComments, 'comment');
- detail.comment = TikTokAuto.comment.post(config, text, 'comment');
- if (detail.comment.ok && !detail.comment.skipped) {
- usedComments.push(text);
- summary.commented += 1;
- } else {
- /* 失败了**必须把面板收掉**:面板盖在信息流上时,
- * 后面的上滑会变成"在评论列表里滚动",整个流程就歪了。 */
- TikTokAuto.comment.closePanel('comment:cleanup');
- if (!detail.comment.ok) {
- summary.warnings.push('评论没发出去:' +
- (detail.comment.reason || detail.comment.via || '未知'));
- }
- }
- pause(config);
- }
- return detail;
- }
- /**
- * @param config 生效配置(含 `config.nurture` 与顶层 `config.comments`)
- * @returns summary
- */
- function runSession(config) {
- var log = logger();
- var settings = config.nurture;
- var summary = {
- ok: false,
- task: 'nurture',
- /* 实际做完的轮数 / 本轮随机出来的目标轮数 */
- rounds: 0,
- targetRounds: 0,
- /** 真正在看视频上花掉的毫秒数(不含动作与停顿) */
- watchedMs: 0,
- /* 真的做成功的个数:跳过(已赞/已关注)不计入 */
- liked: 0,
- followed: 0,
- commented: 0,
- /* 概率命中的次数:用来区分"没打算做"和"想做但失败了" */
- likeHits: 0,
- followHits: 0,
- commentHits: 0,
- photoPosts: 0,
- /** 'rounds' | 'time' | 'aborted' —— 是哪个约束先到 */
- stoppedBy: '',
- details: [],
- /* 不算失败但值得说的事。必须在这里初始化:后面直接 push,
- * 不初始化就是运行期 TypeError,而 AutoX 上这种异常不弹窗、只表现成"流程停住"。 */
- warnings: [],
- errors: []
- };
- progress('启动应用', 0, 0);
- if (!TikTokAuto.flow.launchApp(config)) {
- summary.errors.push('启动应用失败');
- return summary;
- }
- progress('进入信息流', 0, 0);
- if (!TikTokAuto.flow.ensureFeed(config)) {
- summary.errors.push('未能进入信息流首页');
- return summary;
- }
- var targetRounds = TikTokAuto.player.randomInt(settings.rounds.min, settings.rounds.max);
- summary.targetRounds = targetRounds;
- var deadline = Date.now() + settings.browse.maxMs;
- var beganAt = Date.now();
- log.step('nurture', '养号开始:目标 ' + targetRounds + ' 轮 / 最长 ' +
- Math.round(settings.browse.maxMs / 60000) + ' 分钟;概率 点赞 ' +
- Math.round(settings.likeRate * 100) + '% / 关注 ' +
- Math.round(settings.followRate * 100) + '% / 评论 ' +
- Math.round(settings.commentRate * 100) + '%');
- var usedComments = [];
- var swipeFailures = 0;
- while (summary.rounds < targetRounds && Date.now() < deadline) {
- var state = TikTokAuto.tiktok.pageState();
- if (state !== 'feed') {
- /* 滑进了直播 / 广告落地页 / 评论面板,或者页面还在加载。
- * 交回 ensureFeed 拉回信息流 —— 别在这里自己写一套"返回几次"的逻辑。 */
- log.warn('nurture', '当前不在信息流(' + state + '),尝试回到信息流');
- if (!TikTokAuto.flow.ensureFeed(config)) {
- summary.warnings.push('中途离开信息流且回不去,提前结束(做了 ' +
- summary.rounds + ' 轮)');
- summary.stoppedBy = 'aborted';
- break;
- }
- }
- progress('第 ' + (summary.rounds + 1) + ' / ' + targetRounds + ' 轮',
- summary.rounds, targetRounds);
- summary.details.push(oneRound(config, summary.rounds, usedComments, summary));
- summary.rounds += 1;
- if (!nextVideo()) {
- swipeFailures += 1;
- log.warn('nurture', '上滑后内容没变(连续第 ' + swipeFailures + ' 次)');
- if (swipeFailures >= SWIPE_FAIL_LIMIT) {
- summary.warnings.push('连续 ' + swipeFailures +
- ' 次上滑都换不了视频,提前结束(做了 ' + summary.rounds + ' 轮)');
- summary.stoppedBy = 'aborted';
- break;
- }
- } else {
- swipeFailures = 0;
- }
- }
- /* 如实说明是哪个约束先到 —— 不要笼统说"完成"。
- * 轮数先满 vs 时长先到,对用户下一步该调哪个参数完全是指向不同。 */
- if (!summary.stoppedBy) {
- var byTime = Date.now() >= deadline;
- var byRounds = summary.rounds >= targetRounds;
- summary.stoppedBy = byTime && byRounds ? 'rounds' : (byRounds ? 'rounds' : 'time');
- }
- if (summary.stoppedBy === 'time') {
- summary.warnings.push('浏览时长到了(' +
- Math.round(settings.browse.maxMs / 60000) + ' 分钟),只做了 ' +
- summary.rounds + '/' + targetRounds + ' 轮 —— 想多做几轮请把时长上限调大');
- }
- summary.ok = summary.rounds > 0;
- if (!summary.ok && summary.errors.length === 0) {
- summary.errors.push('一轮都没能完成(信息流没有加载出来?)');
- }
- log.step('nurture', '养号结束:' + summary.rounds + ' 轮,用时 ' +
- Math.round((Date.now() - beganAt) / 1000) + ' 秒,真正浏览 ' +
- Math.round(summary.watchedMs / 1000) + ' 秒;点赞 ' + summary.liked +
- ' / 关注 ' + summary.followed + ' / 评论 ' + summary.commented +
- '(' + summary.stoppedBy + ' 先到)');
- return summary;
- }
- return {
- runSession: runSession,
- nextVideo: nextVideo,
- hit: hit,
- SWIPE_FAIL_LIMIT: SWIPE_FAIL_LIMIT
- };
- }());
- /* ============================== src/parse.js ============================== */
- /**
- * parse.js — 文本 → 数值 / 语言 / 区间的**纯函数**解析
- *
- * ## 为什么单独一个模块
- * 这三件事都是「界面上的设置真的会拦人」这一主张的证据链:
- * · 数量门槛要先把 "1.6M" 变成 1600000 才谈得上比较;
- * · 语言区域要靠昵称/个签里的字符构成来判;
- * · 区间判定要能表达"不限"和"读不到"这两种不同的"没有值"。
- * 它们全部与界面无关,所以能 100% 离线自检 —— 这是整个 ID 提取里
- * **唯一一处可以用断言证明"逻辑对了"** 的部分。
- *
- * ## 关于数字缩写(真机实测)
- * TikTok 的计数一律是缩写形态,实测到的写法:
- * "594"、"2,491"、"1.6M"、"253.9M"、"245K"、"371.7K"
- * 中文界面下会是 "1.2万" / "1.5亿"。所以后缀要同时认 K/M/B 与 万/亿。
- *
- * 依赖:无(纯函数,不取任何模块引用)
- */
- TikTokAuto.parse = (function () {
- 'use strict';
- /* ---------------------------------------------------------- 数字 */
- /** 后缀倍数。键统一小写;`二`的两种写法(万/萬)都认。 */
- var SUFFIX = {
- '': 1,
- 'k': 1000,
- 'm': 1000000,
- 'b': 1000000000,
- 'w': 10000, /* 万:中文界面 "1.2万" */
- '万': 10000,
- '萬': 10000,
- '亿': 100000000,
- '億': 100000000
- };
- /**
- * 把一个计数字符串解析成整数。
- *
- * 返回 **null** 表示"读不到",与 **0** 严格区分:
- * "0 followers" 是真实的值(一个粉丝都没有),"读不到"是另一回事 ——
- * 数量门槛的判定要靠这个区别决定是"拦下"还是"放行"。
- *
- * @param raw 原始文本,如 "1.6M followers" / "2,491" / "1.2万"
- * @returns {number|null}
- */
- function count(raw) {
- if (raw === null || raw === undefined) {
- return null;
- }
- /* 统一全角数字与全角逗号:某些语言/输入法会给出全角 */
- var text = String(raw)
- .replace(/[0-9]/g, function (ch) {
- return String.fromCharCode(ch.charCodeAt(0) - 0xFEE0);
- })
- .replace(/[,]/g, ',');
- /* 抓「数字 + 可选小数 + 可选后缀」。前面的自由文本("1.6M followers")不影响。
- * 后缀只认白名单里的字符,避免把 "594 followers" 的 f 当成后缀。 */
- var matched = text.match(/(\d[\d,]*(?:\.\d+)?)\s*([kKmMbBwW万萬亿億]?)/);
- if (!matched) {
- return null;
- }
- var number = Number(String(matched[1]).replace(/,/g, ''));
- if (isNaN(number)) {
- return null;
- }
- var suffix = String(matched[2] || '').toLowerCase();
- var factor = SUFFIX[suffix];
- if (factor === undefined) {
- /* 后缀不在白名单(例如 "594 followers" 的 f 已不会被捕获)→ 按无后缀处理 */
- factor = 1;
- }
- return Math.round(number * factor);
- }
- /* ---------------------------------------------------------- 语言 */
- /** CJK 汉字(含扩展 A 与兼容区)与假名 —— 判定"中文内容"用 */
- var CJK = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\u3040-\u30ff]/;
- var LATIN = /[A-Za-z\u00c0-\u024f]/; /* 含带音标的拉丁字母(西/法/德用户昵称很常见) */
- /**
- * 估算一段文本的语种。只返回三类,够用且不猜。
- *
- * 判定规则(不是拍脑袋,理由在下面):
- * · **只统计字母类字符**,忽略数字/标点/emoji —— 昵称里 emoji 占比极高,
- * 算进分母会把"中文昵称 + 三个 emoji"判成"非中文"。
- * · 中文门槛 **30%**:中文用户常混英文单词("小美的Fashion"),
- * 门槛设成 50% 会把这类人漏掉。
- * · 英文门槛 **70%**:反过来,英文用户极少混中文,
- * 所以"仅英文"要更严格 —— 否则一个 "Fashion 小美" 会同时进两个桶。
- *
- * @returns {string} "zh" | "en" | "mixed" | "none"
- */
- function language(raw) {
- var text = String(raw === null || raw === undefined ? '' : raw);
- if (text === '') {
- return 'none';
- }
- var cjk = 0;
- var latin = 0;
- for (var i = 0; i < text.length; i += 1) {
- var ch = text.charAt(i);
- if (CJK.test(ch)) {
- cjk += 1;
- } else if (LATIN.test(ch)) {
- latin += 1;
- }
- }
- var total = cjk + latin;
- if (total === 0) {
- return 'none'; /* 纯 emoji / 纯数字 / 阿拉伯文等 */
- }
- var zhRatio = cjk / total;
- var enRatio = latin / total;
- if (zhRatio >= 0.3) {
- return 'zh';
- }
- if (enRatio >= 0.7) {
- return 'en';
- }
- return 'mixed';
- }
- /**
- * 语言是否匹配"仅中文 / 仅英文 / 通杀"。
- *
- * `mixed` 在两边都不算匹配 —— 说不清是哪一边的,就不该被任何一边选中。
- * 但 `none`(纯 emoji 昵称)在"通杀"下通过:通杀本来就不筛。
- */
- function languageMatches(want, actual) {
- if (!want || want === 'any') {
- return true;
- }
- if (want === 'zh') {
- return actual === 'zh';
- }
- if (want === 'en') {
- return actual === 'en';
- }
- return true;
- }
- /* ---------------------------------------------------------- 区间 */
- /**
- * 区间判定。
- *
- * 三种状态必须分开表达,这是这个函数存在的理由:
- * · **不限**(min/max 都是 0)→ 永远通过,连值都不用读;
- * · **有值** → 落在 [min, max] 内(0 表示该侧不设限);
- * · **读不到**(value === null)→ **不通过**。
- *
- * 为什么读不到要拦:用户明确设了门槛,说明他在意这个条件;
- * 读不到就无法证明符合,宁可少收并在日志里说清楚 ——
- * 若这里放行,"过滤"就是形同虚设,而用户以为它生效了。
- *
- * @returns {boolean}
- */
- function inRange(value, min, max) {
- var low = Number(min) || 0;
- var high = Number(max) || 0;
- if (low <= 0 && high <= 0) {
- return true;
- }
- if (value === null || value === undefined) {
- return false;
- }
- var number = Number(value);
- if (isNaN(number)) {
- return false;
- }
- if (low > 0 && number < low) {
- return false;
- }
- if (high > 0 && number > high) {
- return false;
- }
- return true;
- }
- /** 该区间是否"什么都没设"(用来判断要不要为此打开用户页) */
- function isRangeOpen(min, max) {
- return (Number(min) || 0) <= 0 && (Number(max) || 0) <= 0;
- }
- /* ---------------------------------------------------------- 用户名 */
- /**
- * 把 "@username" 归一成 "username"。
- *
- * 实测发现用户名文本被 **FID 隔离符** 包裹(U+2068 / U+2069):
- * 显示为 `ajman_fashion_week`,肉眼完全看不出,但直接当文件名/比对键就会出错。
- * `layout.sanitize` 处理了 U+200b-200f 等,**没有**处理 2068/2069(它们不是零宽字符,
- * 是双向文本隔离符),所以这里单独剥一次。
- *
- * 同时剥掉全角 @ 与首尾空白。
- */
- function username(raw) {
- if (raw === null || raw === undefined) {
- return '';
- }
- return String(raw)
- .replace(/[\u2066-\u2069\u202a-\u202e]/g, '')
- .replace(/\s+/g, '')
- .replace(/^[@@]+/, '');
- }
- /**
- * 一个字符串是否像 TikTok 用户名(字母数字下划线点,长度 2~30)。
- *
- * 注意**不能**先走 username() —— 那个函数会去掉所有空白(清洗用途),
- * 于是 "This is a caption" 会被压成 "Thisisacaption" 而"看起来像用户名"。
- * 判定必须**保留原始空白**:真正的用户名里不会有空格。
- */
- function looksLikeUsername(raw) {
- if (raw === null || raw === undefined) {
- return false;
- }
- var name = String(raw)
- .replace(/[\u2066-\u2069\u202a-\u202e]/g, '')
- .replace(/^[\s@@]+|[\s]+$/g, '');
- if (name.length < 2 || name.length > 30) {
- return false;
- }
- if (/\s/.test(name)) {
- return false; /* 中间有空白 → 是文案,不是用户名 */
- }
- return /^[A-Za-z0-9_.]+$/.test(name);
- }
- /* ---------------------------------------------------------- 文件名 */
- /** 两位补零 */
- function pad2(value) {
- return value < 10 ? '0' + value : String(value);
- }
- /**
- * 结果文件名:`前缀_YYYYMMDD_HHmm.txt`
- *
- * 方案 §6.1 是对规格书的有意识偏离:规格书只有日期(`uid_20261013.txt`),
- * 同一天跑两次会互相覆盖 —— 而"同一天调两次参数"恰恰是最常见的用法。
- */
- function timestampName(prefix, date) {
- var d = date || new Date();
- return prefix + '_' + d.getFullYear() + pad2(d.getMonth() + 1) + pad2(d.getDate()) +
- '_' + pad2(d.getHours()) + pad2(d.getMinutes()) + '.txt';
- }
- return {
- count: count,
- language: language,
- languageMatches: languageMatches,
- inRange: inRange,
- isRangeOpen: isRangeOpen,
- username: username,
- looksLikeUsername: looksLikeUsername,
- timestampName: timestampName
- };
- }());
- /* ============================== src/uidset.js ============================== */
- /**
- * uidset.js — 去重集合与结果文件
- *
- * ## 三种"已经见过"
- * 1. **本轮已收**:这次运行里已经写进产物文件的;
- * 2. **历史已收**:之前各轮跑过的(`uid_extracted.txt`,只追加不删除);
- * 3. **输入里已有**:文本取材那份文件本身就列着的("裂变"时,"输入里的人"不该再算新收获)。
- * 三者合成一个集合,`add` 之后就不再重复。区别只在**装载来源**,判定逻辑是同一个。
- *
- * ## 为什么落盘是"追加"而不是"最后一次性写"
- * 用户点了停止、或脚本被系统杀掉时,已经抓到的部分不能全丢。
- * 所以每收 N 个就 append 一次 —— 代价是几条多余的系统调用,收益是"停在哪都留着"。
- *
- * ## 为什么不用 files 的删除接口
- * 引擎产物要过 MCP 的危险调用黑名单(`build.js` 的 `DANGEROUS_RULES` 里有一条按
- * 词边界匹配 `remove` 加左括号),命中后每次下发都要求人工确认。
- * 所以这里**只追加不删除** —— 需要"重来"时由用户自己在文件管理器里删。
- *
- * 依赖:TikTokAuto.logger(调用时取)
- */
- TikTokAuto.uidset = (function () {
- 'use strict';
- /** 每收这么多个就 append 一次。20 是"丢掉的最多几个"与"写盘频率"的折中。 */
- var FLUSH_EVERY = 20;
- /**
- * 去重集合。
- *
- * 内部用普通对象而不是 Set:Rhino 对 ES6 Set 的支持在各版本不一致,
- * 而对象字面量的键查找在所有版本上都是稳的。uid 只会是字母数字下划线点,不会撞 `__proto__`。
- */
- function create() {
- var seen = {};
- var count = 0;
- return {
- /** 见过没有 */
- has: function (uid) {
- return seen[uid] === true;
- },
- /** 记一笔;返回 true 表示这是新加的(之前没见过) */
- add: function (uid) {
- if (seen[uid] === true) {
- return false;
- }
- seen[uid] = true;
- count += 1;
- return true;
- },
- /** 已记多少(含历史装载的) */
- size: function () {
- return count;
- }
- };
- }
- /**
- * 读一批文本行,返回去重后的 uid 数组。
- * 跳过空行、`#` 注释行、以及带 @ 的行(有些用户会直接粘贴 "@name",我们容忍这种输入)。
- */
- function readUidFile(path) {
- var out = [];
- try {
- if (!path || !files.exists(path)) {
- return out;
- }
- var lines = String(files.read(path)).split(/\r?\n/);
- for (var i = 0; i < lines.length; i += 1) {
- var uid = TikTokAuto.parse.username(lines[i]);
- if (uid !== '' && uid.charAt(0) !== '#') {
- out.push(uid);
- }
- }
- } catch (error) {
- TikTokAuto.logger.warn('uidset', '读取 ' + path + ' 失败:' + error);
- }
- return out;
- }
- /**
- * 从历史文件装载去重集合。
- * 文件不存在 = 第一次跑,不是错误。
- */
- function loadHistory(set, path) {
- var list = readUidFile(path);
- for (var i = 0; i < list.length; i += 1) {
- set.add(list[i]);
- }
- TikTokAuto.logger.info('uidset', '历史去重装载 ' + list.length + ' 条(' + path + ')');
- return list.length;
- }
- /**
- * 结果写入器。
- *
- * 两个文件同时写:
- * · `本次`:这一轮新收的(新建,给人看/给下游吃);
- * · `累计`:所有轮次的(追加,跨轮去重的基准)。
- *
- * @param options { runPath, historyPath, dryRun, historyEnabled }
- */
- function createWriter(options) {
- var pending = [];
- var collected = [];
- var written = 0;
- var flushFailed = false;
- function flush() {
- if (pending.length === 0) {
- return;
- }
- var block = pending.join('\n') + '\n';
- pending = [];
- if (options.dryRun) {
- return; /* 演练模式:不碰任何文件 */
- }
- try {
- files.append(options.runPath, block);
- if (options.historyEnabled) {
- files.append(options.historyPath, block);
- }
- } catch (error) {
- /* 写盘失败要留痕但不能中断 —— 用户更需要"继续抓"而不是"因为一个文件写不进去全停"。
- * 只报一次,避免几百条一模一样的警告把日志刷满。 */
- if (!flushFailed) {
- flushFailed = true;
- TikTokAuto.logger.error('uidset', '写结果文件失败:' + error);
- }
- }
- }
- return {
- /** 收一个 uid。调用方保证已经过去重。 */
- write: function (uid) {
- pending.push(uid);
- /* 同时留一份在内存里:收尾阶段要拿它去换数字 uid(见 resolve.js)。
- * 从文件读一遍也行,但那样得处理"文件还没 flush 完"的时序问题,
- * 而这里的量级(几十到几千)远没到需要省内存的地步。 */
- collected.push(uid);
- written += 1;
- if (pending.length >= FLUSH_EVERY) {
- flush();
- }
- },
- /** 本轮收到的全部 uid(按收取顺序) */
- list: function () {
- return collected.slice(0);
- },
- /** 已写多少个 */
- count: function () {
- return written;
- },
- /** 收尾:把剩下的刷出去。**收到停止信号时也必须调它**,否则"停了但文件是空的"。 */
- close: function () {
- flush();
- return written;
- },
- /** 是否发生过写盘失败(给 summary 用) */
- hadFailure: function () {
- return flushFailed;
- }
- };
- }
- /** 确保目录存在(结果文件与累计文件同目录) */
- function ensureDir(dir) {
- try {
- files.ensureDir(dir + '/');
- return true;
- } catch (error) {
- TikTokAuto.logger.error('uidset', '创建目录失败 ' + dir + ':' + error);
- return false;
- }
- }
- return {
- FLUSH_EVERY: FLUSH_EVERY,
- create: create,
- readUidFile: readUidFile,
- loadHistory: loadHistory,
- createWriter: createWriter,
- ensureDir: ensureDir
- };
- }());
- /* ============================== src/fingerprint.js ============================== */
- /**
- * fingerprint.js — 配置指纹(判断"断点还有效吗")
- *
- * ## 为什么要它
- * 断点续跑的前提是"配置没变过"。取材换了关键词、过滤条件调了门槛,
- * 上次"已完成"的判定就不再成立 —— 接着跑会得到口径不一致的数据,而用户看不出来。
- *
- * ## 为什么不用 hash 库
- * 项目里没有任何现成的 hash / 稳定序列化工具,而 AutoX 环境里能用的
- * 也就 Rhino 那点东西(没有 `Math.imul`、没有 ES6 Map/Set 的稳定行为)。
- * 所以自己写两个小函数:**稳定序列化 + djb2**。
- *
- * ## 覆盖哪些字段(这是设计决策,不是随手挑的)
- *
- * | 覆盖 | 为什么 |
- * |---|---|
- * | `sources[]` 的全部取值字段(**顺序敏感**) | 换账号/关键词/深度/删条目都改变"取哪些人" |
- * | `filter` 的 private/noAvatar/langRegion/nicknameCheck/follow/fans/likes | 直接决定谁被拦掉 |
- * | `output` 的 dir/historyPath/dedupeHistory | dir 决定产物位置;后两个决定 `seen` 的初始内容 |
- *
- * | 不覆盖 | 为什么 |
- * |---|---|
- * | `output.limit` | 约束"跑多少个"而不是"取哪些人";续跑仍按累计数生效 |
- * | `output.resolveUid` | 只影响收尾是否多产一份映射文件 |
- * | `output.dryRun` | 演练不写产物,**调用方直接禁续跑**,不必进指纹 |
- * | `filter.videos` | `filters.judge` 只对它出 soft 提示,不参与判定 |
- * | `guard.*` / `timeouts` / `probe` / `runtime` | 与"取哪些人"无关 |
- *
- * ## ★ 这个文件在界面侧有一份逐字节相同的实现
- * `tiktok-app/src/fingerprint.js`。**改这里必须同步改那边**,
- * 否则两侧算出的指纹不同 → 界面说"可以续跑"、引擎说"配置变了"。
- * `tools/pc-selftest-id.js` 里有一组断言专门比对两份实现的结果。
- */
- TikTokAuto.fingerprint = (function () {
- 'use strict';
- var VERSION = 1;
- /**
- * 是不是数组。
- *
- * 不用 `v instanceof Array`:跨 realm(PC 自检把 Node 造的对象喂进 vm 沙箱)时
- * `instanceof` 会返回 false,那会让"数组"被当成普通对象序列化,两边算出的指纹就不一样了。
- *
- * 也不用 `toString` 配 `Function.prototype.call` 的写法:那个关键字加左括号
- * 命中 MCP 危险调用黑名单(`tools/build.js` 的 `DANGEROUS_RULES`),会让每次下发都要人工确认。
- * 优先 `Array.isArray`(ES5,AutoX 的 Rhino 支持),没有时退回 `.apply`。
- */
- function isArray(v) {
- if (typeof Array.isArray === 'function') {
- return Array.isArray(v);
- }
- return Object.prototype.toString.apply(v, []) === '[object Array]';
- }
- /** 自有键,排序后返回(键序无关 → 同一份配置换个键序不会让断点失效) */
- function ownKeys(v) {
- var keys = [];
- for (var k in v) {
- if (Object.prototype.hasOwnProperty.apply(v, [k]) === true) {
- keys.push(k);
- }
- }
- keys.sort();
- return keys;
- }
- /**
- * 稳定序列化:对象按键名排序、数组保持顺序、每个值带类型前缀。
- *
- * 类型前缀是必须的 —— 否则 `"1"`(字符串)和 `1`(数字)会序列化成同一个串,
- * 而界面上"粉丝下限"这类字段确实可能一个是空串一个是 0。
- */
- function canon(v) {
- if (v === null || v === undefined) {
- return 'n';
- }
- if (isArray(v)) {
- var parts = [];
- for (var i = 0; i < v.length; i += 1) {
- parts.push(canon(v[i]));
- }
- return '[' + parts.join(',') + ']';
- }
- if (typeof v === 'object') {
- var keys = ownKeys(v);
- var out = [];
- for (var j = 0; j < keys.length; j += 1) {
- out.push(keys[j] + ':' + canon(v[keys[j]]));
- }
- return '{' + out.join(',') + '}';
- }
- if (typeof v === 'number') {
- return 'd' + String(v);
- }
- if (typeof v === 'boolean') {
- return v ? 'T' : 'F';
- }
- return 's' + JSON.stringify(String(v));
- }
- /**
- * djb2-xor 摘要。
- *
- * 末尾拼上原文长度:32 位散列碰撞概率本来就不高,加个长度等于多一维区分,
- * 而且是"免费"的(长度本来就要算)。输出是 base36,比十六进制短。
- */
- function digest(text) {
- var s = String(text);
- var h = 5381;
- for (var i = 0; i < s.length; i += 1) {
- h = (((h << 5) + h) ^ s.charCodeAt(i)) | 0; /* |0 截成 32 位,避免精度丢失 */
- }
- return (h >>> 0).toString(36) + '-' + s.length.toString(36);
- }
- /** 把一条取材归一化成"只含参与指纹的字段",缺项补默认 */
- function ofSource(one, index) {
- var s = one || {};
- return {
- key: s.key || ('i' + index + ':' + (s.kind || '')),
- kind: s.kind || '',
- anchors: s.anchors || '',
- lists: isArray(s.lists) ? s.lists : [],
- searchType: s.searchType || '',
- keyword: s.keyword || '',
- videoSource: s.videoSource || '',
- videoKeyword: s.videoKeyword || '',
- path: s.path || '',
- expand: s.expand || ''
- };
- }
- /**
- * 算 `id` 段的指纹。
- *
- * **分项存**(sources / filter / output 各一份)而不只存一个总串:
- * 界面要能说清"是**取材**改过了"而不是含糊的"配置变了",用户才知道该改回去什么。
- *
- * @param id `config.id` 整段(界面传 `Engine.buildIdConfig().config.id`,
- * 引擎传解析后的 `config.id` —— 两者是同一份数据)
- */
- function ofIdSection(id) {
- var section = id || {};
- var list = isArray(section.sources) ? section.sources : [];
- var sources = [];
- for (var i = 0; i < list.length; i += 1) {
- sources.push(ofSource(list[i], i));
- }
- var f = section.filter || {};
- var o = section.output || {};
- var sourcesHash = digest(canon(sources));
- var filterHash = digest(canon({
- private: f.private === true,
- noAvatar: f.noAvatar === true,
- langRegion: f.langRegion || 'any',
- nicknameCheck: f.nicknameCheck || 'any',
- follow: f.follow || {},
- fans: f.fans || {},
- likes: f.likes || {}
- }));
- var outputHash = digest(canon({
- dir: o.dir || '',
- historyPath: o.historyPath || '',
- dedupeHistory: o.dedupeHistory !== false
- }));
- return {
- v: VERSION,
- sources: sourcesHash,
- filter: filterHash,
- output: outputHash,
- all: digest(sourcesHash + filterHash + outputHash)
- };
- }
- /**
- * 比两份指纹。
- * @returns { same, changed: ['sources'|'filter'|'output'] }
- */
- function compare(a, b) {
- if (!a || !b || a.v !== VERSION || b.v !== VERSION) {
- return { same: false, changed: ['sources', 'filter', 'output'] };
- }
- var changed = [];
- if (a.sources !== b.sources) {
- changed.push('sources');
- }
- if (a.filter !== b.filter) {
- changed.push('filter');
- }
- if (a.output !== b.output) {
- changed.push('output');
- }
- return { same: changed.length === 0, changed: changed };
- }
- return {
- VERSION: VERSION,
- canon: canon,
- digest: digest,
- ofIdSection: ofIdSection,
- compare: compare
- };
- }());
- /* ============================== src/checkpoint.js ============================== */
- /**
- * checkpoint.js — ID 提取的断点("跑到哪了"的唯一真相)
- *
- * ## 粒度:取材条目级,不是屏/行级
- *
- * TikTok 的列表内容会变、滚动位置无法复原。记"第几屏第几行"只会造成
- * 漏掉别人或重复劳动;而「哪条取材已经走完」是稳定事实。
- * 所以:**跳过已完成的取材;未完成的那条从头重走,靠已收集合跳人**。
- *
- * ## 文件
- * `<id.output.dir>/id_progress.json`,与产物同目录(用户清产物时看得见)。
- * 全局只保留一份(全局单跑,多份会让"哪份有效"变成判断题)。
- *
- * ## 几条硬约束(都踩过或差点踩)
- *
- * 1. **只能覆盖写,不能删文件** —— `files.remove` 命中 MCP 危险调用黑名单
- * (见 `tools/build.js` 的 `DANGEROUS_RULES` 与 `uidset.js` 文件头)。
- * 「清空断点」= 覆盖写成 `state:"none"`。
- * 2. **read 必须容错** —— 写到一半被杀会留下半截 JSON。读不懂就当"没有断点",
- * 绝不抛异常:产物还在,丢的只是"能不能接着跑"。
- * 3. **不存已收 uid 列表** —— 结果文件本身就是那份清单,重复存只会出现两份不一致。
- * 4. **写盘不追每一条 uid** —— 粒度是取材条目级,每收 1 个就写只换来"少重走 19 个人"。
- * 节奏是:取材边界 + 每 20 个(与产物刷盘同拍)+ 收尾。
- *
- * 依赖:TikTokAuto.logger、TikTokAuto.fingerprint(仅 compare 用)
- */
- TikTokAuto.checkpoint = (function () {
- 'use strict';
- /** 文件名固定:不放用户配置里,避免"换个名字就找不到断点" */
- var DEFAULT_NAME = 'id_progress.json';
- var VERSION = 1;
- function logger() { return TikTokAuto.logger; }
- /**
- * 是不是数组。优先 `Array.isArray`;退回用 `.apply` 而不是 `Function.prototype.call`
- * —— 后者那个关键字加左括号会命中 MCP 危险调用黑名单(见 `tools/build.js`)。
- */
- function isArray(v) {
- if (typeof Array.isArray === 'function') {
- return Array.isArray(v);
- }
- return Object.prototype.toString.apply(v, []) === '[object Array]';
- }
- function dirOf(path) {
- var s = String(path || '');
- var i = s.lastIndexOf('/');
- return i > 0 ? s.substring(0, i + 1) : '';
- }
- /** 断点文件路径(由结果目录派生) */
- function pathOf(output) {
- var dir = (output && output.dir) || '/sdcard/Download/tiktok_app';
- return dir + '/' + DEFAULT_NAME;
- }
- /**
- * 新建一份断点。
- *
- * @param runId 当前这次运行(界面靠它把记录与断点精确对上)
- * @param sig 指纹(`fingerprint.ofIdSection(config.id)`)
- * @param runFile 结果文件绝对路径(续跑时复用它,不新开文件)
- * @param sources `[{key, kind, label}]`,顺序与 config.id.sources 一致
- */
- function empty(runId, sig, runFile, sources) {
- var list = [];
- for (var i = 0; i < (sources || []).length; i += 1) {
- var s = sources[i] || {};
- list.push({
- key: s.key || ('i' + i + ':' + (s.kind || '')),
- kind: s.kind || '',
- label: s.label || '',
- status: 'pending', /* pending | running | done */
- screens: 0,
- handled: 0,
- added: 0
- });
- }
- var now = Date.now();
- return {
- v: VERSION,
- state: 'running', /* running | stopped | done | none */
- runId: runId || '',
- beganAt: now,
- updatedAt: now,
- sig: sig || null,
- runFile: runFile || '',
- mapFile: '',
- limitBase: 0,
- counts: { extracted: 0, skipped: 0, duplicates: 0, openedProfiles: 0 },
- sources: list,
- expand: { queue: [], done: [] }
- };
- }
- /** 读断点。**任何解析问题都返回 null**(当作没有断点),绝不抛 */
- function read(path) {
- try {
- if (!path || !files.exists(path)) {
- return null;
- }
- var text = String(files.read(path) || '');
- if (text.replace(/\s/g, '') === '') {
- return null;
- }
- var cp = JSON.parse(text);
- if (!cp || cp.v !== VERSION) {
- return null;
- }
- /* 半截文件可能缺字段:补齐,别让调用方到处判空 */
- if (!isArray(cp.sources)) {
- cp.sources = [];
- }
- if (!cp.expand || typeof cp.expand !== 'object') {
- cp.expand = { queue: [], done: [] };
- }
- if (!isArray(cp.expand.queue)) {
- cp.expand.queue = [];
- }
- if (!isArray(cp.expand.done)) {
- cp.expand.done = [];
- }
- return cp;
- } catch (error) {
- logger().warn('checkpoint', '断点文件读不懂(当作没有断点):' + error);
- return null;
- }
- }
- /** 覆盖写整份断点。返回是否写成功(失败只记 warn,不影响主流程) */
- function save(path, cp) {
- if (!cp) {
- return false;
- }
- try {
- var dir = dirOf(path);
- if (dir) {
- files.ensureDir(dir);
- }
- cp.updatedAt = Date.now();
- files.write(path, JSON.stringify(cp));
- return true;
- } catch (error) {
- logger().warn('checkpoint', '断点写盘失败(不影响已抓到的结果):' + error);
- return false;
- }
- }
- /** 「清空断点」= 覆盖成空壳。**不删文件**(`files.remove` 是危险调用) */
- function clearStub() {
- return { v: VERSION, state: 'none', updatedAt: Date.now() };
- }
- /** 指纹不符时给用户一句人话,说清是哪一段变了 */
- function nameOf(key) {
- if (key === 'sources') { return '取材'; }
- if (key === 'filter') { return '过滤条件'; }
- if (key === 'output') { return '输出设置(目录 / 跨轮去重)'; }
- return key;
- }
- /**
- * 能不能续跑?—— 顺序很重要,每一步的失败都要给可操作的话。
- *
- * 全部判定都在**启动 App 之前**做完:拒绝时零成本返回,不折腾用户手机。
- *
- * @param prev 上一次读到的断点(或 null)
- * @param sig 当前配置指纹
- * @param runFileExists 上次的结果文件还在不在
- * @param opts { dryRun }
- * @returns { mode:'resume'|'fresh'|'reject', reason, message, changed }
- */
- function planResume(prev, sig, runFileExists, opts) {
- var options = opts || {};
- if (!prev || prev.v !== VERSION || prev.state === 'none') {
- return { mode: 'fresh', reason: 'no-checkpoint', message: '', changed: [] };
- }
- if (prev.state === 'done') {
- return { mode: 'fresh', reason: 'last-complete', message: '', changed: [] };
- }
- if (options.dryRun) {
- return {
- mode: 'reject', reason: 'dry-run', changed: [],
- message: '断点失效:演练模式不写产物,无法续跑'
- };
- }
- var cmp = TikTokAuto.fingerprint.compare(prev.sig, sig);
- if (!cmp.same) {
- var names = [];
- for (var i = 0; i < cmp.changed.length; i += 1) {
- names.push(nameOf(cmp.changed[i]));
- }
- return {
- mode: 'reject', reason: 'config-changed', changed: cmp.changed,
- message: '断点失效:' + names.join('、') + '已改动,只能重新开始'
- };
- }
- if (!runFileExists) {
- return {
- mode: 'reject', reason: 'run-file-missing', changed: [],
- message: '断点失效:上次的结果文件已不存在(' + (prev.runFile || '') + ')'
- };
- }
- return { mode: 'resume', reason: 'ok', message: '', changed: [] };
- }
- /* ------------------------------------------------------------ 取材条目 */
- function findSource(cp, key) {
- if (!cp || !isArray(cp.sources)) {
- return null;
- }
- for (var i = 0; i < cp.sources.length; i += 1) {
- if (cp.sources[i].key === key) {
- return cp.sources[i];
- }
- }
- return null;
- }
- function sourceDone(cp, key) {
- var one = findSource(cp, key);
- return !!(one && one.status === 'done');
- }
- /**
- * 更新一条取材的状态。
- * @param extra 可选 { screens, handled, added }
- */
- function markSource(cp, key, status, extra) {
- var one = findSource(cp, key);
- if (!one) {
- return null;
- }
- one.status = status;
- if (extra) {
- if (extra.screens !== undefined) { one.screens = extra.screens; }
- if (extra.handled !== undefined) { one.handled = extra.handled; }
- if (extra.added !== undefined) { one.added = extra.added; }
- }
- return one;
- }
- /** 已完成 / 共几条(给界面写提示条用) */
- function sourceProgress(cp) {
- var done = 0;
- var total = cp && isArray(cp.sources) ? cp.sources.length : 0;
- for (var i = 0; i < total; i += 1) {
- if (cp.sources[i].status === 'done') {
- done += 1;
- }
- }
- return { done: done, total: total };
- }
- /** 上次停在哪儿(提示条上那句"停在「xxx」") */
- function stoppedAt(cp) {
- if (!cp || !isArray(cp.sources)) {
- return '';
- }
- for (var i = 0; i < cp.sources.length; i += 1) {
- if (cp.sources[i].status !== 'done') {
- return cp.sources[i].label || '';
- }
- }
- return '';
- }
- /**
- * 取一条取材的记录(找不到返回 null)。
- *
- * 续跑时要用它上次翻了多少屏来**放大** `maxScreens` 预算:
- * 从头重放旧人也要翻屏,不放大就会"还没走到新人就撞上 60 屏硬顶"。
- */
- function sourceOf(cp, key) {
- return findSource(cp, key);
- }
- /** 上次这条取材翻了多少屏(没记录就 0) */
- function screensOf(cp, key) {
- var one = findSource(cp, key);
- return one ? (one.screens || 0) : 0;
- }
- /* ------------------------------------------------------------ 深度展开 */
- function expandKey(uid, which) {
- return String(uid) + '|' + String(which);
- }
- function enqueueExpand(cp, uid, which) {
- if (!cp || !uid) {
- return false;
- }
- var key = expandKey(uid, which);
- if (cp.expand.done.indexOf(key) >= 0) {
- return false; /* 上次已经展开过,别重复排 */
- }
- for (var i = 0; i < cp.expand.queue.length; i += 1) {
- if (expandKey(cp.expand.queue[i].uid, cp.expand.queue[i].which) === key) {
- return false;
- }
- }
- cp.expand.queue.push({ uid: String(uid), which: String(which) });
- return true;
- }
- function expandDone(cp, uid, which) {
- return !!cp && cp.expand && cp.expand.done.indexOf(expandKey(uid, which)) >= 0;
- }
- function markExpandDone(cp, uid, which) {
- if (!cp || !cp.expand) {
- return;
- }
- var key = expandKey(uid, which);
- if (cp.expand.done.indexOf(key) < 0) {
- cp.expand.done.push(key);
- }
- }
- /**
- * 这次还要展开谁。
- *
- * @param queue 本轮内存里攒的待展开项(`[{uid, which}]`,由 handleRow 边收边入队)
- * @returns 过滤掉"上次已完成"之后的那部分
- */
- function pendingExpand(cp, queue) {
- var out = [];
- for (var i = 0; i < (queue || []).length; i += 1) {
- var one = queue[i];
- if (!expandDone(cp, one.uid, one.which)) {
- out.push(one);
- }
- }
- return out;
- }
- /* ------------------------------------------------------------ 收尾判定 */
- /** 所有取材都 done,且展开队列里没有未完成的 → 这一轮真的做完了 */
- function allDone(cp) {
- if (!cp || !isArray(cp.sources) || cp.sources.length === 0) {
- return false;
- }
- for (var i = 0; i < cp.sources.length; i += 1) {
- if (cp.sources[i].status !== 'done') {
- return false;
- }
- }
- for (var j = 0; j < cp.expand.queue.length; j += 1) {
- if (!expandDone(cp, cp.expand.queue[j].uid, cp.expand.queue[j].which)) {
- return false;
- }
- }
- return true;
- }
- /**
- * 「提取满 N 个就停」—— 续跑时必须**按累计数**算。
- *
- * 续跑的人均语义是"这一轮凑够 N 个",那就变成设了 100 个、跑了 60 个被停、
- * 接着再收 100 个 = 一共 160 个。所以基数是"上次已落盘的条数",
- * 而那个数以**结果文件的行数**为准(文件是真相,不信 JSON 里的计数)。
- *
- * @param cp 断点(读 `limitBase`)
- * @param limit 用户设的上限(0 = 不限)
- * @param currentExtracted 本轮新增
- */
- function limitReached(cp, limit, currentExtracted) {
- if (!(limit > 0)) {
- return false;
- }
- var base = (cp && cp.limitBase) || 0;
- return (base + (currentExtracted || 0)) >= limit;
- }
- return {
- DEFAULT_NAME: DEFAULT_NAME,
- VERSION: VERSION,
- pathOf: pathOf,
- empty: empty,
- read: read,
- save: save,
- clearStub: clearStub,
- planResume: planResume,
- nameOf: nameOf,
- sourceDone: sourceDone,
- markSource: markSource,
- sourceProgress: sourceProgress,
- stoppedAt: stoppedAt,
- sourceOf: sourceOf,
- screensOf: screensOf,
- enqueueExpand: enqueueExpand,
- expandDone: expandDone,
- markExpandDone: markExpandDone,
- pendingExpand: pendingExpand,
- allDone: allDone,
- limitReached: limitReached
- };
- }());
- /* ============================== src/filters.js ============================== */
- /**
- * filters.js — 「只要这样的账号」的判定
- *
- * 这一层是**纯函数**:给它一份采集到的资料 + 一份过滤设置,它只回答"要 / 不要 / 为什么"。
- * 不点屏幕、不读写文件、不知道 TikTok 长什么样。所以它能被 100% 离线自检,
- * 而"界面上的 21 项设置真的会拦人"这件事也就有了证据。
- *
- * ## 一条重要的区分:**数据缺失**不等于**不符合**
- * 三种"没有值"必须分开处理,否则过滤要么形同虚设,要么乱杀:
- *
- * ① 用户没设这个门槛 → 放行(设置里是 0,语义就是"不限")
- * ② 设了,但平台根本不提供 → **放行 + 记一条 warn**(例如"视频量":真机实测用户页
- * 顶部只有 关注/粉丝/获赞 三组数字,没有视频数)
- * ③ 设了,本应能读到却读不到 → **拦下**(读不到就无法证明符合,宁可少收)
- *
- * ②和③看起来像,但责任不同:②是我们做不到,不该惩罚用户抓到的人;③是数据异常,
- * 放行会让过滤失去意义而用户以为它生效了。
- *
- * 依赖:TikTokAuto.parse、TikTokAuto.logger(调用时取)
- */
- TikTokAuto.filters = (function () {
- 'use strict';
- /**
- * 这份过滤设置是否需要打开用户页才能判。
- *
- * 用途是**快路径**:所有过滤都关着时(最常见的第一种用法),
- * 直接从列表页把 uid 抄下来就行 —— 不必为每个人开一次用户页。
- * 实测一次用户页打开约 3~5 秒,100 个人就是 5~8 分钟,差一个数量级。
- *
- * 注意 `videos` 不参与这个判断:它读不到(见文件头 ②),
- * 为此打开用户页也只是白花时间。
- */
- function needsProfile(filter) {
- var f = filter || {};
- if (f.private === true || f.noAvatar === true) {
- return true;
- }
- if (f.langRegion && f.langRegion !== 'any') {
- return true;
- }
- var groups = ['follow', 'fans', 'likes'];
- for (var i = 0; i < groups.length; i += 1) {
- var range = f[groups[i]] || {};
- if (!TikTokAuto.parse.isRangeOpen(range.min, range.max)) {
- return true;
- }
- }
- return false;
- }
- /** 取该判定的取样文本:昵称、个签,或两者(昵称检测下拉的三个取值)。 */
- function sampleText(profile, mode) {
- var nick = profile.nickname || '';
- var sign = profile.signature || '';
- if (mode === 'nick') {
- return nick;
- }
- if (mode === 'sign') {
- return sign;
- }
- /* "通杀":两段合起来判 —— 分开判会让"昵称中文、个签全英文"的人两边都不算 */
- return nick + ' ' + sign;
- }
- /**
- * 单条判定的结果。
- * `soft` 表示"我们做不到,放行但要告知"(对应文件头的 ②)。
- */
- function pass() {
- return { ok: true, reason: '', soft: '' };
- }
- function reject(reason) {
- return { ok: false, reason: reason, soft: '' };
- }
- function softPass(reason) {
- return { ok: true, reason: '', soft: reason };
- }
- /**
- * 判定一个账号要不要。
- *
- * @param profile 采集到的资料;`null` 表示"这次没有打开用户页"(快路径)
- * —— 此时只有不看资料就能判的项目生效,其余全部放行
- * @param filter 过滤设置
- * @returns { ok, reason, soft }
- * reason 是"为什么不要"(人话,会进日志)
- * soft 是"放行了但你应该知道"(进 summary 的 warnings,只报一次)
- */
- function judge(profile, filter) {
- var f = filter || {};
- var parse = TikTokAuto.parse;
- if (!profile) {
- /* 没有资料,两种情况必须分开:
- * · 这份设置**本来就不需要**资料(快路径)→ 放行;
- * · 需要资料却拿不到(用户页打不开、读不出)→ **拦下**。
- * 第二种如果放行,等于"打不开的人全都通过" —— 过滤看着在跑,
- * 实际放进来一批根本没检查过的账号,比不过滤更糟(用户以为筛过了)。 */
- if (needsProfile(f)) {
- return reject('打不开用户页,读不到任何资料');
- }
- return pass();
- }
- /* ① 私密账户。列表页读不到,只能打开用户页看提示文案。 */
- if (f.private === true && profile.isPrivate === true) {
- return reject('私密账号');
- }
- /* ② 无头像。真机实测头像节点不可读(无文案、不可点),
- * 只能靠像素法 —— 见 profile.js 里的说明。读不到时放行并告知。 */
- if (f.noAvatar === true) {
- if (profile.hasAvatar === false) {
- return reject('无头像');
- }
- if (profile.hasAvatar === null) {
- return {
- ok: true,
- reason: '',
- soft: '无头像检测:头像读取不可用,本次未生效'
- };
- }
- }
- /* ③ 语言区域:按昵称/个签的字符构成判(见 parse.language 的规则说明) */
- if (f.langRegion && f.langRegion !== 'any') {
- var text = sampleText(profile, f.nicknameCheck || 'any');
- var lang = parse.language(text);
- if (lang === 'none') {
- /* 纯 emoji / 纯数字昵称:没有语言线索。用户明确要筛语言,就不该混进来。 */
- return reject('看不出语言(昵称与个签都没有文字)');
- }
- if (!parse.languageMatches(f.langRegion, lang)) {
- return reject('语言不符:' + lang + ' ≠ ' + f.langRegion);
- }
- }
- /* ④ 三组数量门槛。真机实测用户页顶部是 关注 / 粉丝 / 获赞,
- * 数字形态是缩写("1.6M"),parse.count 负责解析。 */
- var checks = [
- { key: 'follow', label: '关注量' },
- { key: 'fans', label: '粉丝量' },
- { key: 'likes', label: '获赞量' }
- ];
- for (var i = 0; i < checks.length; i += 1) {
- var one = checks[i];
- var range = f[one.key] || {};
- if (parse.isRangeOpen(range.min, range.max)) {
- continue;
- }
- if (!parse.inRange(profile[one.key], range.min, range.max)) {
- var actual = profile[one.key];
- return reject(one.label + '不在区间内(' +
- (actual === null || actual === undefined ? '读不到' : actual) + ')');
- }
- }
- /* ⑤ 视频量:平台不提供(实测),放行 + 告知。见文件头 ②。 */
- var videoRange = f.videos || {};
- if (!parse.isRangeOpen(videoRange.min, videoRange.max)) {
- return softPass('视频量:用户页不显示该数字,本次未参与过滤');
- }
- return pass();
- }
- /** 给人看的一句话:这次到底在筛什么(进日志与界面状态条) */
- function describe(filter) {
- var f = filter || {};
- var parts = [];
- if (f.private === true) { parts.push('排除私密'); }
- if (f.noAvatar === true) { parts.push('排除无头像'); }
- if (f.langRegion && f.langRegion !== 'any') {
- parts.push(f.langRegion === 'zh' ? '仅中文' : '仅英文');
- }
- var labels = { follow: '关注', fans: '粉丝', likes: '获赞' };
- var keys = ['follow', 'fans', 'likes'];
- for (var i = 0; i < keys.length; i += 1) {
- var range = f[keys[i]] || {};
- if (TikTokAuto.parse.isRangeOpen(range.min, range.max)) {
- continue;
- }
- parts.push(labels[keys[i]] + ' ' + (range.min || 0) + '~' + (range.max || '∞'));
- }
- return parts.length === 0 ? '不过滤(拿到就收)' : parts.join(',');
- }
- return {
- needsProfile: needsProfile,
- judge: judge,
- describe: describe
- };
- }());
- /* ============================== src/profile.js ============================== */
- /**
- * profile.js — 在 TikTok 里"走到某个账号的主页并读出来"
- *
- * ## 这一层管什么
- * 只管**导航**与**读取**:打开搜索、进用户主页、点开粉丝列表、翻一屏、退回去。
- * 它不判断"要不要这个人"(那是 filters.js),也不管"从哪来"(idsource.js)。
- *
- * ## 真机实测出来的三条导航事实(2026-10-20 / TikTok 47.1.4)
- * 1. **用户主页 → 粉丝列表是一张覆盖页**,顶部有三个页签(Following / Followers / Suggested),
- * 可以直接在页签间切换 —— 所以"先收粉丝再收关注"不必退回主页再进。
- * 2. **从列表进用户页再返回,列表位置会保持**(实测首行与坐标都不变)。
- * 这条决定了翻页策略:可以"读一屏 → 逐个进 → 退回来接着读下一屏",
- * 不必先把整屏 uid 存起来再统一处理。
- * 3. **列表行里就有 uid**(`txt_desc`),不必为了拿 uid 去开用户页。
- * 用户页只在**需要更多资料**(数量、语言、私密、头像)时才开 —— 差一个数量级的时间。
- *
- * ## 头像为什么要截屏
- * 头像是 ImageView,**没有 text、没有 desc、不可点**,无障碍树里读不到任何线索。
- * 实测对比:默认头像(灰色人形剪影)高饱和像素占比 0.035,真头像 0.472 ——
- * 分离度足够,所以用像素法。**已知局限:纯黑白照片的头像会被误判成"无头像"**,
- * 这一点会如实写进 README 与界面说明,不假装没有。
- *
- * 依赖:TikTokAuto.layout / tiktok / ui / wait / logger / parse
- */
- TikTokAuto.profile = (function () {
- 'use strict';
- /** 头像采样网格:5x5。25 个点足够区分剪影与照片,又不会让截图开销变成瓶颈。 */
- var AVATAR_GRID = 5;
- /** 高饱和像素占比超过它就认为"有头像"。实测默认头像 0.035 / 真头像 0.472。 */
- var AVATAR_SATURATED_RATIO = 0.12;
- function logger() { return TikTokAuto.logger; }
- /** 等页面安静下来(签名连续几次不变),比固定 sleep 可靠 */
- function settle(maxMs) {
- TikTokAuto.wait.waitQuiet(function () {
- return TikTokAuto.layout.signature();
- }, { maxMs: maxMs || 2500, tickMs: 200, stableTimes: 2 });
- }
- // ------------------------------------------------------------ 读取主页
- /**
- * 把当前用户主页读成一份资料。
- *
- * 读不到的字段一律给 **null**(而不是 0 或空串):
- * "读不到"与"就是 0"在过滤判定里含义完全不同(见 filters.js 文件头)。
- *
- * @returns { uid, nickname, signature, follow, fans, likes, isPrivate, hasAvatar, gridCount }
- */
- function readProfile() {
- var tiktok = TikTokAuto.tiktok;
- var parse = TikTokAuto.parse;
- var userNode = tiktok.profileUsernameNode();
- var uid = userNode ? parse.username(TikTokAuto.layout.text(userNode)) : '';
- var nicknameNode = tiktok.profileNicknameNode();
- var signatureNode = tiktok.profileSignatureNode();
- var stats = readStatsStable();
- var hint = tiktok.privateHint();
- var grid = tiktok.videoGridCount();
- return {
- uid: uid,
- nickname: nicknameNode ? TikTokAuto.layout.text(nicknameNode) : '',
- signature: signatureNode ? TikTokAuto.layout.text(signatureNode) : '',
- follow: stats.follow,
- fans: stats.fans,
- likes: stats.likes,
- /* 私密:读到了提示文案才算私密;读不到就是"看起来是公开的"。
- * 反过来判("没读到就是私密")会把所有正常账号都排除掉。 */
- isPrivate: hint !== '' && grid < 3,
- privateHint: hint,
- /* 头像:null = 判定不可用(截屏没授权 / 找不到头像节点) */
- hasAvatar: readAvatarState(),
- gridCount: grid
- };
- }
- /**
- * 读三组数字,直到**连续两次一致**才采信。
- *
- * ★ 这是实测踩出来的:点开用户主页后,页面会先渲染骨架、再把数字填进去,
- * 中间那一小段时间读到的数字是**占位的 0**。
- * 同一个账号(@user3670597276145)两次运行分别读到 78 与 0,
- * 而 78 才是真的 —— 如果拿 0 去比"粉丝量 ≥ 1000",它就被冤枉地跳过了。
- * 页面"安静"不代表数字已经就位,只有"两次读到一样"才是可靠的信号。
- */
- function readStatsStable() {
- var tiktok = TikTokAuto.tiktok;
- TikTokAuto.wait.tick(350);
- var previous = tiktok.statNumbers();
- for (var i = 0; i < 3; i += 1) {
- TikTokAuto.wait.tick(350);
- var current = tiktok.statNumbers();
- if (current.follow === previous.follow && current.fans === previous.fans &&
- current.likes === previous.likes) {
- /* 三组全是 0 是**可疑值**:既可能是真新号,也可能是占位还没换掉。
- * 多等 1.5 秒复核一次 —— 真新号复核后还是 0,不影响结果;
- * 占位的话这一刻通常已经填上真值了(实测把 2019 粉丝读成 0 过)。 */
- if (current.follow === 0 && current.fans === 0 && current.likes === 0) {
- TikTokAuto.wait.tick(1500);
- var recheck = tiktok.statNumbers();
- if (recheck.follow !== 0 || recheck.fans !== 0 || recheck.likes !== 0) {
- logger().info('profile', '三组全 0 复核后拿到真值:' +
- recheck.follow + '/' + recheck.fans + '/' + recheck.likes);
- return recheck;
- }
- }
- return current;
- }
- previous = current;
- }
- logger().warn('profile', '三组数字连续三次都没稳定,采用最后一次的值(可能是中间态)');
- return previous;
- }
- /**
- * 截屏授权状态:null = 还没试过,true = 已就绪,false = 不可用。
- *
- * ★ 真机实测(2026-10-20):**`requestScreenCapture(false)` 是静默的** ——
- * AutoX 的无障碍服务带 `CAPABILITY_CAN_TAKE_SCREENSHOT`,申请当场返回 true,
- * 不弹 MediaProjection 确认框,1 秒内完成,随后 `captureScreen()` 直接可用。
- * 而**不主动申请**时 `captureScreen()` 会抛 `No screen capture permission` ——
- * 这就是早先"排除无头像一直不生效、每个账号还刷一条错误"的全部原因。
- *
- * 代价与风险:某些设备/ROM 上申请会弹一次系统授权框并阻塞。
- * 所以申请失败就锁定"不可用"并**只告知一次**,不让它把整轮拖住或刷屏。
- */
- var screenCaptureReady = null;
- function ensureScreenCapture() {
- if (screenCaptureReady !== null) {
- return screenCaptureReady;
- }
- if (typeof requestScreenCapture !== "function") {
- screenCaptureReady = false;
- logger().warn('profile', '这个运行时不支持截屏申请,本次运行的头像判定会跳过');
- return false;
- }
- try {
- screenCaptureReady = requestScreenCapture(false) === true;
- } catch (error) {
- screenCaptureReady = false;
- logger().warn('profile', '申请截屏授权失败,本次运行的头像判定会跳过:' + error);
- return false;
- }
- if (screenCaptureReady) {
- logger().info('profile', '截屏授权已就绪,头像判定可用');
- } else {
- logger().warn('profile', '截屏授权未获批准,本次运行的头像判定会跳过');
- }
- return screenCaptureReady;
- }
- /**
- * 头像是默认剪影吗?
- *
- * ★ "判为无头像"必须**再采一次**才采信。
- * 实测发现:刚进用户页时(`waitProfile` 只等到 `@username` 出现,约 585ms),
- * 头像图片往往还在下载,截到的是**灰色占位块** → 高饱和 0.000 → 误判"无头像"
- * → 一个明明有头像的账号被错误跳过。
- * 有色的情况不必二次确认(灰底不会自己变彩色),所以这个额外开销只落在
- * "疑似无头像"的那部分账号上,约多花 400ms。
- *
- * @returns true(有头像)/ false(默认剪影)/ null(判不了)
- */
- function readAvatarState() {
- if (!ensureScreenCapture()) {
- return null;
- }
- var node = avatarNode();
- if (!node) {
- return null;
- }
- var first = sampleAvatar(node);
- if (first === null || first === true) {
- return first;
- }
- /* 第一次判为"无色":等图片加载完再确认一次 */
- TikTokAuto.wait.tick(400);
- var second = sampleAvatar(node);
- if (second === null) {
- return first;
- }
- if (second !== first) {
- logger().info('profile', '头像两次采样不一致(' + first + ' → ' + second +
- '),采用第二次 —— 第一次多半是图片还没加载');
- }
- return second;
- }
- /**
- * 对头像区域采样一次,返回"是否有头像"。
- * @returns true / false / null(拿不到节点或截屏失败)
- */
- function sampleAvatar(node) {
- var box = node.bounds();
- var saturated = 0;
- var total = 0;
- var image = null;
- try {
- image = captureScreen();
- } catch (error) {
- /* 申请过了却还是拿不到(被系统撤销 / ROM 限制):不是"没头像",
- * 是"这次判不了" —— 必须返回 null,由上层如实告知。 */
- screenCaptureReady = false;
- logger().warn('profile', '截屏不可用,后续账号不再尝试头像判定:' + error);
- return null;
- }
- if (!image) {
- screenCaptureReady = false;
- return null;
- }
- var stepX = (box.right - box.left) / (AVATAR_GRID + 1);
- var stepY = (box.bottom - box.top) / (AVATAR_GRID + 1);
- for (var row = 1; row <= AVATAR_GRID; row += 1) {
- for (var col = 1; col <= AVATAR_GRID; col += 1) {
- var x = Math.round(box.left + stepX * col);
- var y = Math.round(box.top + stepY * row);
- var pixel = 0;
- try {
- pixel = image.pixel(x, y);
- } catch (error2) {
- continue;
- }
- total += 1;
- if (isSaturated(pixel)) {
- saturated += 1;
- }
- }
- }
- if (total === 0) {
- return null;
- }
- var ratio = saturated / total;
- logger().info('profile', '头像像素判定:高饱和 ' + saturated + '/' + total +
- ' = ' + ratio.toFixed(3) + '(阈值 ' + AVATAR_SATURATED_RATIO + ')');
- return ratio >= AVATAR_SATURATED_RATIO;
- }
- /** ARGB int → 饱和度是否够高。s = (max-min)/max,避开明亮的白色背景。 */
- function isSaturated(pixel) {
- var r = (pixel >> 16) & 0xff;
- var g = (pixel >> 8) & 0xff;
- var b = pixel & 0xff;
- var max = Math.max(r, Math.max(g, b));
- var min = Math.min(r, Math.min(g, b));
- if (max < 40) {
- return false; /* 太暗:黑边/阴影,不算色彩 */
- }
- return (max - min) / max >= 0.25;
- }
- /**
- * 用户主页的头像节点。
- * 判据:可见 ImageView、尺寸 140~280px、位于页面上部 1/3。
- * 实测新版布局在右上角([856,168][1048,360]),老布局可能在左上,所以两侧都认。
- */
- function avatarNode() {
- var layout = TikTokAuto.layout;
- var best = null;
- var bestArea = 0;
- var collection = className('android.widget.ImageView').find();
- for (var i = 0; i < collection.size(); i += 1) {
- var node = collection.get(i);
- if (!layout.isVisible(node)) {
- continue;
- }
- var box = node.bounds();
- var width = box.right - box.left;
- var height = box.bottom - box.top;
- if (width < 140 || width > 280 || height < 140 || height > 280) {
- continue;
- }
- if (box.top > device.height * 0.33) {
- continue;
- }
- /* 取面积最大的那个:页面上部可能还有别的图标,头像一定是最大的 */
- if (width * height > bestArea) {
- bestArea = width * height;
- best = node;
- }
- }
- return best;
- }
- // ------------------------------------------------------------ 导航
- /** 当前在哪个页面(ID 提取这条链路自己的判据) */
- function where() {
- return TikTokAuto.tiktok.idPageState();
- }
- /**
- * 打开一个账号的主页(走搜索)。
- *
- * 为什么不直接拼 URL 打开:TikTok 的 deeplink 需要签名参数,
- * 而且"用户在用的这个 TikTok 版本"才是唯一可靠的入口。
- * 走搜索虽然慢(约 8 秒),但实测稳定。
- *
- * @returns 资料对象;失败返回 null
- */
- function openBySearch(uid, config) {
- var step = 'openProfile';
- var tiktok = TikTokAuto.tiktok;
- var log = logger();
- var parse = TikTokAuto.parse;
- var wanted = parse.username(uid);
- if (wanted === '') {
- return null;
- }
- log.step(step, '搜索并打开 @' + wanted);
- if (!ensureFeed(config)) {
- return null;
- }
- if (!openSearch(config)) {
- return null;
- }
- if (!submit(wanted, config)) {
- return null;
- }
- /* 切到 Users 页签:不切的话看到的是视频,找不到人 */
- var usersTab = tiktok.resultTab('Users');
- if (usersTab) {
- TikTokAuto.ui.clickNode(usersTab, step + ':usersTab');
- /* ★ 等**结果行真的出现**,不能只等"页面安静":
- * 页签条一渲染出来签名就稳定了,而结果列表还要联网加载 1~2 秒。
- * 这里如果不等,就会稳定地误报"搜索结果里找不到该账号"(实测踩到)。 */
- TikTokAuto.wait.waitFor(step + ':rows', function () {
- return tiktok.usersResultRows().length > 0 ? true : null;
- }, config.timeouts.searchResultsMs, config.probe.pollTickMs);
- }
- var row = findRowByUid(wanted);
- if (!row) {
- log.warn(step, '搜索结果里找不到 @' + wanted +
- '(当前可见 ' + tiktok.usersResultRows().length + ' 行)');
- return null;
- }
- tapRow(row, step + ':row');
- var arrived = waitProfile(step);
- if (!arrived) {
- /* 实测:`node.click()` 打在整行容器上会落在**中心**,
- * 而搜索用户行的中心那块是空白(有时还是 Follow 按钮的邻域)——
- * 点了返回 true 却没进主页。左侧 30% 处(用户名文字上)才稳定。
- * 所以这里再补一次"按坐标点",只重试一次,不做无限重试。 */
- log.info(step, '第一次点击没进主页,改用行内坐标重试');
- tapRow(row, step + ':row-retry');
- arrived = waitProfile(step + ':retry');
- }
- if (!arrived) {
- log.warn(step, '点了搜索结果但没有进入用户主页(当前 ' + where() + ')');
- return null;
- }
- /* 主页渲染完成后,数字区可能还差一帧。读之前再确认一次"三组数字里至少有一个读到了",
- * 否则会把"还没渲染"当成"这个账号没有数据"。最多再等 3 秒。 */
- TikTokAuto.wait.waitFor(step + ':stats', function () {
- var stats = tiktok.statNumbers();
- return (stats.follow !== null || stats.fans !== null || stats.likes !== null) ? true : null;
- }, 3000, 200);
- return readProfile();
- }
- /** 搜索页 → 输入 → 提交。复用 flow 里已经验证过的锚点,但关键词由这里给。 */
- function openSearch(config) {
- var step = 'openSearch';
- var entry = TikTokAuto.tiktok.searchEntry();
- if (!entry) {
- logger().error(step, '首页找不到搜索入口');
- return false;
- }
- TikTokAuto.ui.clickNode(entry, step);
- return TikTokAuto.wait.waitFor(step, function () {
- var state = TikTokAuto.tiktok.idPageState();
- return (state === 'search' || state === 'users-results') ? true : null;
- }, config.timeouts.pageReadyMs, config.probe.pollTickMs) === true;
- }
- function submit(keyword, config) {
- var step = 'submitKeyword';
- var tiktok = TikTokAuto.tiktok;
- var input = tiktok.searchInput();
- if (!input) {
- logger().error(step, '找不到搜索输入框');
- return false;
- }
- TikTokAuto.ui.clickNode(input, step);
- TikTokAuto.wait.tick(250);
- var typed = TikTokAuto.ui.typeInto(tiktok.searchInput() || input, keyword, step);
- if (!typed.ok) {
- return false;
- }
- var button = tiktok.searchSubmit();
- if (!button) {
- logger().error(step, '找不到搜索提交按钮');
- return false;
- }
- TikTokAuto.ui.clickNode(button, step);
- return TikTokAuto.wait.waitFor(step, function () {
- var state = tiktok.idPageState();
- return (state === 'users-results' || TikTokAuto.tiktok.resultTabStripPresent()) ? true : null;
- }, config.timeouts.searchResultsMs, config.probe.pollTickMs) === true;
- }
- /**
- * 打开**自己**的主页(底部导航的 Profile)。
- *
- * 为什么必须先回信息流:底部导航只在首页有。当前若停在搜索页 / 播放页 /
- * 别人的主页,屏幕上根本没有那个入口 —— 直接找必然失败,而且报的是
- * "找不到 Profile 入口"这种看不出原因的话。
- *
- * 为什么不用 openBySearch(自己的用户名):引擎里没有"我是谁"这个信息
- * (不带登录态、也不应该去猜),而底部导航这一步是零成本且永远正确的。
- */
- function openOwnProfile(config) {
- var step = 'openOwnProfile';
- var log = logger();
- if (!ensureFeed(config)) {
- log.warn(step, '回不到信息流,打不开自己的主页');
- return false;
- }
- var nav = TikTokAuto.tiktok.profileNav();
- if (!nav) {
- log.warn(step, '底部导航上找不到 Profile 入口');
- return false;
- }
- TikTokAuto.ui.clickNode(nav, step);
- var ready = TikTokAuto.wait.waitFor(step, function () {
- return where() === 'profile' ? true : null;
- }, config.timeouts.pageReadyMs, config.probe.pollTickMs);
- if (ready !== true) {
- log.warn(step, '点了 Profile 但没进入用户主页');
- return false;
- }
- /* ★ 必须等**三组数字**读全,不能用 settle(3000)(2026-10-29 真机踩到)。
- *
- * 「username 出现」比「数字渲染完」早得多:实测点完 Profile 1 秒就
- * `where() === 'profile'` 通过,而 fans 入口挂在数字上 —— 这时 `statEntry('fans')`
- * 找不到,上层看到的是"用户主页上找不到 fans 入口(等了 3 秒仍没有)",
- * 一次运行就此失败。`settle()` 在动态页面上会提前返回(它的判据是
- * "文案连续两次不变",而加载中与渲染完的签名可能一样),挡不住这件事。
- *
- * `waitProfile` 的判据正好是「username + 关注/粉丝/获赞三组都读到」,
- * 而且它读不到时**只警告不失败**(有的账号确实不显示某一组)。 */
- waitProfile(step + ':stats');
- return true;
- }
- /** 在当前搜索结果里按 uid 找行(找不到返回 null,不猜第一行) */
- function findRowByUid(uid) { var rows = TikTokAuto.tiktok.usersResultRows();
- for (var i = 0; i < rows.length; i += 1) {
- if (rows[i].uid === uid) {
- return rows[i];
- }
- }
- return null;
- }
- /** 保证停在信息流首页(复用 flow 的成熟实现,避免两套退出逻辑) */
- function ensureFeed(config) {
- return TikTokAuto.flow.ensureFeed(config) === true;
- }
- /**
- * 在用户主页上打开「粉丝/关注」列表。
- *
- * ★ 这里**不能**用 `settle()` 判就绪:点完之后列表要联网加载,
- * 而 `waitQuiet` 的判据是"可见文案连续两次不变" —— 加载中的列表和
- * 打开前的页面**签名是一样的**,于是它会立刻就返回(实测 872ms 就回来了),
- * 然后被判成"打不开列表"。正确做法是等**列表真的有行**。
- *
- * @param which 'fans' | 'following'
- */
- function openList(which, config) {
- var step = 'openList';
- var tiktok = TikTokAuto.tiktok;
- var wanted = which === 'following' ? 'following' : 'fans';
- var entry = tiktok.statEntry(wanted);
- if (!entry) {
- /* ★ 找不到入口时**再等一次**,不要立刻报失败。
- * 实测(用户 2026-10-21 反馈):从搜索结果进主页后,标题已经出来了,
- * 但数字区还在渲染,这时 `statEntry()` 返回 null,直接判"找不到 fans 入口",
- * 用户看到的现象就是"还没加载出来就已经返回了"。重跑往往就正常 —— 典型的时序问题。
- * 这里最多等 3 秒,够渲染完;真没有(比如私密号不显示列表)就如实失败。 */
- logger().info(step, '暂时找不到 ' + wanted + ' 入口,等页面渲染完再试');
- TikTokAuto.wait.waitFor(step + ':entry', function () {
- entry = tiktok.statEntry(wanted);
- return entry !== null ? true : null;
- }, 3000, 300);
- }
- if (!entry) {
- logger().warn(step, '用户主页上找不到 ' + wanted + ' 入口(等了 3 秒仍没有)');
- return false;
- }
- logger().step(step, '打开列表:' + wanted);
- TikTokAuto.ui.clickNode(entry, step);
- var ready = TikTokAuto.wait.waitFor(step, function () {
- return where() === 'user-list' ? true : null;
- }, config.timeouts.pageReadyMs, config.probe.pollTickMs);
- if (ready === true) {
- return true;
- }
- /* 与粉丝列表相关的一个真机实况:首次打开会弹「给 TikTok 访问你的 Facebook 好友列表」
- * 的授权窗,盖住整个列表。它不是错误页,但会让"列表就绪"永远等不到。
- * 这里点掉它再等一次 —— 只做一轮,不无限重试。 */
- if (dismissFriendPrompt()) {
- logger().info(step, '点掉了好友列表授权窗,再等一次');
- ready = TikTokAuto.wait.waitFor(step + ':retry', function () {
- return where() === 'user-list' ? true : null;
- }, config.timeouts.pageReadyMs, config.probe.pollTickMs);
- }
- return ready === true;
- }
- /**
- * 点掉「Facebook 好友列表」授权窗的"不允许"。
- *
- * 实测这个弹窗的文案是英文 `Don’t allow`(撇号是 U+2019,不是 ASCII 单引号,
- * 直接比对 'Don\'t allow' 是匹配不上的 —— 这里按**包含**比对并同时认几种写法)。
- * 找不到就当没有弹窗,返回 false,由上层决定下一步。
- */
- function dismissFriendPrompt() {
- var layout = TikTokAuto.layout;
- var keywords = ['Facebook', '好友列表', 'friends list'];
- var buttons = className('android.widget.Button').find();
- var hint = false;
- for (var i = 0; i < buttons.size(); i += 1) {
- var text = layout.text(buttons.get(i));
- if (text === '') {
- continue;
- }
- if (text.indexOf('allow') >= 0 || text.indexOf('允许') >= 0 || text.indexOf('允許') >= 0) {
- hint = true;
- continue;
- }
- for (var k = 0; k < keywords.length; k += 1) {
- if (text.indexOf(keywords[k]) >= 0) {
- hint = true;
- break;
- }
- }
- }
- if (!hint) {
- return false;
- }
- /* 有授权窗:找那个"不允许 / Don't allow",它一定比"OK"靠左(实测 [260,1114][539,1209] vs [540,1114][819,1209]) */
- var target = null;
- for (var j = 0; j < buttons.size(); j += 1) {
- var label = layout.text(buttons.get(j));
- if (/don.t allow|不允许|不允許|稍后|稍後|not now|no thanks/i.test(label)) {
- if (target === null || buttons.get(j).bounds().left < target.bounds().left) {
- target = buttons.get(j);
- }
- }
- }
- if (!target) {
- return false;
- }
- logger().info('profile', '点掉授权窗:' + JSON.stringify(TikTokAuto.layout.text(target)));
- TikTokAuto.ui.clickNode(target, 'dismissFriendPrompt');
- settle(2000);
- return true;
- }
- /**
- * 在列表页切页签。已在目标页签上时直接返回 true(不重复点)。
- * @param which 'fans' | 'following'
- */
- function switchListTab(which) {
- var step = 'switchTab';
- var tabs = TikTokAuto.tiktok.userListTabs();
- var wanted = which === 'following' ? 'following' : 'fans';
- for (var i = 0; i < tabs.length; i += 1) {
- if (tabs[i].key !== wanted) {
- continue;
- }
- if (TikTokAuto.tiktok.selectedListTab() === wanted) {
- logger().info(step, '已在 ' + wanted + ' 页签上');
- return true;
- }
- logger().info(step, '切到 ' + wanted + ' 页签(' + tabs[i].label + ')');
- TikTokAuto.ui.clickNode(tabs[i].node, step);
- settle(3000);
- return true;
- }
- logger().warn(step, '找不到 ' + wanted + ' 页签');
- return false;
- }
- /**
- * 当前在不在"一个可以逐行取 uid 的列表页"上。
- *
- * 有两种,长得不一样、资源 id 也不同,但对上层来说都是"一屏一屏的用户行":
- * · `user-list` 粉丝/关注列表(`txt_desc` 装 uid)
- * · `users-results` 搜索结果的用户页签(`tv_username` 装 uid)
- * 上层不该关心是哪种 —— 这正是这个判据存在的意义。
- */
- function isListPage(state) {
- var now = state || where();
- return now === 'user-list' || now === 'users-results';
- }
- /**
- * 列表当前可见的每一行(已按屏幕从上到下排序)。
- *
- * ★ **按当前页面选解析器**,这是实测踩出来的一个"看起来能跑但永远 0 行"的 bug:
- * 当初这里写死成 `userListRows()`(粉丝列表的解析器,找 `txt_desc`),
- * 而搜索结果页的用户行是 `tv_username` —— 于是"取材=搜索结果"时,
- * 上一步 `usersResultRows()` 明明读到了行(日志里 `userRows` 就绪),
- * 下一步 `listRows()` 却返回空,最后表现为"处理 0 行、一个都没抓到"。
- * 两种页面的行结构不同,但**对调用方是同一件事**,所以分派必须留在这里。
- */
- function listRows() {
- if (where() === 'users-results') {
- return TikTokAuto.tiktok.usersResultRows();
- }
- return TikTokAuto.tiktok.userListRows();
- }
- /**
- * 列表向下翻一屏。
- *
- * 起点刻意选在屏幕中部偏下、终点在中部偏上:太靠下会落到 Follow 按钮上
- * (按钮吃掉手势,页面纹丝不动 —— 这个坑在界面上踩过一次,这里避开)。
- *
- * @returns 翻之前那一屏的 uid 签名,供调用方判断"翻完有没有新东西"
- */
- function scrollList() {
- var layout = TikTokAuto.layout;
- var screen = layout.screen();
- TikTokAuto.ui.dragScreen(
- Math.floor(screen.width * 0.3), Math.floor(screen.height * 0.78),
- Math.floor(screen.width * 0.3), Math.floor(screen.height * 0.3),
- 420
- );
- settle(2500);
- }
- /** 点列表里的一行,进入该用户主页 */
- function openRow(row) {
- var step = 'openRow';
- if (!row) {
- return false;
- }
- logger().info(step, '点开 @' + row.uid);
- tapRow(row, step);
- return waitProfile(step);
- }
- /**
- * 等用户主页**真的渲染出来**。
- *
- * ★ 不能只等 `settle()`(页面安静):点开主页后骨架先出现、`@username`
- * 那几个字后出现,中间页面是"安静的" —— 于是 settle 会提前返回,
- * 紧接着 readProfile() 读到一份**空资料**。
- * 实测后果:一个粉丝数为 0 的账号被当成"读不到资料",或被误判过滤。
- * 判据必须是"@username 节点已经存在"。
- */
- /**
- * 等用户主页**真的渲染完**,而不是"标题出来了就算到"。
- *
- * ★ 两段等待,缺一不可(实测踩出来的):
- *
- * ① 等 `@username` 出现 —— 这一步很快(约 585ms),因为标题先渲染;
- * ② 再等顶部三组数字(关注/粉丝/获赞)**至少读到两个**。
- *
- * 只做 ① 会出两类问题,而且都很难查:
- * · 数字区还是占位的 0 → 把"粉丝 78"读成"粉丝 0"→ 按数量门槛被错误跳过;
- * · 数字区根本没渲染 → `statEntry()` 找不到"粉丝/关注"那个可点入口
- * → 报"用户主页上找不到 fans 入口"(用户看到的就是"还没加载出来就返回了")。
- *
- * 第 ② 段是**尽力等**:有些账号(私密号、无数据的新号)可能只有一组数字,
- * 所以超时也照常返回 true —— 不能因为等不到三组就把正常账号判成失败。
- */
- function waitProfile(step) {
- var tiktok = TikTokAuto.tiktok;
- var arrived = TikTokAuto.wait.waitFor(step + ':profile', function () {
- return tiktok.profileUsernameNode() !== null ? true : null;
- }, 10000, 300);
- if (arrived !== true) {
- return false;
- }
- /* 第二段:数字区。**三组都要读到**,10 秒上限。
- *
- * 为什么要求三组而不是两组:TikTok 会先把数字渲染成占位的 0,
- * 再逐组换成真值。要求"两组"时,`粉丝 0 / 关注 0 / 获赞 null` 这种
- * 半渲染状态就算通过了 —— 实测把 @.1523615 的 2019 粉丝读成了 0,
- * 而这类错误会直接影响数量门槛的判定(读成 0 就会被"粉丝≥1000"筛掉)。 */
- var statsReady = TikTokAuto.wait.waitFor(step + ':stats', function () {
- var stats = tiktok.statNumbers();
- return (stats.follow !== null && stats.fans !== null && stats.likes !== null)
- ? true : null;
- }, 10000, 300);
- if (statsReady !== true) {
- /* 不是错误:确实有账号不显示某一组(新号可能没有获赞)。
- * 但要在日志里留痕,因为后面的"读不到数量"多半是这里没等到。 */
- var last = tiktok.statNumbers();
- logger().info('profile', '三组数字 10 秒内没读全(关注 ' + last.follow +
- ' / 粉丝 ' + last.fans + ' / 获赞 ' + last.likes + '),继续');
- }
- return true;
- }
- /**
- * 点一行用户条目。
- *
- * 用**坐标**而不是 `node.click()`:整行容器的 click() 在真机上常常落到
- * "行内第一个可点元素"(也就是右侧那个 Follow 按钮),会把用户关注掉 ——
- * 这是不能接受的副作用。所以点在行左侧 30% 处(用户名文字上),远离按钮。
- * 实测这一处既能进主页,也不会误触关注。
- */
- function tapRow(row, step) {
- if (row.x === undefined || row.y === undefined) {
- TikTokAuto.ui.clickNode(row.node, step || 'tapRow');
- return;
- }
- TikTokAuto.ui.tapPoint(row.x, row.y);
- }
- /** 从用户主页退回列表(列表位置会保持,实测);两种列表页都认 */
- function backToList() {
- var step = 'backToList';
- var deadline = Date.now() + 10000;
- while (Date.now() < deadline) {
- var state = where();
- if (isListPage(state)) {
- return true;
- }
- if (state === 'profile' || state === 'comment-panel') {
- TikTokAuto.ui.goBack(step);
- settle(2000);
- continue;
- }
- /* 已经退到搜索页/结果页/信息流:说明退过头了(列表已经不在栈上)。
- * 这种情况不该再往回退 —— 再退就退到首页去了。 */
- return false;
- }
- return isListPage();
- }
- /** 从列表退回用户主页 */
- function backToProfile() {
- var step = 'backToProfile';
- var deadline = Date.now() + 8000;
- while (Date.now() < deadline) {
- var state = where();
- if (state === 'profile') {
- return true;
- }
- TikTokAuto.ui.goBack(step);
- settle(2000);
- }
- return where() === 'profile';
- }
- /**
- * 从用户主页退回评论区。
- *
- * 与 backToProfile/backToList 是一组:评论区取材时每个评论者都要
- * "点头像 → 读 → 退回来",退错一层(退到播放页而不是评论区)就会
- * 让下一轮找不到评论者。所以这里按状态确认,不靠数次数。
- */
- function backToCommentPanel() {
- var step = 'backToComment';
- var deadline = Date.now() + 10000;
- while (Date.now() < deadline) {
- var state = where();
- if (state === 'comment-panel') {
- return true;
- }
- if (state === 'player') {
- /* 退到了播放页:把评论面板重新点开,而不是继续退 */
- var button = TikTokAuto.tiktok.commentButton();
- if (button) {
- TikTokAuto.ui.clickNode(button, step + ':reopen');
- settle(2500);
- if (where() === 'comment-panel') {
- return true;
- }
- }
- return false;
- }
- TikTokAuto.ui.goBack(step);
- settle(2000);
- }
- return where() === 'comment-panel';
- }
- return {
- readProfile: readProfile,
- readAvatarState: readAvatarState,
- avatarNode: avatarNode,
- where: where,
- ensureFeed: ensureFeed,
- openBySearch: openBySearch,
- openOwnProfile: openOwnProfile,
- findRowByUid: findRowByUid,
- openList: openList,
- switchListTab: switchListTab,
- listRows: listRows,
- isListPage: isListPage,
- /** 给外部用的"等主页就绪":等 @username + 三组数字,见 waitProfile 的注释 */
- waitProfilePublic: function () { return waitProfile('waitProfile'); },
- scrollList: scrollList,
- openRow: openRow,
- backToList: backToList,
- backToProfile: backToProfile,
- backToCommentPanel: backToCommentPanel,
- settle: settle,
- dismissFriendPrompt: dismissFriendPrompt,
- AVATAR_SATURATED_RATIO: AVATAR_SATURATED_RATIO
- };
- }());
- /* ============================== src/idsource.js ============================== */
- /**
- * idsource.js — 五种「从哪儿找人」,以及深度展开
- *
- * ## 为什么是五种而不是四张脚本卡
- * 旧的四个脚本卡是「起点 × 深度」的 4 个**不完整组合**。把它们拆开后发现,
- * 真正不同的只有一件事:**候选 ID 从哪儿来**。于是收敛成五种起点
- * (指定账号 / 搜索结果 / 某个视频的评论区 / 文本文件 / 当前屏幕),
- * 深度(再取这些人的粉丝)是所有起点共用的一个选项。
- *
- * ## 三种遍历模式
- * 五种起点在"怎么走"上只有三种形态,实现都归到这三条路径上:
- * · **列表型**(指定账号 / 搜索用户 / 当前屏幕)—— 逐屏读行,可翻页;
- * · **文本型**(文本文件)—— 一次性读出每一行;
- * · **逐个点开型**(搜索视频作者 / 评论区)—— 屏幕上的条目没有 uid,
- * 只能点进去看(视频页底部有 `@uid`,评论者要靠头像进用户页)。
- *
- * ## 深度展开为什么分两阶段
- * "取这批人的粉丝"如果边遍历边展开,会嵌套两层列表上下文 ——
- * 打开 B 列表时 A 的滚动位置就丢了。所以做成:
- * ① 先把基础层收完,边收边记下"这些人要展开";
- * ② 再对这批人逐个打开其粉丝/关注列表。
- * 两层都线性,没有嵌套,也就没有"回到上一层时不知道走到哪"的问题。
- * **上限固定两层**:100 粉丝 × 每人 100 粉丝 = 1 万次开页,真机上不现实。
- *
- * 依赖:TikTokAuto.tiktok / profile / filters / parse / logger / wait
- */
- TikTokAuto.idsource = (function () {
- 'use strict';
- /** 翻一个列表最多翻多少屏。防止"永远有新内容"(推荐流)把一轮拖成无限长。 */
- var MAX_SCREENS = 60;
- /**
- * 连续这么多次"翻屏后内容不变"就认为到头了(有的列表末尾会重复推荐位)。
- *
- * ★ 注意判据是**翻不动**,不是"没有新 uid"(2026-10-28 修,见 walkStep 的注释)。
- */
- var IDLE_SCREENS_TO_STOP = 2;
- function logger() { return TikTokAuto.logger; }
- /**
- * 一屏处理完之后决定下一步。纯函数,便于在 PC 上回归(`tools/pc-selftest-id.js`)。
- *
- * ★ 这里修的是一个**真 bug**(2026-10-28)。
- *
- * 旧实现有两个地方同时给 `idle` 加一:
- * ① 本屏 `fresh` 为空(全是已收过的人)→ idle + 1
- * ② 翻屏后签名不变 → idle + 1
- * 而 `idflow` 会先把 `uid_extracted.txt` 预载进 `seen`。于是**第二轮跑同一个账号**时,
- * 前两屏全是历史 → idle=2 → **在列表顶部就判"到底"**,一条新人都拿不到。
- * 断点续跑要把上次的产物也预载进 `seen`,这个误判会从"偶发"变成"必然"。
- *
- * 正解:**只看"翻得动翻不动"**。一屏全是旧人说明"旧人多",不代表列表到头;
- * 只有"滚动之后签名没变"才是真的到头。
- *
- * @param state { screens, stuck }
- * @param view { sigBefore, sigAfter, rowsAfter, maxScreens }
- * @returns { action: 'continue'|'break', state, reason }
- */
- function walkStep(state, view) {
- var stuck = state.stuck;
- var screens = state.screens;
- if (view.rowsAfter === 0) {
- /* 翻完这一屏读不到行了:可能还在加载,也可能真的到头。
- * 不在这里下结论 —— 交给下一轮循环开头的"空行即结束"处理
- * (这样"加载慢"和"列表结束"两个判断只在一处)。 */
- return { action: 'continue', reason: '', state: { screens: screens, stuck: stuck } };
- }
- if (view.sigAfter === view.sigBefore) {
- stuck += 1;
- if (stuck >= IDLE_SCREENS_TO_STOP) {
- return {
- action: 'break',
- reason: '翻屏后内容不变,认为列表到底',
- state: { screens: screens, stuck: stuck }
- };
- }
- } else {
- /* 内容变了 → 列表还在往下走。**不管这一屏里有没有新人**都归零。 */
- stuck = 0;
- }
- if (screens >= view.maxScreens) {
- return {
- action: 'break',
- reason: '达到单列表翻屏上限 ' + view.maxScreens + ' 屏',
- state: { screens: screens, stuck: stuck }
- };
- }
- return { action: 'continue', reason: '', state: { screens: screens, stuck: stuck } };
- }
- /** 一行 uid 的临时载体(列表行带昵称与计数,用户页带更多资料,统一成这个形状) */
- function rowOf(uid, nickname, extra) {
- var one = { uid: uid, nickname: nickname || '' };
- if (extra) {
- for (var key in extra) {
- if (extra.hasOwnProperty(key)) {
- one[key] = extra[key];
- }
- }
- }
- return one;
- }
- /* ============================================================ 列表型 */
- /**
- * 在"一个可滚动的用户列表"上逐行推进。
- *
- * 关键设计:**用 uid 集合决定"哪一行还要处理",不用行下标**。
- * 因为每次点进用户页再退回来,列表的节点树都重建过一遍(引用失效),
- * 只有 uid 是稳的。
- *
- * 但**停止判据不能用 uid 集合** —— 那会把"全是旧人"误判成"列表到底"(见 walkStep)。
- *
- * @param ctx { seen, onRow(row), shouldStop(), maxScreens?, stat?, config? }
- * maxScreens:本次最多翻多少屏(续跑时按上次已翻的屏数放大,见 idflow)
- * stat:传入则回填 { screens, handled },供断点记录
- * @returns 处理了多少行(本屏新收的)
- */
- function walkList(ctx) {
- var log = logger();
- var handled = 0;
- var maxScreens = ctx.maxScreens || MAX_SCREENS;
- var state = { screens: 0, stuck: 0 };
- while (state.screens < maxScreens) {
- if (ctx.shouldStop()) {
- log.info('idsource', '达到停止条件,结束当前列表');
- break;
- }
- state.screens += 1;
- var rows = TikTokAuto.profile.listRows();
- if (rows.length === 0 && state.screens === 1) {
- /* 首屏还在加载。**等一次**再判定,否则会把"慢"当成"空":
- * 从用户页点开粉丝列表、或从结果页切到用户页签后,
- * 容器先出来、行后出来(实测差 1~2 秒),这时 listRows() 返回空数组 ——
- * 直接 break 就等于"一条都没抓到"。 */
- TikTokAuto.wait.waitFor('listRows', function () {
- return TikTokAuto.profile.listRows().length > 0 ? true : null;
- }, ctx.config ? ctx.config.timeouts.pageReadyMs : 8000, 300);
- rows = TikTokAuto.profile.listRows();
- }
- if (rows.length === 0) {
- log.info('idsource', '当前屏没有读到用户行,结束列表');
- break;
- }
- /* 一行一行处理:只处理没见过的人(见过的直接跳过,但**不因此判定列表到底**)。
- * 需要资料时会点进用户页再退回(实测位置保持)。 */
- var stopped = false;
- for (var i = 0; i < rows.length; i += 1) {
- if (ctx.shouldStop()) {
- stopped = true;
- break;
- }
- if (ctx.seen.has(rows[i].uid)) {
- continue;
- }
- ctx.onRow(rows[i]);
- ctx.seen.add(rows[i].uid); /* 处理过就算见过,避免同一行被反复点开 */
- handled += 1;
- }
- if (stopped) {
- break;
- }
- /* 翻一屏,用"内容有没有变"判断是否到头 */
- var before = signatureOfRows(rows);
- TikTokAuto.profile.scrollList();
- var afterRows = TikTokAuto.profile.listRows();
- var step = walkStep(state, {
- sigBefore: before,
- sigAfter: signatureOfRows(afterRows),
- rowsAfter: afterRows.length,
- maxScreens: maxScreens
- });
- state = step.state;
- if (step.action === 'break') {
- log.info('idsource', step.reason);
- break;
- }
- }
- if (ctx.stat) {
- ctx.stat.screens = state.screens;
- ctx.stat.handled = handled;
- }
- return handled;
- }
- /** 一屏 uid 的签名,用来判断"翻完到底有没有变" */
- function signatureOfRows(rows) {
- var parts = [];
- for (var i = 0; i < rows.length; i += 1) {
- parts.push(rows[i].uid);
- }
- return parts.join('|');
- }
- /* ============================================================ 文本型 */
- /**
- * 从本地文本文件读 uid。
- *
- * 两条容忍(都是为了让用户不用改自己的文件):
- * · 行首的 `@` 会被剥掉(有人习惯直接粘贴 "@name");
- * · `#` 开头的行当注释跳过。
- */
- function readTextSource(source) {
- var list = TikTokAuto.uidset.readUidFile(source.path);
- logger().info('idsource', '文本取材:' + source.path + ' 读到 ' + list.length + ' 行');
- var rows = [];
- for (var i = 0; i < list.length; i += 1) {
- rows.push(rowOf(list[i], ''));
- }
- return rows;
- }
- /* ============================================================ 逐个点开型 */
- /**
- * 搜索结果里的视频 → 逐个点开,读播放页底部的作者 uid。
- *
- * 实测:搜索结果的视频卡片上只有**作者昵称**,没有 uid;
- * 播放页底部有一个 `Button` 文案是 `@uid` —— 这是唯一能拿到 uid 的地方。
- * 所以每个视频要"点开 → 读 → 退回",约 6 秒一个。
- */
- function walkSearchVideos(source, ctx) {
- var log = logger();
- var handled = 0;
- var idle = 0;
- for (var screen = 0; screen < MAX_SCREENS; screen += 1) {
- if (ctx.shouldStop()) {
- break;
- }
- var cards = TikTokAuto.tiktok.videoCards();
- if (cards.length === 0) {
- /* 0 张卡可能是"到底了",也可能是"列表正在重排/图片在加载"。
- * 先给 5 秒,等不到再判定到底 —— 直接 break 会把慢当成空。 */
- cards = TikTokAuto.flow.waitForCards(ctx.config, 5000) || [];
- }
- if (cards.length === 0) {
- log.info('idsource', '结果页没有卡片了');
- break;
- }
- var opened = 0;
- for (var i = 0; i < cards.length; i += 1) {
- if (ctx.shouldStop()) {
- return handled;
- }
- var card = cards[i];
- var marker = 'card:' + String(card.caption).slice(0, 40);
- if (ctx.seen.has(marker)) {
- continue;
- }
- ctx.seen.add(marker);
- if (!TikTokAuto.flow.openCard(ctx.config, card)) {
- log.warn('idsource', '打开视频失败:' + marker);
- TikTokAuto.flow.returnToResults(ctx.config);
- continue;
- }
- var uid = TikTokAuto.tiktok.playerAuthorUid();
- if (uid) {
- ctx.onRow(rowOf(uid, String(card.caption).slice(0, 30)));
- handled += 1;
- opened += 1;
- } else {
- log.warn('idsource', '播放页读不到作者 uid');
- }
- TikTokAuto.flow.returnToResults(ctx.config);
- }
- if (opened === 0) {
- idle += 1;
- if (idle >= IDLE_SCREENS_TO_STOP) {
- log.info('idsource', '连续几屏都读不到作者,结束');
- break;
- }
- } else {
- idle = 0;
- }
- TikTokAuto.flow.scrollResults(ctx.config);
- }
- return handled;
- }
- /**
- * 评论区 → 逐个点评论者的头像进用户页读 uid。
- *
- * 实测这条最贵:评论区只显示昵称,没有 uid(`Button title` 是昵称,
- * 旁边那个 `@uid` 是固定节点、不是评论者的)。所以每个评论者都要
- * "点头像 → 读 → 退回",约 8 秒一个。这也是为什么方案里
- * 把"评论者"和"评论者的粉丝"分开成两条取材,而不是默认都开。
- */
- function walkCommenters(source, ctx) {
- var log = logger();
- var handled = 0;
- for (var screen = 0; screen < MAX_SCREENS; screen += 1) {
- if (ctx.shouldStop()) {
- break;
- }
- var rows = TikTokAuto.tiktok.commentRowers();
- if (rows.length === 0) {
- log.info('idsource', '评论区没有更多可见条目');
- break;
- }
- var opened = 0;
- var before = signatureOfNodes(rows);
- for (var i = 0; i < rows.length; i += 1) {
- if (ctx.shouldStop()) {
- return handled;
- }
- /* 头像节点每次都会重建,用"位置 + 序号"当标记 —— 换一屏位置就会变 */
- var marker = 'cmt:' + rows[i].top + ':' + (screen * 100 + i);
- if (ctx.seen.has(marker)) {
- continue;
- }
- ctx.seen.add(marker);
- TikTokAuto.ui.tapPoint(rows[i].x, rows[i].y);
- /* ★ 用 waitProfile(等 @username + 三组数字),而不是 settle + where()。
- *
- * 早先这里是"等页面安静 3 秒,再看 where() 是不是 profile":
- * · settle 会提前返回(骨架先出、内容后加载,签名看起来没变);
- * · 而 where() 要求 `@username` 已经渲染出来。
- * 两头一凑,就成了"人明明已经进主页了,却被判成没进去"——
- * 实测日志里连续两条"点评论者头像没有进入用户主页",然后一条都没取到。
- */
- if (!TikTokAuto.profile.waitProfilePublic()) {
- log.warn('idsource', '点评论者头像没有进入用户主页(等了 10 秒)');
- continue;
- }
- var profile = TikTokAuto.profile.readProfile();
- if (profile.uid) {
- /* 资料已经在手上了,带上 `_profile` 让编排层不必再开一次用户页 */
- ctx.onRow(rowOf(profile.uid, profile.nickname, { _profile: profile }));
- handled += 1;
- opened += 1;
- }
- TikTokAuto.profile.backToCommentPanel();
- }
- if (opened === 0) {
- break;
- }
- /* 评论区滚动 */
- var layout = TikTokAuto.layout;
- var screenSize = layout.screen();
- TikTokAuto.ui.dragScreen(
- Math.floor(screenSize.width * 0.5), Math.floor(screenSize.height * 0.8),
- Math.floor(screenSize.width * 0.5), Math.floor(screenSize.height * 0.4),
- 420
- );
- TikTokAuto.profile.settle(2000);
- if (signatureOfNodes(TikTokAuto.tiktok.commentRowers()) === before) {
- log.info('idsource', '评论区滚动后内容不变,结束');
- break;
- }
- }
- return handled;
- }
- function signatureOfNodes(rows) {
- var parts = [];
- for (var i = 0; i < rows.length; i += 1) {
- parts.push(rows[i].top);
- }
- return parts.join(',');
- }
- /* ============================================================ 入口 */
- /**
- * 把一个取材源跑完,逐行交给 `onRow`。
- *
- * @param source 取材项(与界面 sources.js 的形状一致)
- * @param ctx { config, seen, onRow(row), shouldStop() }
- * @returns 处理的行数
- */
- function run(source, ctx) {
- var log = logger();
- log.step('idsource', '取材开始:' + describe(source));
- if (source.kind === 'text') {
- /* 文本源也走 `onRow`,**不在这一层自己判去重** ——
- * 去重与过滤是编排层(idflow)的职责,在这里再做一遍会让
- * `duplicates` 统计漏掉(实测:跨轮去重跳了 3 个,summary 里却是 0)。 */
- var rows = readTextSource(source);
- var handled = 0;
- for (var i = 0; i < rows.length; i += 1) {
- if (ctx.shouldStop()) {
- break;
- }
- ctx.onRow(rows[i]);
- handled += 1;
- }
- return handled;
- }
- if (source.kind === 'screen') {
- /* 当前屏幕:不做任何导航,直接把眼前这一屏当列表读。
- *
- * 但要先确认眼前确实是"一个用户列表" —— 否则 walkList 会一屏一屏地读,
- * 最后报"处理 0 行",用户完全不知道是页面不对。 */
- var state = TikTokAuto.profile.where();
- if (!TikTokAuto.profile.isListPage(state)) {
- log.warn('idsource', '当前屏幕取材:需要先把页面停在「用户列表」(粉丝/关注/搜索用户)上,' +
- '引擎不会替你跳转。现在停在「' + state + '」');
- return 0;
- }
- return walkList(ctx);
- }
- if (source.kind === 'anchor') {
- return runAnchor(source, ctx);
- }
- if (source.kind === 'search') {
- if (source.searchType === 'videos') {
- if (!prepareSearch(source.keyword, ctx.config)) {
- return 0;
- }
- TikTokAuto.flow.selectResultTab({
- target: { resultTab: 'Top' },
- timeouts: ctx.config.timeouts,
- probe: ctx.config.probe
- });
- return walkSearchVideos(source, ctx);
- }
- if (!prepareSearch(source.keyword, ctx.config)) {
- return 0;
- }
- return walkList(ctx);
- }
- if (source.kind === 'video') {
- if (!prepareVideo(source, ctx.config)) {
- return 0;
- }
- return walkCommenters(source, ctx);
- }
- log.warn('idsource', '未知的取材起点:' + source.kind);
- return 0;
- }
- /**
- * 指定账号:打开它的主页 → 按 `lists` 逐个开列表 → 每处走一遍 walkList。
- *
- * `anchors` 为空表示"不限定账号,从当前屏幕取" —— 这是界面允许的状态,
- * 对应 v6 时代「主播 ID 留空」的语义。
- */
- function runAnchor(source, ctx) {
- var log = logger();
- var parse = TikTokAuto.parse;
- var anchors = String(source.anchors || '').split('#');
- var handled = 0;
- var accounts = [];
- for (var i = 0; i < anchors.length; i += 1) {
- var name = parse.username(anchors[i]);
- if (name !== '') {
- accounts.push(name);
- }
- }
- if (accounts.length === 0) {
- log.info('idsource', '没有指定账号 → 直接把当前屏幕当列表');
- return walkList(ctx);
- }
- for (var a = 0; a < accounts.length; a += 1) {
- if (ctx.shouldStop()) {
- break;
- }
- var profile = TikTokAuto.profile.openBySearch(accounts[a], ctx.config);
- if (!profile) {
- log.warn('idsource', '打不开账号 @' + accounts[a] + ',跳过');
- continue;
- }
- ctx.onSeenProfile(profile);
- var lists = source.lists && source.lists.length > 0 ? source.lists : ['fans'];
- for (var l = 0; l < lists.length; l += 1) {
- if (ctx.shouldStop()) {
- return handled;
- }
- var which = lists[l] === 'home' ? 'fans' : lists[l];
- if (!TikTokAuto.profile.openList(which, ctx.config)) {
- log.warn('idsource', '打不开 ' + lists[l] + ' 列表');
- continue;
- }
- TikTokAuto.profile.switchListTab(which);
- handled += walkList(ctx);
- TikTokAuto.profile.backToProfile();
- }
- }
- return handled;
- }
- /** 搜索关键词并停在结果页(供列表型与逐个点开型共用) */
- function prepareSearch(keyword, config) {
- var log = logger();
- if (!TikTokAuto.profile.ensureFeed(config)) {
- return false;
- }
- var tiktok = TikTokAuto.tiktok;
- var entry = tiktok.searchEntry();
- if (!entry) {
- log.error('idsource', '首页找不到搜索入口');
- return false;
- }
- TikTokAuto.ui.clickNode(entry, 'idsource:search');
- if (TikTokAuto.wait.waitFor('idsource:search', function () {
- var state = tiktok.idPageState();
- return (state === 'search' || state === 'users-results') ? true : null;
- }, config.timeouts.pageReadyMs, config.probe.pollTickMs) !== true) {
- return false;
- }
- var input = tiktok.searchInput();
- if (!input) {
- return false;
- }
- TikTokAuto.ui.clickNode(input, 'idsource:input');
- TikTokAuto.wait.tick(250);
- if (!TikTokAuto.ui.typeInto(tiktok.searchInput() || input, keyword, 'idsource:type').ok) {
- return false;
- }
- var submit = tiktok.searchSubmit();
- if (!submit) {
- return false;
- }
- TikTokAuto.ui.clickNode(submit, 'idsource:submit');
- if (TikTokAuto.wait.waitFor('idsource:results', function () {
- return tiktok.resultTabStripPresent() ? true : null;
- }, config.timeouts.searchResultsMs, config.probe.pollTickMs) !== true) {
- return false;
- }
- /* 用户搜索要切到 Users 页签;视频搜索留在 Top */
- var usersTab = tiktok.resultTab('Users');
- if (usersTab && !tiktok.resultTabSelected('Users')) {
- TikTokAuto.ui.clickNode(usersTab, 'idsource:usersTab');
- /* 等结果行真的出现(页签条先渲染、行后加载,只等"安静"会误判为空) */
- TikTokAuto.wait.waitFor('idsource:userRows', function () {
- return tiktok.usersResultRows().length > 0 ? true : null;
- }, config.timeouts.searchResultsMs, config.probe.pollTickMs);
- }
- return true;
- }
- /**
- * 打开一个视频(播放页),供评论区取材用。
- * `videoSource = "screen"` 表示用户自己已经把视频停在屏幕上,不动它。
- */
- function prepareVideo(source, config) {
- var log = logger();
- if (source.videoSource !== 'keyword') {
- var state = TikTokAuto.tiktok.pageState();
- if (state === 'player' || state === 'comment-panel') {
- log.info('idsource', '已在播放页,直接用当前屏幕的视频');
- return true;
- }
- /* 这里的提示必须是**可操作**的:用户能做的只有三件事 —— 自己先把页面停好、
- * 改用关键词模式、或者换一个取材源。不能只说"不在播放页"。 */
- log.warn('idsource', '「当前屏幕」需要用你手上已经打开的那个视频,引擎不会替你跳转。\n' +
- ' 请先在 TikTok 里打开视频(或直接打开它的评论区),再点「开始运行」。\n' +
- ' 当前页面是「' + state + '」。');
- return false;
- }
- if (!prepareSearch(source.videoKeyword, config)) {
- return false;
- }
- /* Top 页签是视频;取第一张卡点开 */
- var cards = TikTokAuto.tiktok.videoCards();
- if (cards.length === 0 && !TikTokAuto.flow.waitForCards(config)) {
- return false;
- }
- cards = TikTokAuto.tiktok.videoCards();
- if (cards.length === 0) {
- return false;
- }
- if (!TikTokAuto.flow.openCard(config, cards[0])) {
- return false;
- }
- /* 打开评论面板 */
- var button = TikTokAuto.tiktok.commentButton();
- if (!button) {
- log.warn('idsource', '播放页找不到评论按钮');
- return false;
- }
- TikTokAuto.ui.clickNode(button, 'idsource:comments');
- return TikTokAuto.wait.waitFor('idsource:panel', function () {
- return TikTokAuto.tiktok.commentPanelOpen() ? true : null;
- }, config.timeouts.panelReadyMs, config.probe.pollTickMs) === true;
- }
- /** 一句人话摘要(日志与状态条用) */
- function describe(source) {
- var parse = TikTokAuto.parse;
- if (source.kind === 'anchor') {
- var list = source.anchors ? source.anchors.split('#')[0] : '当前屏幕';
- return '账号 ' + list + ' 的 ' + (source.lists || []).join('+');
- }
- if (source.kind === 'search') {
- return (source.searchType === 'videos' ? '视频作者' : '用户') + '「' + source.keyword + '」';
- }
- if (source.kind === 'video') {
- return source.videoSource === 'keyword'
- ? '评论区「' + source.videoKeyword + '」'
- : '当前屏幕的评论区';
- }
- if (source.kind === 'text') {
- return '文本 ' + String(source.path).split('/').pop();
- }
- return '当前屏幕';
- }
- return {
- MAX_SCREENS: MAX_SCREENS,
- IDLE_SCREENS_TO_STOP: IDLE_SCREENS_TO_STOP,
- run: run,
- walkList: walkList,
- /* 纯函数,供 PC 回归(`tools/pc-selftest-id.js`)——
- * 停止判据是"翻不动了"而不是"没有新人",这条必须能被离线断言。 */
- walkStep: walkStep,
- readTextSource: readTextSource,
- describe: describe
- };
- }());
- /* ============================== src/resolve.js ============================== */
- /**
- * resolve.js — 把 username(@handle)换成 TikTok 的数字 uid
- *
- * ## 为什么需要它
- * 抓取产物里存的是 **username**(`beautifulsoul78869`)—— 那是能搜到人、能 @ 的标识。
- * 但有些下游(对接接口、入库、跟外部系统对账)要的是 **数字 uid**(`254801657882882048`)。
- * 两者完全不同:
- *
- * uniqueId / handle beautifulsoul78869 能搜到人、能 @,界面上可见
- * nickname 🌹Beautiful Soul💀 显示名,可重名、可随时改
- * id(本文的 uid) 254801657882882048 平台内部主键,界面上**读不到**
- *
- * 数字 uid 在 App 界面上任何地方都不显示,所以只能靠 HTTP 拿。
- *
- * ## 三条取数路径(照 `tiktok-uid和视频短链解析.js` 的分级思路)
- *
- * ① `www.tiktok.com/api/user/detail/?uniqueId=` 官方详情接口,最轻(本应几百字节)
- * ② `www.tiktok.com/node/share/user/@<handle>` 老的分享接口
- * ③ `www.tiktok.com/@<handle>` 的整页 SSR 最重(约 388KB),但**最可靠**
- *
- * 2026-10-20 真机实测:**①② 都已经不可用** ——
- * ① 返回 `HTTP 200` 但**响应体为空**(风控静默拒绝,最容易让人误以为"接口还在")
- * ② 返回 `403`
- * ③ 200 / 388KB / 约 2 秒 / `JSON.parse` 仅 7ms
- *
- * 所以路径按"轻 → 重"逐个降级,且**每条只探一次**:某个账号证明 ① 不可用之后,
- * 本次运行内不再尝试 ① —— 否则每个账号都要白付两次失败请求。
- * 这样将来接口恢复了,第一次就会自动走轻的那条;恢复不了也不会拖慢。
- *
- * ## 试过但走不通的(别再试)
- * · `tikwm.com/api/user/info` —— Cloudflare 挑战页("Just a moment...")
- * · `m.tiktok.com/@user` —— 404(那条短链只认 `/v/<videoId>.html`)
- * · `www.tiktok.com/oembed?url=...` —— 只有昵称与 handle,**没有数字 id**
- * · AutoX 的 `http.get` —— 本机这个版本是坏的:
- * `TypeError: Cannot find function newCall`(OkHttp 绑定冲突)→ 走 Java `HttpURLConnection`
- *
- * 依赖:TikTokAuto.logger、TikTokAuto.wait(节流与停止响应)、TikTokAuto.parse
- */
- TikTokAuto.resolve = (function () {
- 'use strict';
- var UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
- '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
- /** 整页近 400KB,慢网下 20 秒是合理的上限;接口那两条给 8 秒就够。 */
- var API_TIMEOUT_MS = 8000;
- var PAGE_TIMEOUT_MS = 20000;
- /**
- * 两次请求之间的间隔区间。
- *
- * 为什么不并发:这是网页版接口,短时间高频请求很容易被要求过验证码;
- * 而我们是"一次几十个"的量级,串行 + 随机间隔的代价可以接受(每个约 2.5 秒)。
- */
- var GAP_MIN_MS = 400;
- var GAP_MAX_MS = 900;
- /**
- * 本次运行里各条路径是否已被证明不可用。
- *
- * 这是**性能关键**:接口①② 实测必失败,若不记住,每个账号要多付两次失败请求;
- * 记住之后只有第一个账号付这个代价。
- */
- var deadPaths = { api: false, share: false };
- /** ttwid 只取一次(接口风控用;主页 HTML 不需要它) */
- var ttwidCache = null;
- var ttwidTried = false;
- function logger() { return TikTokAuto.logger; }
- /**
- * 读一个连接的响应体。
- *
- * ★ 必须区分**正常流与错误流**:`getInputStream()` 在 4xx/5xx 时会直接抛异常,
- * 于是「404 账号不存在」会被误判成「网络错误」。
- * 从 `getErrorStream()` 读出来的 body 才是真正的原因(有时还带 JSON 错误码)。
- */
- function readBody(conn) {
- var stream = null;
- try {
- var code = conn.getResponseCode();
- stream = code >= 400 ? conn.getErrorStream() : conn.getInputStream();
- if (!stream) {
- return { code: code, body: '' };
- }
- var reader = new java.io.BufferedReader(new java.io.InputStreamReader(stream, 'UTF-8'));
- var buffer = new java.lang.StringBuilder();
- var line;
- while ((line = reader.readLine()) !== null) {
- buffer.append(line).append('\n');
- }
- reader.close();
- return { code: code, body: String(buffer.toString()) };
- } finally {
- try { if (stream) { stream.close(); } } catch (ignored) { }
- }
- }
- /**
- * 发一个 GET。
- * @returns { ok, status, body, error }
- */
- function get(url, headers, timeoutMs) {
- var conn = null;
- try {
- conn = new java.net.URL(url).openConnection();
- conn.setConnectTimeout(timeoutMs);
- conn.setReadTimeout(timeoutMs);
- conn.setInstanceFollowRedirects(true);
- conn.setRequestProperty('User-Agent', UA);
- conn.setRequestProperty('Accept-Language', 'en-US,en;q=0.9');
- if (headers) {
- for (var key in headers) {
- if (headers.hasOwnProperty(key)) {
- conn.setRequestProperty(key, headers[key]);
- }
- }
- }
- var result = readBody(conn);
- return { ok: true, status: result.code, body: result.body };
- } catch (error) {
- return { ok: false, status: 0, body: '', error: String(error) };
- } finally {
- /* 一定要断开:不关的话连接会一直占着,几十个账号之后就容易出问题 */
- try { if (conn) { conn.disconnect(); } } catch (ignoredToo) { }
- }
- }
- /**
- * 取 ttwid(TikTok 首页下发,接口风控用)。
- * 取不到不算错 —— 只是那两条接口大概也走不通,直接降级到主页 HTML。
- */
- function ttwid() {
- if (ttwidTried) {
- return ttwidCache;
- }
- ttwidTried = true;
- var conn = null;
- try {
- conn = new java.net.URL('https://www.tiktok.com/').openConnection();
- conn.setConnectTimeout(API_TIMEOUT_MS);
- conn.setReadTimeout(API_TIMEOUT_MS);
- conn.setRequestProperty('User-Agent', UA);
- conn.getResponseCode();
- var fields = conn.getHeaderFields();
- var cookies = fields ? fields.get('Set-Cookie') : null;
- if (cookies) {
- for (var i = 0; i < cookies.size(); i += 1) {
- var one = String(cookies.get(i));
- var idx = one.indexOf('ttwid=');
- if (idx !== -1) {
- ttwidCache = one.substring(idx).split(';')[0]; /* 形如 ttwid=xxx */
- break;
- }
- }
- }
- } catch (error) {
- logger().warn('resolve', '取 ttwid 失败(不影响后续降级):' + error);
- } finally {
- try { if (conn) { conn.disconnect(); } } catch (ignored) { }
- }
- return ttwidCache;
- }
- /** 从 JSON 对象里找 `userInfo.user`(别写死键路径,层级改过) */
- function findUser(node, depth) {
- if (depth > 8 || node === null || typeof node !== 'object') {
- return null;
- }
- if (node.userInfo && node.userInfo.user && node.userInfo.user.id) {
- return node.userInfo.user;
- }
- for (var key in node) {
- if (node.hasOwnProperty(key)) {
- var hit = findUser(node[key], depth + 1);
- if (hit) {
- return hit;
- }
- }
- }
- return null;
- }
- /** JSON 解析失败时用正则兜一层(整页 SSR 结构改版时不至于全废) */
- function findUserByRegex(html, handle) {
- var escaped = String(handle).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
- var matched = html.match(
- new RegExp('"uniqueId":"' + escaped + '"[^}]*?"id":"(\\d+)"')
- ) || html.match(
- new RegExp('"id":"(\\d+)","shortId":"[^"]*","uniqueId":"' + escaped + '"')
- );
- if (!matched) {
- return null;
- }
- return { id: matched[1], uniqueId: handle };
- }
- /** 统一校验:拿到的确实是这个 handle 的用户(网页版可能重定向到别名) */
- function accept(user, wanted) {
- if (!user || !user.id) {
- return null;
- }
- var got = TikTokAuto.parse.username(user.uniqueId || '');
- if (got !== '' && got.toLowerCase() !== String(wanted).toLowerCase()) {
- logger().warn('resolve', '返回的是另一个账号(' + got + ' ≠ ' + wanted + '),丢弃');
- return null;
- }
- return {
- ok: true,
- uid: String(user.id),
- nickname: user.nickname || '',
- secUid: user.secUid || ''
- };
- }
- /* ---------------------------------------------------------- 三条路径 */
- /** ① 官方用户详情接口 */
- function viaApi(handle) {
- var headers = {
- 'Accept': 'application/json, text/plain, */*',
- 'Referer': 'https://www.tiktok.com/@' + encodeURIComponent(handle)
- };
- var cookie = ttwid();
- if (cookie) {
- headers['Cookie'] = cookie;
- }
- var res = get('https://www.tiktok.com/api/user/detail/?uniqueId=' +
- encodeURIComponent(handle), headers, API_TIMEOUT_MS);
- if (!res.ok || res.status !== 200 || res.body === '') {
- return null;
- }
- var data = null;
- try { data = JSON.parse(res.body); } catch (error) { return null; }
- return findUser(data, 0);
- }
- /** ② 老的分享接口 */
- function viaShare(handle) {
- var res = get('https://www.tiktok.com/node/share/user/@' + encodeURIComponent(handle) +
- '?uniqueId=' + encodeURIComponent(handle),
- { 'Accept': 'application/json, text/plain, */*' }, API_TIMEOUT_MS);
- if (!res.ok || res.status !== 200 || res.body === '') {
- return null;
- }
- var data = null;
- try { data = JSON.parse(res.body); } catch (error) { return null; }
- return findUser(data, 0);
- }
- /** ③ 主页整页 SSR(实测唯一可用的一条) */
- function viaProfileHtml(handle) {
- var res = get('https://www.tiktok.com/@' + encodeURIComponent(handle),
- { 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' },
- PAGE_TIMEOUT_MS);
- if (!res.ok || res.status !== 200 || res.body === '') {
- return null;
- }
- var matched = res.body.match(
- /<script id="__UNIVERSAL_DATA_FOR_REHYDRATION__" type="application\/json">([\s\S]*?)<\/script>/
- );
- if (matched) {
- var data = null;
- try { data = JSON.parse(matched[1]); } catch (error) { data = null; }
- if (data) {
- var user = findUser(data, 0);
- if (user) {
- return user;
- }
- }
- }
- /* 结构改版 / 解析失败时的兜底 */
- return findUserByRegex(res.body, handle);
- }
- /**
- * 一个 username → uid。
- *
- * @returns { ok, uid, nickname, secUid, via, error }
- * `via` 记录这次用的是哪条路径(api / share / html),看日志就知道接口有没有恢复。
- */
- function fetchUserId(username) {
- var name = TikTokAuto.parse.username(username);
- if (name === '') {
- return { ok: false, error: '空用户名' };
- }
- var paths = [
- { key: 'api', dead: deadPaths.api, run: viaApi,
- label: '官方 user/detail' },
- { key: 'share', dead: deadPaths.share, run: viaShare,
- label: 'node/share' },
- { key: 'html', dead: false, run: viaProfileHtml,
- label: '主页 SSR' }
- ];
- for (var i = 0; i < paths.length; i += 1) {
- var path = paths[i];
- if (path.dead) {
- continue;
- }
- var user = path.run(name);
- if (user) {
- var accepted = accept(user, name);
- if (accepted) {
- accepted.via = path.key;
- return accepted;
- }
- }
- /* 前两条失败就标记"本次运行不再试";主页是最后一条,没有可退的地方 */
- if (path.key !== 'html') {
- deadPaths[path.key] = true;
- logger().info('resolve', path.label + ' 不可用,本次运行后续直接走主页 SSR');
- }
- }
- return { ok: false, error: '三条路径都拿不到(账号可能不存在或已注销)' };
- }
- /** 随机间隔,让请求节奏不像机器 */
- function pause() {
- var span = GAP_MAX_MS - GAP_MIN_MS;
- TikTokAuto.wait.tick(GAP_MIN_MS + Math.floor(Math.random() * (span + 1)));
- }
- /**
- * 批量把 username 换成 uid。
- *
- * 三条原则:
- * ① **一个失败不影响其余**(注销的号、网络抖动都只记一行);
- * ② 每处理一个就回报进度(这个阶段比抓取慢,没有进度会让人以为卡死);
- * ③ 可以被停止打断(`wait.tick` 里会检查停止标志);
- * ④ 换不到的**不写进映射文件** —— 文件里全是可用的对应关系,比写一堆空值有用。
- *
- * @param usernames 用户名数组
- * @param options { onProgress(done, total, name), shouldStop() }
- * @returns { ok, map: [{username, uid, nickname}], failed: [{username, error}], stopped }
- */
- function resolveAll(usernames, options) {
- var settings = options || {};
- var log = logger();
- var map = [];
- var failed = [];
- var total = usernames.length;
- for (var i = 0; i < total; i += 1) {
- if (settings.shouldStop && settings.shouldStop()) {
- /* 注意措辞:这里的 shouldStop 只反映**时长护栏**,
- * 不含"提取上限已到"(那个属于抓取阶段,不该阻止收尾)。
- * 早先写成"收到停止",把"跑太久"和"用户点了停止"混为一谈,排查时很误导。 */
- log.warn('resolve', '到达时长上限,已换 ' + map.length + '/' + total + ' 个');
- return { ok: true, map: map, failed: failed, stopped: true };
- }
- var name = usernames[i];
- if (settings.onProgress) {
- settings.onProgress(i + 1, total, name);
- }
- var result = fetchUserId(name);
- if (result.ok) {
- map.push({ username: name, uid: result.uid, nickname: result.nickname });
- /* 带上 via:这样一看日志就知道现在走的是哪条路径,
- * 接口哪天恢复了也能立刻发现(不用等别人报告)。 */
- log.info('resolve', name + ' → ' + result.uid + '(via ' + result.via + ')');
- } else {
- failed.push({ username: name, error: result.error });
- log.warn('resolve', '换不到 ' + name + ':' + result.error);
- }
- if (i < total - 1) {
- pause();
- }
- }
- return { ok: true, map: map, failed: failed, stopped: false };
- }
- /** 把映射写成两列文本(TAB 分隔)。写入失败返回 false,不抛。 */
- function writeMap(path, map) {
- var lines = [];
- for (var i = 0; i < map.length; i += 1) {
- lines.push(map[i].username + '\t' + map[i].uid);
- }
- try {
- files.write(path, lines.join('\n') + (lines.length > 0 ? '\n' : ''));
- return true;
- } catch (error) {
- logger().error('resolve', '写映射文件失败 ' + path + ':' + error);
- return false;
- }
- }
- return {
- fetchUserId: fetchUserId,
- viaApi: viaApi,
- viaShare: viaShare,
- viaProfileHtml: viaProfileHtml,
- resolveAll: resolveAll,
- writeMap: writeMap,
- GAP_MIN_MS: GAP_MIN_MS,
- GAP_MAX_MS: GAP_MAX_MS
- };
- }());
- /* ============================== src/idflow.js ============================== */
- /**
- * idflow.js — ID 提取的编排
- *
- * 与 `flow.js`(视频链路)并列,共用同一套基础设施:应用启动、弹窗守护、
- * 坐标点击、`wait.tick()` 的停止、`bridge.report()` 的状态上报。
- *
- * ## 一轮跑完是什么样
- *
- * 启动应用 → 进信息流 → 对每条取材:
- * 取材(列表型 / 文本型 / 逐个点开型)
- * → 每个候选:去重判定 → 需要时打开用户页 → 过滤判定 → 写盘
- * → 深度展开(第二层,最多一层)
- * → 收尾:关文件、把 summary 上报给界面
- *
- * ## 三个必须处理好的"与视频链路不同"
- *
- * ① **总量未知**。粉丝列表可能几十万人,没有 `total` 可报。
- * 所以状态条上的进度是「已收 N 个」而不是"N/M" —— 界面只读 `phase`,零改动。
- *
- * ② **"不限"是危险的**。界面里 `extractLimit = 0` 的语义是"不限制"。
- * 但真跑起来没有上限就等于跑到没电。所以引擎**另加三道物理护栏**:
- * 最大时长、单列表最大翻屏数(在 idsource 里)、连续无新内容即止。
- * 到护栏时**如实报告**"因达到护栏而结束",绝不装作抓完了。
- *
- * ③ **停止时必须写文件**。用户在任意时刻点停,已经抓到的部分不能丢 ——
- * 所以 `writer.close()` 放在 finally 里,无论怎么结束都会刷盘。
- *
- * 依赖:全部 ID 相关模块 + bridge / wait / ui / popups
- */
- TikTokAuto.idflow = (function () {
- 'use strict';
- /**
- * 最近一次运行的 summary。
- *
- * 为什么挂模块级而不是只靠返回值:用户点停止时 StopSignal 会从
- * `wait.tick()` 里抛出来,穿过整个流程到 main.js 的 catch ——
- * 那时 `runSession` 的返回值根本用不上。main 从这里取已经统计好的数字,
- * 否则界面会显示"提取 0 个"(实际抓到的都在文件里,只是没报上去)。
- */
- var lastSummary = null;
- function logger() { return TikTokAuto.logger; }
- /** 上报进度。只动 phase(界面状态条只读它),total 恒为 0 —— 这里没有总量。 */
- function progress(phase, done) {
- TikTokAuto.bridge.report({
- status: 'running',
- phase: phase + '(已收 ' + done + ' 个)',
- done: done,
- total: 0
- });
- }
- /**
- * 估算"够用的抓取预算"。
- *
- * ## 为什么要自动算
- * 预算是**按时间**给的,而用户的目标是**按个数**("提取满 100 个")。
- * 两者之间要经过"每个的平均耗时",而这个耗时取决于过滤设置(差 6 倍):
- * · 不过滤 → 直接从列表抄 uid,约 1.5 秒/个;
- * · 开了私密/无头像/数量门槛 → 每个都要打开用户页读资料,约 8 秒/个;
- * · 再加上被过滤跳过的(实测收录率约 37%)→ 折算到"每个**收录**"约 20 秒。
- *
- * 所以固定给 30 分钟是错的:设 100 个要 38 分钟、设 1000 个要 5 个多小时。
- * 用户设的数只当**下限**("至少跑这么久"),实际按提取上限放宽。
- *
- * ## 为什么还要封顶
- * 4 小时是设备安全的边界(长时间亮屏运行会发热)。真要更多,分几轮跑更好 ——
- * 每轮都刷盘,中断不丢,还能跨轮去重。
- */
- var PER_RESULT_MS = 20000; /* 折算到每个"收录"的耗时 */
- var FIXED_OVERHEAD_MS = 5 * 60 * 1000; /* 启动 app / 搜索 / 开列表的固定开销 */
- var HARD_CAP_MS = 4 * 60 * 60 * 1000; /* 4 小时硬顶 */
- /** 解析 uid 的预算:按实际待解析个数算(每个约 3 秒 + 1 分钟缓冲),1~60 分钟 */
- var PER_RESOLVE_MS = 3000;
- var RESOLVE_CAP_MS = 60 * 60 * 1000;
- function budgetFor(limit, userMinMs) {
- var userMs = Math.max(1, userMinMs);
- if (limit <= 0) {
- /* 不限个数 → 没有依据可算,按用户设的走 */
- return Math.min(userMs, HARD_CAP_MS);
- }
- var autoMs = limit * PER_RESULT_MS + FIXED_OVERHEAD_MS;
- return Math.min(Math.max(userMs, autoMs), HARD_CAP_MS);
- }
- function resolveBudgetFor(count) {
- var need = count * PER_RESOLVE_MS + 60000;
- return Math.min(Math.max(10 * 60 * 1000, need), RESOLVE_CAP_MS);
- }
- /**
- * 把界面配置里的 `id` 段收拢成引擎侧要用的形状。
- *
- * 界面已经做过一遍归一化(Sources.normalize),但**引擎不能假设界面一定对**:
- * 手写的 engine-config.json、旧版本残留、被改坏的文件都会进来。
- * 这里只做"缺了就补默认",不做语义校验 —— 语义校验在 filters 与 idsource 里。
- */
- function readSettings(config) {
- var id = config.id || {};
- var filter = id.filter || {};
- var output = id.output || {};
- var guard = id.guard || {};
- var limit = Number(output.limit) || 0;
- var userMaxMs = Number(guard.maxRuntimeMs) || 30 * 60 * 1000;
- return {
- sources: (id.sources instanceof Array ? id.sources : []),
- filter: {
- private: filter.private === true,
- noAvatar: filter.noAvatar === true,
- langRegion: filter.langRegion || 'any',
- nicknameCheck: filter.nicknameCheck || 'any',
- follow: filter.follow || {},
- fans: filter.fans || {},
- likes: filter.likes || {},
- videos: filter.videos || {}
- },
- output: {
- dir: output.dir || '/sdcard/Download/tiktok_app',
- historyPath: output.historyPath || (output.dir || '/sdcard/Download/tiktok_app') + '/uid_extracted.txt',
- dedupeHistory: output.dedupeHistory !== false,
- dryRun: output.dryRun === true,
- /* 缺省开:数字 uid 在界面上读不到,不在这里换就得事后另跑一次 */
- resolveUid: output.resolveUid !== false
- },
- limit: limit,
- /** 用户设的(下限,界面「单次最长运行」) */
- userMaxRuntimeMs: userMaxMs,
- /** 实际生效的抓取预算(按 limit 自动放宽) */
- maxRuntimeMs: budgetFor(limit, userMaxMs),
- resolveMaxMs: Number(guard.resolveMaxMs) || 10 * 60 * 1000,
- /**
- * 断点续跑开关。默认关:只有界面点「继续」时才由 `--resume` 打开。
- * path 留空时由 output.dir 派生 —— 用户改产物目录,断点跟着走。
- */
- resume: {
- enabled: (id.resume && id.resume.enabled === true),
- path: (id.resume && id.resume.path) ||
- ((output.dir || '/sdcard/Download/tiktok_app') + '/' +
- TikTokAuto.checkpoint.DEFAULT_NAME)
- }
- };
- }
- /**
- * 跑一轮 ID 提取。
- *
- * @returns 结构化 summary(与视频链路同族,供 bridge/main 复用)
- */
- function runSession(config) {
- var log = logger();
- var profileApi = TikTokAuto.profile;
- var settings = readSettings(config);
- var began = Date.now();
- var summary = {
- ok: false,
- task: 'id',
- extracted: 0, /* **本轮**写进产物文件的个数 */
- skipped: 0, /* 因过滤被排除 */
- duplicates: 0, /* 因去重被排除 */
- openedProfiles: 0, /* 打开过多少次用户页(成本指标) */
- sources: [],
- warnings: [],
- resultFile: '',
- /* —— 断点续跑相关 ——
- * resumed:本次是不是接着上次跑
- * extractedTotal:累计口径(上次已落盘 + 本轮新增),上限与文案都按它算
- * resumeRejected:要求续跑但被拒时,把"为什么"结构化成一份给界面看
- */
- resumed: false,
- extractedTotal: 0,
- resumeRejected: null,
- errors: []
- };
- lastSummary = summary;
- var seen = TikTokAuto.uidset.create();
- var expandQueue = [];
- var softReported = {};
- var writer = null;
- var runFile = '';
- /* 断点(cp)在"决定续跑"之后才会有值;base 是"上次已落盘的条数",
- * 上限、文案、ok 判定都按 base + 本轮 来算。 */
- var cp = null;
- var cpPath = settings.resume.path;
- var resumed = false;
- var base = 0;
- var sig = TikTokAuto.fingerprint.ofIdSection(config.id);
- /* —— 停止条件:上限 / 时长 / 用户叫停,三处统一问它 —— */
- function limitReached() {
- /* ★ 上限必须按**累计**算:不然设 100 个、跑了 60 个被停,
- * 续跑后又会再收 100 个 = 一共 160 个。 */
- return TikTokAuto.checkpoint.limitReached(cp, settings.limit, summary.extracted);
- }
- function timeUp() {
- return Date.now() - began >= settings.maxRuntimeMs;
- }
- function shouldStop() {
- if (limitReached()) {
- return true;
- }
- if (timeUp()) {
- if (!summary.warnings.length || summary.warnings[summary.warnings.length - 1] !== 'TIME_LIMIT') {
- log.warn('idflow', '已达单次运行时长上限(' +
- Math.round(settings.maxRuntimeMs / 60000) + ' 分钟),准备收尾');
- summary.warnings.push('TIME_LIMIT');
- }
- return true;
- }
- return false;
- }
- /**
- * 给一个候选拿到资料。三条路径按代价从低到高:
- * ① 调用方已经读过(评论者那只,`_profile`);
- * ② 列表行自带计数(搜索用户列表有"粉丝 · 获赞");
- * ③ 打开用户页(最贵,约 3~5 秒)。
- */
- function obtainProfile(row) {
- if (row._profile) {
- return row._profile;
- }
- if (!TikTokAuto.filters.needsProfile(settings.filter)) {
- /* 不需要任何用户页才能判的条件 → 绝不为此付一次开页代价 */
- if (row.fans !== undefined || row.likes !== undefined) {
- return {
- uid: row.uid,
- nickname: row.nickname,
- signature: '',
- follow: null,
- fans: row.fans === undefined ? null : row.fans,
- likes: row.likes === undefined ? null : row.likes,
- isPrivate: false,
- hasAvatar: null
- };
- }
- return null;
- }
- /* 从列表行点进去最快(且实测返回后列表位置保持)。
- * ★ 判据是"当前在**任一**种列表页上",不是只有粉丝列表:
- * 搜索结果的用户列表也一样能直接点行进去。
- * 早先写死成 `where() === 'user-list'`,于是搜索取材时
- * 每个候选都要**重新搜一次**(约 15 秒/个)—— 慢十倍且没有必要。
- * ★ 点开失败时不再退回搜索:在列表遍历中途跳去搜索,
- * 会把"当前在哪个列表的第几行"整个丢掉,之后再也接不回原来的列表。 */
- if (row.node && row.x !== undefined && profileApi.isListPage()) {
- if (profileApi.openRow(row)) {
- summary.openedProfiles += 1;
- var data = profileApi.readProfile();
- profileApi.backToList();
- return data;
- }
- log.warn('idflow', '点不开 @' + row.uid + ' 的主页,跳过(不回搜索,避免丢失列表位置)');
- return null;
- }
- var profile = profileApi.openBySearch(row.uid, config);
- if (profile) {
- summary.openedProfiles += 1;
- }
- return profile;
- }
- /**
- * 收尾阶段:把本轮抓到的 username 换成数字 uid,另存一份映射文件。
- *
- * 产物分工:
- * `uid_*.txt` 一行一个 username —— 给"搜人/私信"这类下游用
- * `uid_map_*.txt` `username<TAB>uid` 两列 —— 给"要平台主键"的下游用
- * 两份都留着,因为它们的用途真的不同,而且映射只有这一轮的数据。
- */
- function resolvePhase(target, settings) {
- /* ★ 续跑时解析**整份结果文件**,不是只解析本轮新增。
- * 映射文件是"结果文件的对照表",只含本轮新增就成了残表 ——
- * 上次已收的 37 个永远拿不到数字 uid。
- * 代价是这些名字要重新请求一遍(每个约 2.5 秒),先要正确性。 */
- var names = resumed ? TikTokAuto.uidset.readUidFile(runFile) : writer.list();
- if (names.length === 0) {
- return;
- }
- /* 续跑复用上次的映射文件路径(覆盖写整份):
- * 否则一次任务会产出两个 uid_map,下游还得自己合并。 */
- var mapFile = (resumed && cp && cp.mapFile)
- ? cp.mapFile
- : settings.output.dir + '/' + TikTokAuto.parse.timestampName('uid_map', new Date());
- log.step('idflow', '开始解析数字 uid:' + names.length + ' 个(每个约 2~3 秒)');
- var resolveStarted = Date.now();
- /* 解析预算也按实际个数算:10 分钟只够 240 个,抓了 1000 个就不够用了 */
- var resolveBudgetMs = resolveBudgetFor(names.length);
- log.info('idflow', '解析预算 ' + Math.round(resolveBudgetMs / 60000) + ' 分钟');
- var result = TikTokAuto.resolve.resolveAll(names, {
- /**
- * ★ 这里**只用自己的预算**,不继承抓取阶段的任何停止条件。
- *
- * 两次踩同一个坑,两次都是"收尾继承了抓取的条件":
- * ① 先是 `shouldStop()` 里的 `limitReached()` —— 抓满 N 个后解析 0 个;
- * ② 再是 `shouldStop()` 里的 `timeUp()` —— 抓取把 30 分钟用满后解析 0 个
- * (实测:83 个候选 × 每人约 8 秒 = 30 分钟,收尾一点时间都不剩)。
- *
- * 抓取阶段的"够了 / 到点了"和收尾阶段的"必须做完"是相反的要求。
- * 所以这里从**进入解析的那一刻**重新计时,用一个独立预算(默认 10 分钟)。
- * 用户主动停止仍然有效 —— 那是 StopSignal 从 `wait.tick` 抛出来的。
- */
- shouldStop: function () {
- return Date.now() - resolveStarted >= resolveBudgetMs;
- },
- onProgress: function (done, total) {
- progress('解析 uid ' + done + '/' + total, target.extracted);
- }
- });
- if (result.stopped) {
- target.warnings.push('解析 uid 到达自身时长上限(' +
- Math.round(resolveBudgetMs / 60000) + ' 分钟),只换到 ' +
- result.map.length + '/' + names.length + ' 个');
- }
- target.resolved = result.map.length;
- target.unresolved = result.failed.length;
- if (result.map.length > 0 && TikTokAuto.resolve.writeMap(mapFile, result.map)) {
- target.mapFile = mapFile;
- if (cp) {
- cp.mapFile = mapFile;
- TikTokAuto.checkpoint.save(cpPath, cp);
- }
- }
- if (result.failed.length > 0) {
- target.warnings.push(result.failed.length +
- ' 个账号换不到数字 uid(账号可能已注销,详见日志)');
- }
- log.step('idflow', 'uid 解析完成:成功 ' + result.map.length +
- ' / 失败 ' + result.failed.length);
- }
- /** 处理一个候选:去重 → 过滤 → 写盘 → 记下要不要展开 */
- function handleRow(row) {
- var parse = TikTokAuto.parse;
- var uid = parse.username(row.uid);
- if (uid === '' || !parse.looksLikeUsername(uid)) {
- return;
- }
- if (seen.has(uid)) {
- summary.duplicates += 1;
- return;
- }
- if (shouldStop()) {
- return;
- }
- progress('正在读取 @' + uid, summary.extracted);
- var profile = obtainProfile(row);
- var verdict = TikTokAuto.filters.judge(profile, settings.filter);
- if (!verdict.ok) {
- summary.skipped += 1;
- log.info('idflow', '跳过 @' + uid + ':' + verdict.reason);
- seen.add(uid);
- return;
- }
- if (verdict.soft && !softReported[verdict.soft]) {
- softReported[verdict.soft] = true;
- summary.warnings.push(verdict.soft);
- log.warn('idflow', verdict.soft);
- }
- seen.add(uid);
- writer.write(uid);
- summary.extracted += 1;
- /* 收录时记下依据。这不只是"好看":用户事后想核对"这个人为啥被收进来"时,
- * 日志是唯一的答案;没有它就只能重新跑一遍。 */
- log.info('idflow', '收录 @' + uid + (profile
- ? '(粉丝 ' + profile.fans + ' / 关注 ' + profile.follow +
- ' / 获赞 ' + profile.likes + ')'
- : '(未打开用户页,按列表现有资料通过)'));
- if (row.expandTo) {
- expandQueue.push({ uid: uid, which: row.expandTo });
- /* 同步记进断点:不然续跑时"这个人还要展开"这条信息就丢了 */
- TikTokAuto.checkpoint.enqueueExpand(cp, uid, row.expandTo);
- }
- /* 节流落盘,与产物刷盘同拍(每 20 个)。
- * 不追每一个:粒度是取材条目级,写那么频只换来"少重走 19 个人"。 */
- if (cp && summary.extracted % TikTokAuto.uidset.FLUSH_EVERY === 0) {
- saveCheckpoint();
- }
- }
- /** 把当前进度写进断点文件(失败只记 warn,不影响主流程) */
- function saveCheckpoint() {
- if (!cp) {
- return;
- }
- cp.counts = {
- extracted: base + summary.extracted,
- skipped: summary.skipped,
- duplicates: summary.duplicates,
- openedProfiles: summary.openedProfiles
- };
- cp.limitBase = base;
- TikTokAuto.checkpoint.save(cpPath, cp);
- }
- try {
- log.step('idflow', '=== TikTok ID 提取开始 ===');
- log.info('idflow', '取材 ' + settings.sources.length + ' 条,过滤:' +
- TikTokAuto.filters.describe(settings.filter));
- /* 把预算说清楚:用户设的是下限,实际按提取上限放宽过。
- * 不说的话,"怎么跑了 38 分钟"会变成第二个疑问。 */
- log.info('idflow', '抓取预算 ' + Math.round(settings.maxRuntimeMs / 60000) + ' 分钟' +
- (settings.limit > 0
- ? '(按提取上限 ' + settings.limit + ' 个估算;你设的下限是 ' +
- Math.round(settings.userMaxRuntimeMs / 60000) + ' 分钟)'
- : '(未设提取上限,按你设的时长)'));
- /* —— 续跑决策(★ 全部在启动 App 之前做完)——
- * 拒绝续跑时零成本返回:不启动 TikTok、不动用户手机上的任何一个页面。
- * 判定顺序见 checkpoint.planResume(文件不在 → 已跑完 → 演练 → 配置变 → 结果文件没了)。 */
- if (settings.resume.enabled) {
- var prev = TikTokAuto.checkpoint.read(cpPath);
- var decision = TikTokAuto.checkpoint.planResume(
- prev, sig,
- !!(prev && prev.runFile && files.exists(prev.runFile)),
- { dryRun: settings.output.dryRun });
- if (decision.mode === 'reject') {
- summary.errors.push(decision.message);
- summary.resumeRejected = {
- reason: decision.reason,
- changed: decision.changed || [],
- message: decision.message,
- runFile: (prev && prev.runFile) || ''
- };
- log.warn('idflow', '续跑被拒绝:' + decision.message);
- return summary;
- }
- if (decision.mode === 'resume') {
- cp = prev;
- resumed = true;
- summary.resumed = true;
- log.info('idflow', '续跑:上次停在「' +
- (TikTokAuto.checkpoint.stoppedAt(cp) || '未知') + '」,已收 ' +
- (cp.counts && cp.counts.extracted ? cp.counts.extracted : 0) + ' 个');
- /* 「当前屏幕」类取材的前提是"用户此刻停在那个页面"。
- * 续跑时那一屏早就没了,引擎也不会替他跳回去
- * (`flow.launchApp` 刻意不动页面,见它的注释)。
- * 这种情况必须说清楚 —— 否则用户只会看到"这条取材又是 0 个"。 */
- var screenSources = 0;
- for (var ssi = 0; ssi < settings.sources.length; ssi += 1) {
- var src = settings.sources[ssi];
- if (src.kind === 'screen' ||
- (src.kind === 'video' && src.videoSource === 'screen')) {
- screenSources += 1;
- }
- }
- if (screenSources > 0) {
- summary.warnings.push('续跑提示:有 ' + screenSources +
- ' 条取材依赖「你当前停留的页面」,而上次那一屏已经不在了 —— ' +
- '这几条可能取不到人(失败会保留断点,下次还能再试)');
- log.warn('idflow', '续跑:' + screenSources +
- ' 条取材依赖当前屏幕,可能需要你手动打开对应页面');
- }
- } else {
- log.info('idflow', '没有可用的断点(' + decision.reason + '),从零开始');
- }
- }
- TikTokAuto.bridge.report({ status: 'running', phase: '准备', done: 0, total: 0 });
- if (!TikTokAuto.uidset.ensureDir(settings.output.dir)) {
- summary.errors.push('无法创建结果目录:' + settings.output.dir);
- return summary;
- }
- /* 文件名只生成一次:分两次调 timestampName 有可能跨过整分钟,
- * 于是"写进去的文件"和"报给界面的文件名"不是同一个 —— 这种错几乎无法复现。
- *
- * ★ 续跑时**复用上次的文件**(不新开):一次任务只该有一个 uid_*.txt,
- * 否则用户拿到两个文件还得自己合并。 */
- if (resumed && cp && cp.runFile) {
- runFile = cp.runFile;
- } else {
- runFile = settings.output.dir + '/' +
- TikTokAuto.parse.timestampName('uid', new Date());
- }
- writer = TikTokAuto.uidset.createWriter({
- runPath: runFile,
- historyPath: settings.output.historyPath,
- dryRun: settings.output.dryRun,
- historyEnabled: settings.output.dedupeHistory
- });
- summary.resultFile = settings.output.dryRun ? '' : runFile;
- if (settings.output.dedupeHistory) {
- TikTokAuto.uidset.loadHistory(seen, settings.output.historyPath);
- }
- /* ★ 续跑时**无条件**把上次的产物载入去重集合(不依赖 dedupeHistory):
- * 否则会把已经收过的人再写一遍。base 以**文件行数**为准 —— 文件是真相,
- * 不信断点里那个计数(它可能是被杀之前写的旧值)。 */
- if (resumed) {
- base = TikTokAuto.uidset.readUidFile(runFile).length;
- TikTokAuto.uidset.loadHistory(seen, runFile);
- summary.warnings.push('本次为续跑:结果继续追加到 ' + runFile);
- log.info('idflow', '续跑:跳过已收 ' + base + ' 个');
- }
- /* 建/续断点,并**立刻写一次** —— 刚起步就被杀也能接着跑 */
- if (!resumed) {
- var entries = [];
- for (var si = 0; si < settings.sources.length; si += 1) {
- entries.push({
- key: settings.sources[si].key ||
- ('i' + si + ':' + (settings.sources[si].kind || '')),
- kind: settings.sources[si].kind || '',
- label: TikTokAuto.idsource.describe(settings.sources[si])
- });
- }
- cp = TikTokAuto.checkpoint.empty(
- TikTokAuto.bridge.currentRunId ? TikTokAuto.bridge.currentRunId() : '',
- sig, runFile, entries);
- }
- saveCheckpoint();
- /* —— 启动与就位(复用视频链路的成熟实现) —— */
- progress('启动应用', 0);
- if (!TikTokAuto.flow.launchApp(config)) {
- summary.errors.push('启动应用失败');
- return summary;
- }
- /* ★ 这里**刻意不做 ensureFeed**。
- *
- * 早先这里无条件把页面退回信息流首页,于是「当前屏幕」类的取材必然失败:
- * 用户手动把 TikTok 停在视频评论区(那正是这个取材源的全部前提),
- * 引擎一上来就连按几次返回退到首页,然后再报"取材设置为当前屏幕,
- * 但当前不在播放页(feed)"。用户看到的是"我明明停在评论区,它说我不在"。
- *
- * 各个取材源自己知道自己需要什么前置状态,让它们自己保证:
- * · 指定账号 / 搜索 → 走 prepareSearch,内部会 ensureFeed;
- * · 视频/当前屏幕 → **不动页面**,就用用户准备好的那一屏;
- * · 文本文件 → 根本不需要 UI。
- */
- progress('准备取材', 0);
- if (settings.sources.length === 0) {
- summary.errors.push('没有配置任何取材(界面里至少需要一条)');
- return summary;
- }
- /* —— 第一层:逐条取材 —— */
- for (var i = 0; i < settings.sources.length; i += 1) {
- if (shouldStop()) {
- break;
- }
- var source = settings.sources[i];
- var sourceKey = source.key || ('i' + i + ':' + (source.kind || ''));
- var before = summary.extracted;
- /* 上次已走完的取材直接跳过 —— 这就是"断点续跑"的全部含义:
- * 不是从第几屏接着滚,而是**不再重走已经走完的那几条**。 */
- if (resumed && TikTokAuto.checkpoint.sourceDone(cp, sourceKey)) {
- summary.sources.push({
- label: TikTokAuto.idsource.describe(source),
- handled: 0,
- added: 0,
- reused: true
- });
- log.info('idflow', '续跑跳过(上次已完成):' + TikTokAuto.idsource.describe(source));
- continue;
- }
- TikTokAuto.checkpoint.markSource(cp, sourceKey, 'running');
- saveCheckpoint();
- progress('取材 ' + (i + 1) + '/' + settings.sources.length, summary.extracted);
- /* 续跑时这条取材上次翻了多少屏 → 这次把预算放大相应倍数。
- * 从头重放旧人也要翻屏,不放大就会"还没走到新人就撞上 60 屏硬顶"。 */
- var prevScreens = resumed ? TikTokAuto.checkpoint.screensOf(cp, sourceKey) : 0;
- var maxScreens = 0;
- if (prevScreens > 0) {
- maxScreens = Math.min(TikTokAuto.idsource.MAX_SCREENS * 2,
- TikTokAuto.idsource.MAX_SCREENS +
- Math.min(prevScreens, TikTokAuto.idsource.MAX_SCREENS));
- }
- var stat = {};
- var ctx = {
- config: config,
- seen: seen,
- shouldStop: shouldStop,
- maxScreens: maxScreens,
- stat: stat,
- onSeenProfile: function (prof) {
- log.info('idflow', '取材账号 @' + prof.uid + ':粉丝 ' + prof.fans +
- ' / 关注 ' + prof.follow + ' / 获赞 ' + prof.likes);
- },
- onRow: function (row) {
- /* 深度只在第一层记:第二层不再展开(上限两层) */
- if (source.expand && !row.expandTo) {
- row.expandTo = source.expand;
- }
- handleRow(row);
- }
- };
- var handled = 0;
- var errorsBefore = summary.errors.length;
- try {
- handled = TikTokAuto.idsource.run(source, ctx);
- } catch (error) {
- if (TikTokAuto.bridge.isStopSignal(error)) {
- throw error;
- }
- log.error('idflow', '取材失败(' + TikTokAuto.idsource.describe(source) + '):' + error);
- summary.errors.push('取材失败:' + TikTokAuto.idsource.describe(source));
- }
- summary.sources.push({
- label: TikTokAuto.idsource.describe(source),
- handled: handled,
- added: summary.extracted - before
- });
- log.info('idflow', '取材完成:' + TikTokAuto.idsource.describe(source) +
- ',处理 ' + handled + ' 行,新增 ' + (summary.extracted - before) + ' 个');
- /* ★ 走完一条就记一条 —— 下次续跑正是靠它跳过这一条。
- *
- * 但**失败的那条不能标 done**:`idsource.run` 打不开列表时会记 errors
- * 然后返回 0(不抛异常),如果一律标 done,续跑就再也不会重试它,
- * 用户会以为"这条取材就是这样、没数据"。所以:
- * · 本轮新增了错误 → 保持 running,下次接着试
- * · 否则(正常走完,哪怕列表本来就是空的)→ done
- *
- * 中途被停止时这里不会执行,那条取材保持 running,
- * 续跑就从它开头重走 —— 这就是"最多回退一条取材"。 */
- var sourceFailed = summary.errors.length > errorsBefore;
- /* 没抛错但一个人都没取到:列表是空的、或者「当前屏幕」那一刻页面不对
- * (这种情况 idsource 只记 warn 不记 error)。必须说出来 ——
- * 否则用户只会看到"总数不对",却不知道是哪条取材白跑了。 */
- if (!sourceFailed && handled === 0 && (summary.extracted - before) === 0) {
- summary.warnings.push('取材「' + TikTokAuto.idsource.describe(source) +
- '」这次没有取到任何人(列表为空或页面不合适)');
- }
- TikTokAuto.checkpoint.markSource(cp, sourceKey,
- sourceFailed ? 'running' : 'done', {
- screens: stat.screens || 0,
- handled: handled,
- added: summary.extracted - before
- });
- saveCheckpoint();
- }
- /* —— 第二层:深度展开 ——
- * 与第一层分开跑,是为了避免嵌套列表上下文(见 idsource.js 文件头)。
- * 展开进来的人**同样要过一遍过滤** —— 否则"粉丝的粉丝"会绕过所有条件。
- *
- * 续跑时先用断点过滤掉"上次已经展开过"的人:展开一个人要 5~10 秒,
- * 重做一遍纯属浪费。 */
- var todoExpand = TikTokAuto.checkpoint.pendingExpand(cp, expandQueue);
- if (expandQueue.length > 0 && todoExpand.length === 0) {
- log.info('idflow', '展开项上次已全部完成(' + expandQueue.length + ' 个),跳过');
- }
- if (todoExpand.length > 0 && shouldStop() && limitReached()) {
- /* 上限在主层就满了 → 展开根本没跑。这必须说出来:
- * 用户明明勾了「再取粉丝列表」,看到"提取满 3 个"很容易以为展开做了。 */
- summary.warnings.push('提取上限已到,深度展开(再取粉丝列表)本次没有执行');
- log.warn('idflow', '提取上限已到,' + todoExpand.length + ' 个待展开的账号本次没有展开');
- }
- if (todoExpand.length > 0 && !shouldStop()) {
- log.step('idflow', '开始深度展开:' + todoExpand.length + ' 个账号' +
- (resumed ? '(已跳过上次做完的)' : ''));
- for (var e = 0; e < todoExpand.length; e += 1) {
- if (shouldStop()) {
- break;
- }
- var target = todoExpand[e];
- progress('展开 @' + target.uid + ' 的' +
- (target.which === 'following' ? '关注' : '粉丝'), summary.extracted);
- var opened = profileApi.openBySearch(target.uid, config);
- if (!opened) {
- log.warn('idflow', '展开时打不开 @' + target.uid);
- continue;
- }
- summary.openedProfiles += 1;
- if (!profileApi.openList(target.which, config)) {
- continue;
- }
- profileApi.switchListTab(target.which);
- var subCtx = {
- config: config,
- seen: seen,
- shouldStop: shouldStop,
- onSeenProfile: function () { },
- onRow: handleRow /* 注意:这里**不再**设置 expandTo,所以不会递归 */
- };
- try {
- TikTokAuto.idsource.walkList(subCtx);
- } catch (error2) {
- if (TikTokAuto.bridge.isStopSignal(error2)) {
- throw error2;
- }
- log.warn('idflow', '展开 @' + target.uid + ' 失败:' + error2);
- }
- /* 做完一个就记一个:展开成本高(5~10 秒/人),重做代价大 */
- TikTokAuto.checkpoint.markExpandDone(cp, target.uid, target.which);
- saveCheckpoint();
- }
- }
- /* 成功判定按**累计**:续跑本轮新增 0 个、但上次已有 37 个,这是成功不是失败 */
- var totalSoFar = base + summary.extracted;
- summary.ok = totalSoFar > 0;
- if (!summary.ok && summary.errors.length === 0) {
- summary.errors.push('没有提取到任何 ID(取材范围内都被过滤或去重掉了)');
- }
- /* —— 收尾:username → 数字 uid ——
- * 数字 uid 在 App 界面上任何地方都读不到,只能取网页版 SSR(见 resolve.js)。
- * 它比抓取慢(每个约 2.5 秒)且依赖网络,所以三条边界:
- * ① 只在**主产物已经落盘之后**做(先 close 一次,保证文件完整);
- * ② 失败**只记 warning**,绝不把已经抓到的 username 算成失败;
- * ③ 换不到的**不写进映射文件** —— 文件里全是可用的对应关系。
- */
- var needResolve = settings.output.resolveUid && !settings.output.dryRun && totalSoFar > 0;
- /* 续跑但本轮一个都没新增、上次也已经生成过映射 → 直接沿用,不重算。
- * 否则每次续跑都要把整份文件重新解析一遍(每个 2.5 秒)。 */
- if (needResolve && resumed && summary.extracted === 0 && cp && cp.mapFile) {
- log.info('idflow', '本轮没有新增,映射文件沿用上次的:' + cp.mapFile);
- summary.mapFile = cp.mapFile;
- needResolve = false;
- }
- if (needResolve) {
- try {
- writer.close();
- resolvePhase(summary, settings);
- } catch (error) {
- if (TikTokAuto.bridge.isStopSignal(error)) {
- summary.warnings.push('解析 uid 阶段被停止,映射文件只含已换到的部分');
- throw error;
- }
- log.warn('idflow', '解析 uid 失败(不影响已抓到的 username):' + error);
- summary.warnings.push('解析 uid 失败:' + error);
- }
- }
- } finally {
- /* 无论是正常结束、出错、还是用户叫停,都必须把已经抓到的刷进文件 ——
- * "点了停止但文件是空的"是最不能接受的一种失败。 */
- if (writer) {
- var written = writer.close();
- if (written !== summary.extracted) {
- log.warn('idflow', '写盘数量与统计不符:文件 ' + written + ' / 统计 ' + summary.extracted);
- }
- if (writer.hadFailure()) {
- summary.warnings.push('结果文件写入失败(部分结果可能没有落盘)');
- }
- }
- /* 累计口径给上层(bridge 的 done、界面状态条、记录文案都用它) */
- summary.extractedTotal = base + summary.extracted;
- /* 断点收尾:无论正常结束还是被停止/出错解栈,都要把"走到哪"落盘。
- * `allDone` 为真才算真跑完(界面上不再提供"继续");否则留成 stopped 等续跑。 */
- if (cp) {
- /* 三种情况都算"这一轮没有可续的了":
- * ① 取材与展开都走完了;
- * ② 达到了用户设的提取上限 —— 否则会留下一个"点了继续立刻又停"的断点,
- * 因为 limitReached 按累计算,续跑一进来就已经到顶了。
- */
- cp.state = (TikTokAuto.checkpoint.allDone(cp) || limitReached()) ? 'done' : 'stopped';
- saveCheckpoint();
- log.info('idflow', '断点已保存(' + cp.state + '):取材 ' +
- TikTokAuto.checkpoint.sourceProgress(cp).done + '/' +
- TikTokAuto.checkpoint.sourceProgress(cp).total + ' 条已完成');
- }
- /* 一个都没收时**不报文件名**:那个文件根本没被创建(writer 只在有内容时 append),
- * 报一个不存在的路径,用户去文件管理器找会以为"抓到了但丢了"。
- * 续跑时按累计判:上次已收 37 个,文件是存在的。 */
- if (summary.extractedTotal === 0) {
- summary.resultFile = '';
- }
- if (limitReached()) {
- summary.warnings.push('已提取满 ' + settings.limit + ' 个,按设置停止');
- } else if (settings.limit > 0 && timeUp()) {
- /* ★ 时间先到、上限没满 —— 这是最容易被误解的一种结束:
- * 用户设了"提取满 100 个",看到只收了 83 个,会以为是过滤太严或程序漏了,
- * 而真正的原因是**两个上限谁先到听谁**:30 分钟到了就停,不管够不够数。
- *
- * 这里直接给出换算结论,省得用户自己猜:
- * 按本轮速度,收满 limit 个大约需要多久。
- *
- * 估算方法(简单但够用):本轮 T 秒收到 E 个 → 平均每个 E/T 个,
- * 那么收满 L 个需要 L × T / E 秒。实测 1800 秒 / 83 个 → 100 个约 36 分钟。
- */
- var elapsedMs = Date.now() - began;
- /* 文案与换算都按**累计**:续跑时本轮可能只新增 5 个,
- * 但用户真正关心的是"总共 42/100",说"只收了 5/100"会吓人。 */
- if (summary.extracted > 0) {
- var needMin = Math.ceil(
- (settings.limit - summary.extractedTotal + summary.extracted) *
- elapsedMs / summary.extracted / 60000
- );
- summary.warnings.push(
- '时间到,共收了 ' + summary.extractedTotal + '/' + settings.limit +
- ' 个(本轮新增 ' + summary.extracted + ' 个)。' +
- '按本轮速度,收满 ' + settings.limit + ' 个还需约 ' + needMin +
- ' 分钟 —— 可以点「继续」接着跑'
- );
- log.warn('idflow', '时间到,累计 ' + summary.extractedTotal + ' 个;' +
- '按本轮速度收满 ' + settings.limit + ' 个还需约 ' + needMin + ' 分钟');
- } else {
- /* 一个都没收到就超时:多半不是时间不够,而是过滤太严或取材取不到人。
- * 这时给"调大时间"的建议是害人 —— 要说清该查哪里。 */
- summary.warnings.push(
- '时间到,一个都没收到 —— 不是时间太短,请检查取材是否取到人、' +
- '过滤条件是否过严(见日志里的跳过原因)'
- );
- }
- } else if (settings.limit > 0 && summary.extractedTotal < settings.limit) {
- /* 第三种"没到上限":取材本身跑完了(列表到底/文件读完),不是时间问题。
- * 这时调时间没用,得加取材量。 */
- summary.warnings.push(
- '取材范围已取尽,共收 ' + summary.extractedTotal + '/' + settings.limit +
- ' 个 —— 想收更多请增加取材(换关键词、加账号,或打开「再取粉丝列表」)'
- );
- }
- log.step('idflow', '=== 提取结束' + (resumed ? '(续跑)' : '') +
- ':新增 ' + summary.extracted + ' 个,累计 ' + summary.extractedTotal +
- ' 个,跳过 ' + summary.skipped + ' 个,重复 ' + summary.duplicates +
- ' 个,耗时 ' + Math.round((Date.now() - began) / 1000) + ' 秒 ===');
- }
- return summary;
- }
- return {
- runSession: runSession,
- readSettings: readSettings,
- lastSummary: function () {
- return lastSummary;
- }
- };
- }());
- /* ============================== src/dm.js ============================== */
- /**
- * dm.js — 发私信:打开面板 → 输入 → **点发送** → 验证真的发出去了
- *
- * ## 实测锚点(2026-10-29 / TikTok 47.1.4 / 1080×1920 / @nasa 主页)
- *
- * | 元素 | 类 | 识别方式 | 备注 |
- * |---|---|---|---|
- * | Message 入口 | TextView | text/desc === `Message` | 用户主页上,Follow 右边 |
- * | 面板输入框 | EditText | `top > 25%H` | 未聚焦时贴底(y=1816) |
- * | 表情按钮 | Button | desc 含 `stickers, gifs` | 会挡在发送键左边 |
- * | **发送键** | ImageView | 见 `sendButton()` | **无 id、无 desc,只能按位置** |
- *
- * ## 为什么发送键要按位置找
- *
- * 输入前它根本不存在;输入文字后输入框会**缩短**(960 → 888),
- * 空出来的位置才出现那个 72×72 的可点 ImageView(968~1040)。
- * 它没有 id、没有 desc、也没有任何文案 —— 除了几何关系没有别的抓手。
- *
- * ## 和评论的区别(这是私信能做、评论不能做的原因)
- *
- * 评论的发送键是**自绘的**,无障碍树里根本没有对应节点;
- * 私信这个是标准 ImageView、`clickable=true`,无障碍点得到。
- *
- * 依赖:TikTokAuto.logger / layout / ui / wait / tiktok
- */
- TikTokAuto.dm = (function () {
- 'use strict';
- /** 表情按钮的 desc 特征 —— 它紧贴在发送键左边,必须排除 */
- var STICKER_HINT = /stickers|gifs|emoji|表情|贴纸/i;
- function logger() { return TikTokAuto.logger; }
- function screenH() { return TikTokAuto.layout.screen().height; }
- /**
- * 找可见的、text 或 desc 完全匹配的节点。
- *
- * ★ 必须**分别**测 text 和 desc,不能拼起来再测:
- * 拼成 `txt + ' ' + desc` 之后,`desc="Message"` 会变成 " Message"(前导空格),
- * `/^Message$/` 就匹配不上了 —— 明明节点在树里却报"找不到"。
- */
- function findExact(re, classes) {
- var layout = TikTokAuto.layout;
- var height = screenH();
- for (var c = 0; c < classes.length; c += 1) {
- var found = className(classes[c]).find();
- for (var i = 0; i < found.size(); i += 1) {
- var n = found.get(i);
- var box = layout.visibleRect(n);
- if (!box || box.bottom <= 0 || box.top >= height) {
- continue;
- }
- if (re.test(layout.text(n)) || re.test(layout.desc(n))) {
- return n;
- }
- }
- }
- return null;
- }
- /** 用户主页上的 Message 入口 */
- function messageButton() {
- return findExact(/^(Message|Send message|消息|私信)$/i,
- ['android.widget.Button', 'android.widget.TextView']);
- }
- /**
- * 私信面板里的输入框。
- *
- * 位置判据用「top > 25% 屏高」而不是"底部固定 y":聚焦后键盘弹起,
- * 输入框会被顶到 0.65H(实测 1240),贴底判据就失效了。
- */
- function panelInput() {
- var layout = TikTokAuto.layout;
- var height = screenH();
- var found = className('android.widget.EditText').find();
- for (var i = 0; i < found.size(); i += 1) {
- var n = found.get(i);
- var box = layout.visibleRect(n);
- if (!box) { continue; }
- if (box.top > height * 0.25 && box.top < height * 0.95) {
- return n;
- }
- }
- return null;
- }
- /**
- * 发送键。
- *
- * 判据(都来自实测,缺一条就会点到表情按钮或点空):
- * ① 与输入框**垂直重叠**(同一行)
- * ② 左边界在输入框右边界**之外**(`left >= right + 10`)——
- * 输入框输入后会缩短,表情按钮紧贴着它,所以必须留出这 10px 的余量
- * ③ 尺寸在 40~120 之间(排除整行的容器)
- * ④ 可点
- * ⑤ desc 里不含 stickers/gifs/emoji(排除表情按钮)
- *
- * 取**最靠左**的那个:紧挨输入框的是发送键,更右边没有东西。
- */
- function sendButton() {
- var layout = TikTokAuto.layout;
- var input = panelInput();
- if (!input) {
- return null;
- }
- var ib = input.bounds();
- var best = null;
- var bestLeft = null;
- var classes = ['android.widget.ImageView', 'android.widget.Button'];
- for (var c = 0; c < classes.length; c += 1) {
- var found = className(classes[c]).find();
- for (var i = 0; i < found.size(); i += 1) {
- var n = found.get(i);
- var box = layout.visibleRect(n);
- if (!box) { continue; }
- var overlap = Math.min(box.bottom, ib.bottom) - Math.max(box.top, ib.top);
- if (overlap <= 0) { continue; } // ① 同一行
- if (box.left < ib.right + 10) { continue; } // ② 在输入框右边
- var w = box.right - box.left;
- var h = box.bottom - box.top;
- if (w < 40 || h < 40 || w > 120 || h > 120) { continue; } // ③ 尺寸
- try {
- if (n.clickable() !== true) { continue; } // ④ 可点
- } catch (ignored) {
- continue;
- }
- if (STICKER_HINT.test(layout.desc(n))) { continue; } // ⑤ 不是表情
- if (bestLeft === null || box.left < bestLeft) {
- bestLeft = box.left;
- best = n;
- }
- }
- }
- return best;
- }
- /**
- * 拿到发送键,拿不到就**再点一次输入框**强制聚焦后重试一次。
- *
- * ★ 为什么需要重试(2026-10-29 真机踩到):发送键只在**键盘弹起**时才存在
- * (输入框缩短,右侧空出那块 72×72 的 ImageView)。而 `typeInto` 的那次点击
- * 有时被输入法吞掉 —— 文字确实写进去了,键盘却没起来,于是发送键不存在。
- * 表现是"演练成功、实发失败:输入后找不到发送键",且时好时坏。
- *
- * 重试的动作很小(点一下输入框),但它把"时好时坏"变成"基本必成"。
- */
- function ensureSendButton(config, step) {
- var log = logger();
- var button = TikTokAuto.wait.waitFor(step, function () {
- return sendButton() || null;
- }, 1500, 250);
- if (button) {
- return button;
- }
- var input = panelInput();
- if (!input) {
- return null;
- }
- var point = TikTokAuto.layout.center(input);
- if (!point) {
- return null;
- }
- log.info(step, '发送键还没出现,再点一次输入框强制聚焦');
- TikTokAuto.ui.tapPoint(point.x, point.y);
- TikTokAuto.wait.tick(400);
- return TikTokAuto.wait.waitFor(step + ':retry', function () {
- return sendButton() || null;
- }, 4000, 250);
- }
- /**
- * 打开私信面板(前提:当前已经在目标用户的**主页**上)。
- *
- * @returns {ok, via, reason}
- */
- function openPanel(config, step) {
- var story = step || 'dm:open';
- var log = logger();
- if (panelInput() !== null) {
- log.info(story, '私信面板已经开着');
- return { ok: true, via: 'already-open' };
- }
- var entry = messageButton();
- if (!entry) {
- /* 两种可能:对方关闭了私信(没有按钮),或页面还没渲染完。
- * 分不清时如实说,不要假惺惺地重试到超时。 */
- log.warn(story, '主页上没有 Message 按钮(对方可能关闭了私信,或页面还没渲染)');
- return { ok: false, via: 'no-message-button', reason: '主页上没有 Message 按钮' };
- }
- log.info(story, '点 Message 入口');
- TikTokAuto.ui.clickNode(entry, story);
- var opened = TikTokAuto.wait.waitFor(story + ':panel', function () {
- return panelInput() !== null ? true : null;
- }, config.timeouts.panelReadyMs, config.probe.pollTickMs);
- if (opened !== true) {
- /* 点一次没开就再点一次(首次点击常被页面动画吞掉) */
- log.warn(story, '第一次点击没打开面板,重试一次');
- TikTokAuto.wait.waitQuiet(function () {
- return TikTokAuto.layout.signature();
- }, { maxMs: 1500, tickMs: 200, stableTimes: 2 });
- var again = messageButton();
- if (again) {
- TikTokAuto.ui.clickNode(again, story + ':retry');
- }
- opened = TikTokAuto.wait.waitFor(story + ':panel2', function () {
- return panelInput() !== null ? true : null;
- }, config.timeouts.panelReadyMs, config.probe.pollTickMs);
- }
- if (opened !== true) {
- return { ok: false, via: 'panel-not-opened', reason: '私信面板没打开' };
- }
- return { ok: true, via: 'message-button' };
- }
- /** 收起面板(只在确认开着时调用,不无脑 back) */
- function closePanel(step) {
- if (panelInput() === null) {
- return false;
- }
- TikTokAuto.logger.info(step || 'dm:close', '收起私信面板');
- back();
- TikTokAuto.wait.until(function () {
- return panelInput() === null ? true : null;
- }, 4000, 200);
- return true;
- }
- /**
- * 发送一条私信。
- *
- * @param config 生效配置
- * @param text 要发的内容
- * @returns {ok, via, text, reason}
- */
- function send(config, text, step) {
- var story = step || 'dm:send';
- var log = logger();
- var content = String(text || '');
- if (content === '') {
- return { ok: false, via: 'empty-text', text: content, reason: '私信内容为空' };
- }
- var opened = openPanel(config, story);
- if (!opened.ok) {
- return { ok: false, via: opened.via, text: content, reason: opened.reason };
- }
- var input = panelInput();
- if (!input) {
- return { ok: false, via: 'no-input', text: content, reason: '面板里找不到输入框' };
- }
- /* 先用 typeInto(它会点输入框聚焦、再写入、再回读校验)。
- *
- * ⚠️ **必须先聚焦**:不聚焦就不会弹键盘,而发送键是"输入框缩短后"
- * 才出现的那块区域 —— 两者是同一个布局变化。 */
- var typed = TikTokAuto.ui.typeInto(input, content, story);
- if (!typed.ok) {
- return { ok: false, via: 'type-failed', text: content, reason: '输入框回读不一致' };
- }
- if (config.runtime && config.runtime.dryRun) {
- /* dryRun 也要**先把发送键找一遍**(只记录、不点)。
- * 否则"演练模式"永远验证不到最不确定的那一环 —— 发送键是输入后才出现的、
- * 无 id 无 desc 的 ImageView,找不到就等于发不出去,而演练却会报"成功"。 */
- var preview = ensureSendButton(config, story + ':btn-preview');
- log.warn(story, 'dryRun:已输入但不发送。发送键' +
- (preview ? '已定位 ' + JSON.stringify(preview.bounds()) : '**没找到**') +
- ',内容=' + JSON.stringify(content));
- var restore = panelInput();
- if (restore) {
- try { restore.setText(''); } catch (e) { }
- }
- TikTokAuto.wait.tick(300);
- closePanel(story);
- return { ok: true, via: 'dry-run', text: content, skipped: true,
- sendButtonFound: preview !== null };
- }
- /* 发送键是输入之后才出现的(而且依赖键盘弹起),拿不到会再聚焦重试一次 */
- var button = ensureSendButton(config, story + ':btn');
- if (!button) {
- log.warn(story, '输入后仍找不到发送键');
- closePanel(story);
- return { ok: false, via: 'send-button-not-found', text: content,
- reason: '输入后找不到发送键' };
- }
- /* 点发送键必须用**坐标点击**:它是无 id/desc 的 ImageView,
- * 无障碍 ACTION_CLICK 大概率落到父容器上(评论那边就栽在这)。 */
- var point = TikTokAuto.layout.center(button);
- log.info(story, '点发送键 id=' + TikTokAuto.layout.shortId(button) +
- ' b=' + JSON.stringify(button.bounds()));
- if (point) {
- TikTokAuto.ui.tapPoint(point.x, point.y);
- } else {
- TikTokAuto.ui.clickNode(button, story);
- }
- return awaitSent(config, content, story);
- }
- /**
- * 判定"到底发出去没有"。
- *
- * 证据两档:
- * 强:对话列表里出现了刚发的那条文本
- * 中:输入框被清空并恢复占位符("Message...")+ 面板仍然开着
- *
- * 只有一档"面板关了"时**不算成功** —— 面板也可能被别的原因关掉,
- * 这正是评论那边假阳性的来源。
- */
- function awaitSent(config, text, step) {
- var log = logger();
- /* 轮询等"输入框被清空"。
- * `setText` 清空后 text 是空串;占位符 "Message..." 是 UI 画的、不算 text,
- * 但某些版本会把占位符当 text 读回来,所以两个都认。 */
- var sent = TikTokAuto.wait.waitFor(step + ':sent', function () {
- var input = panelInput();
- if (!input) {
- return null; // 面板没了 ≠ 发出去了,不能算成功
- }
- var value = String(input.text() || '');
- return (value === '' || value === 'Message...') ? true : null;
- }, config.timeouts.postMs, config.probe.pollTickMs);
- if (sent === true) {
- /* 强证据:对话里能不能看到这条文本 */
- if (conversationContains(text)) {
- log.info(step, '私信已发送(对话里已出现该内容)' + JSON.stringify(text.slice(0, 20)));
- return { ok: true, via: 'text-in-conversation', confidence: 'high', text: text };
- }
- log.info(step, '私信已发送(输入框已清空)' + JSON.stringify(text.slice(0, 20)));
- return { ok: true, via: 'input-cleared', confidence: 'medium', text: text };
- }
- log.warn(step, '点了发送键但输入框没清空,疑似没发出去');
- closePanel(step);
- return { ok: false, via: 'not-cleared', text: text, reason: '点发送后输入框没清空' };
- }
- /** 对话列表里有没有这条文本(可见的 TextView 里找) */
- function conversationContains(text) {
- var layout = TikTokAuto.layout;
- var needle = String(text || '');
- if (needle === '') { return false; }
- var found = className('android.widget.TextView').find();
- for (var i = 0; i < found.size(); i += 1) {
- var value = layout.text(found.get(i));
- if (value && value.indexOf(needle) >= 0) {
- return true;
- }
- }
- return false;
- }
- return {
- messageButton: messageButton,
- panelInput: panelInput,
- sendButton: sendButton,
- openPanel: openPanel,
- closePanel: closePanel,
- send: send,
- conversationContains: conversationContains
- };
- }());
- /* ============================== src/dmflow.js ============================== */
- /**
- * dmflow.js — UID 私信的编排:读 UID → 关注一批 → 私信另一批 → 逐个休息
- *
- * ## 两批是**完全不同**的人
- *
- * 界面上「关注数量 100~200」与「私信数量 20~30」是两组独立额度,作用在不同的人身上:
- * · 前 followMax 个 UID → 只关注
- * · 接下来 sendMax 个 UID → 只发私信
- * 所以 UID 文件里的号要够多(至少 关注上限 + 私信上限)。
- *
- * ## 每处理完一个人就休息 restMin~Max 秒
- *
- * 这个值 v9 起默认 10~40 秒(原来是 100~500,一轮要跑 5~24 小时,实际跑不完)。
- * 休息是**分段等**的:每秒醒一次,用户点停止能立刻响应,而不是干等半分钟。
- *
- * ## 失败一律跳过
- *
- * 打不开主页、对方关闭私信、找不到发送键 —— 都记一条 warning 继续下一个,
- * 不中断整轮。但**连续失败**会有上限:连着 5 个都不成,说明是环境问题
- * (没登录、被限流、页面改版),继续跑只是白耗时间,停下来并说明原因。
- *
- * 依赖:TikTokAuto.profile / interact / dm / bridge / wait / logger / parse
- */
- TikTokAuto.dmflow = (function () {
- 'use strict';
- /** 连续这么多个都失败就认为环境出问题了,提前收尾 */
- var CONSECUTIVE_FAIL_LIMIT = 5;
- function logger() { return TikTokAuto.logger; }
- function progress(phase, done, total) {
- TikTokAuto.bridge.report({
- status: 'running',
- phase: phase,
- done: done,
- total: total
- });
- }
- /**
- * 读 UID 文本:每行一个(`@` 可有可无),忽略空行与 `#` 开头的注释行。
- * 去重按**规范化后的用户名**,避免 `@abc` 与 `abc` 被当成两个人。
- */
- function readUids(path) {
- var parse = TikTokAuto.parse;
- var out = [];
- var seen = {};
- if (!path) {
- return out;
- }
- var text = '';
- try {
- if (!files.exists(path)) {
- return out;
- }
- text = String(files.read(path) || '');
- } catch (error) {
- return out;
- }
- var rows = text.split(/\r?\n/);
- for (var i = 0; i < rows.length; i += 1) {
- var line = String(rows[i] || '').replace(/^\s+|\s+$/g, '');
- if (line === '' || line.charAt(0) === '#') {
- continue;
- }
- var uid = parse.username(line);
- if (uid === '' || seen[uid]) {
- continue;
- }
- seen[uid] = true;
- out.push(uid);
- }
- return out;
- }
- /**
- * 脚本二:从**自己的粉丝列表**取材。
- *
- * 采够 `need` 个就停 —— 多采没意义,而且每多翻一屏就多等一次渲染。
- * 停止条件挂在 `ctx.shouldStop()` 上:`walkList` 每处理一行前都会问,
- * 够了就自然结束(不是抛异常,所以不会把已采到的丢掉)。
- *
- * ★ 不要在这里判"列表到底":那是 walkList 的事(它只看"翻不翻得动")。
- * 早期版本用"本屏没有新人"当到底判据,结果预载历史时在列表顶部就收工。
- *
- * @returns 采到的 uid 数组(可能少于 need,比如粉丝本来就没那么多)
- */
- function collectOwnFans(config, need, summary) {
- var log = logger();
- var profileApi = TikTokAuto.profile;
- var uids = [];
- if (!profileApi.openOwnProfile(config)) {
- summary.errors.push('打不开自己的主页(底部导航 Profile),无法取材');
- return uids;
- }
- if (!profileApi.openList('fans', config)) {
- /* 主页数字区的入口第一次常常还没渲染出来(openList 内部已等 3 秒)。
- * 再等 2 秒重试一次 —— 失败一次就整轮退出太亏了。 */
- log.warn('dmflow', '第一次没打开粉丝列表,2 秒后重试');
- TikTokAuto.wait.tick(2000);
- if (!profileApi.openList('fans', config)) {
- summary.errors.push('打不开自己的粉丝列表(可能主页上不显示粉丝数)');
- return uids;
- }
- }
- /* 列表页默认停在「粉丝」还是「关注」取决于上次的操作,必须显式切一次。
- * 切错页签的后果很难发现:一样能翻、一样有人,只是全是**你关注的人**。 */
- if (!profileApi.switchListTab('fans')) {
- summary.warnings.push('没找到「粉丝」页签,按当前页签取材(可能取到的是关注列表)');
- }
- log.step('dmflow', '开始从自己的粉丝列表取材,目标 ' + need + ' 个');
- var ctx = {
- config: config,
- seen: TikTokAuto.uidset.create(),
- stat: {},
- shouldStop: function () {
- return uids.length >= need;
- },
- onSeenProfile: function () { },
- onRow: function (row) {
- if (uids.length < need) {
- uids.push(row.uid);
- }
- }
- };
- try {
- TikTokAuto.idsource.walkList(ctx);
- } catch (error) {
- if (TikTokAuto.bridge.isStopSignal(error)) {
- throw error;
- }
- log.warn('dmflow', '遍历粉丝列表时出错:' + error);
- summary.warnings.push('遍历粉丝列表时出错,已采到 ' + uids.length + ' 个');
- }
- profileApi.backToProfile();
- log.step('dmflow', '自己粉丝取材完成:' + uids.length + '/' + need +
- ' 个(翻了 ' + (ctx.stat.screens || 0) + ' 屏)');
- return uids;
- }
- /** 休息指定区间,**分段等**:每秒醒一次,停止信号能立刻生效 */
- function rest(config, step) {
- var lo = config.dm.rest.minMs;
- var hi = config.dm.rest.maxMs;
- var total = TikTokAuto.player.randomInt(lo, hi);
- var left = total;
- var sliceMs = 1000;
- while (left > 0) {
- var slice = Math.min(sliceMs, left);
- TikTokAuto.wait.tick(slice);
- left -= slice;
- }
- return total;
- }
- /** 从文案池随机挑一条,尽量不重复(池子用完就允许重复并告警) */
- function pickText(pool, used, step) {
- if (!pool || pool.length === 0) {
- return '';
- }
- var candidates = [];
- for (var i = 0; i < pool.length; i += 1) {
- if (used.indexOf(pool[i]) === -1) {
- candidates.push(pool[i]);
- }
- }
- if (candidates.length === 0) {
- logger().warn(step, '私信文案已用完,本轮允许重复');
- candidates = pool;
- }
- return candidates[Math.floor(Math.random() * candidates.length)];
- }
- /**
- * 处理一个人:打开主页 → 关注 或 发私信。
- *
- * @returns {uid, follow?, dm?, skipped?, reason?}
- */
- function handleOne(config, uid, mode, text, step) {
- var log = logger();
- var profileApi = TikTokAuto.profile;
- var detail = { uid: uid, mode: mode };
- var opened = profileApi.openBySearch(uid, config);
- if (!opened) {
- detail.skipped = true;
- detail.reason = '打不开主页';
- return detail;
- }
- if (mode === 'follow') {
- detail.follow = TikTokAuto.interact.follow(step + ':follow');
- return detail;
- }
- /* 私信:先确认面板能开,再发。发完把面板收掉,否则下一个人的主页会被它挡住。 */
- detail.dm = TikTokAuto.dm.send(config, text, step + ':dm');
- TikTokAuto.dm.closePanel(step + ':close');
- return detail;
- }
- /**
- * @param config 生效配置(含 `config.dm`)
- * @returns summary
- */
- function runSession(config) {
- var log = logger();
- var settings = config.dm;
- var summary = {
- ok: false,
- task: 'dm',
- script: settings.script,
- /** 人是哪儿来的:'uids' = 脚本一读文本;'ownFans' = 脚本二自己粉丝列表 */
- sourceKind: settings.script === 's2' ? 'ownFans' : 'uids',
- /** 演练模式:输入但不发送。要单独计数,否则 dryRun 会被误报成"什么都没做成" */
- dryRun: !!(config.runtime && config.runtime.dryRun),
- dryRunCount: 0,
- /** 取材结果里有多少个人(s1 = 文件行数,s2 = 粉丝列表采到的) */
- totalUids: 0,
- followPlanned: 0,
- dmPlanned: 0,
- followed: 0,
- sent: 0,
- skipped: 0,
- failed: 0,
- details: [],
- warnings: [],
- errors: []
- };
- /* ① 先算两批各要多少人 —— 脚本二靠这个数才知道要采多少人 */
- var followMax = settings.follow.enabled ? settings.follow.max : 0;
- var sendMax = settings.send.enabled ? settings.send.max : 0;
- if (followMax === 0 && sendMax === 0) {
- summary.errors.push('「进行关注」和「进行私信」都没开,没有可执行的动作');
- return summary;
- }
- var need = followMax + sendMax;
- var uids = [];
- var appReady = false;
- if (settings.script === 's2') {
- /* 脚本二:人就在**自己**主页的粉丝列表里,必须先把 App 拉起来才能取材。
- * 顺序与脚本一相反(脚本一是"文件不对就立刻返回,不折腾设备")。 */
- progress('打开自己的粉丝列表', 0, need);
- if (!TikTokAuto.flow.launchApp(config)) {
- summary.errors.push('启动应用失败');
- return summary;
- }
- if (!TikTokAuto.flow.ensureFeed(config)) {
- summary.errors.push('未能进入信息流首页');
- return summary;
- }
- appReady = true;
- uids = collectOwnFans(config, need, summary);
- if (uids.length === 0) {
- if (summary.errors.length === 0) {
- summary.errors.push('自己的粉丝列表里没取到人(可能还没有粉丝)');
- }
- return summary;
- }
- } else {
- /* 脚本一:读文本。**这一步不碰手机**,文件不对就立刻返回。 */
- uids = readUids(settings.uidPath);
- if (uids.length === 0) {
- summary.errors.push('UID 文本里没有可用的账号(' +
- (settings.uidPath || '(未设置路径)') + ')');
- return summary;
- }
- }
- summary.totalUids = uids.length;
- /* ② 分两批:前 N 个关注,接着 M 个私信 —— 两批人不重叠 */
- var followBatch = uids.slice(0, followMax);
- var dmBatch = uids.slice(followMax, followMax + sendMax);
- summary.followPlanned = followBatch.length;
- summary.dmPlanned = dmBatch.length;
- if (followBatch.length < followMax || dmBatch.length < sendMax) {
- summary.warnings.push((settings.script === 's2' ? '粉丝' : 'UID') +
- '只够 ' + uids.length + ' 个,少于计划(关注 ' +
- followMax + ' + 私信 ' + sendMax + '),本轮按实际数量执行');
- }
- var total = followBatch.length + dmBatch.length;
- log.step('dmflow', '私信任务开始(' +
- (settings.script === 's2' ? '自己粉丝取材' : 'UID 文本取材') + '):' +
- (settings.script === 's2' ? '粉丝' : 'UID') + ' ' + uids.length + ' 个;关注 ' +
- followBatch.length + ' 个 + 私信 ' + dmBatch.length + ' 个;' +
- '每个之后休息 ' + Math.round(settings.rest.minMs / 1000) + '~' +
- Math.round(settings.rest.maxMs / 1000) + ' 秒');
- /* 脚本二在取材时已经把 App 拉起来并停在「自己的主页」上了,别重复启动:
- * launchApp 会重新发一次 startActivity(已在前台也一样),把页面状态打乱。
- * 后面的 handleOne → openBySearch 第一步就是 ensureFeed,会自己回信息流。 */
- if (!appReady) {
- progress('启动应用', 0, total);
- if (!TikTokAuto.flow.launchApp(config)) {
- summary.errors.push('启动应用失败');
- return summary;
- }
- if (!TikTokAuto.flow.ensureFeed(config)) {
- summary.errors.push('未能进入信息流首页');
- return summary;
- }
- }
- var usedTexts = [];
- var done = 0;
- var consecutiveFail = 0;
- var i = 0;
- /* ③ 关注批次 */
- for (i = 0; i < followBatch.length; i += 1) {
- var fUid = followBatch[i];
- progress('关注 ' + (i + 1) + '/' + followBatch.length + ' @' + fUid, done, total);
- var fDetail = handleOne(config, fUid, 'follow', '', 'dmflow:follow');
- fDetail.phase = 'follow';
- summary.details.push(fDetail);
- if (fDetail.skipped) {
- summary.skipped += 1;
- summary.warnings.push('关注 @' + fUid + ' 跳过:' + fDetail.reason);
- consecutiveFail += 1;
- } else if (fDetail.follow && fDetail.follow.ok) {
- if (!fDetail.follow.skipped) {
- summary.followed += 1;
- }
- consecutiveFail = 0;
- } else {
- summary.failed += 1;
- summary.warnings.push('关注 @' + fUid + ' 失败:' +
- ((fDetail.follow && fDetail.follow.reason) || '未知'));
- consecutiveFail += 1;
- }
- done += 1;
- if (consecutiveFail >= CONSECUTIVE_FAIL_LIMIT) {
- summary.warnings.push('连续 ' + consecutiveFail +
- ' 个账号都失败,提前结束(可能是未登录 / 被限流 / 页面改版)');
- break;
- }
- /* 批次之间**不**休息 —— 关注是轻动作,休息留给私信更合理。
- * 但用户明确要求"每处理完一个 UID 都休息",所以这里也休。 */
- rest(config, 'dmflow:rest');
- }
- /* ④ 私信批次(换另一批人) */
- var dmStart = 0;
- if (consecutiveFail < CONSECUTIVE_FAIL_LIMIT) {
- for (dmStart = 0; dmStart < dmBatch.length; dmStart += 1) {
- var dUid = dmBatch[dmStart];
- var text = pickText(settings.pool, usedTexts, 'dmflow:pick');
- if (text === '') {
- summary.warnings.push('私信文案池是空的,私信批次没法执行(检查「随机私信文本」那个文件)');
- break;
- }
- progress('私信 ' + (dmStart + 1) + '/' + dmBatch.length + ' @' + dUid, done, total);
- var dDetail = handleOne(config, dUid, 'dm', text, 'dmflow:dm');
- dDetail.phase = 'dm';
- dDetail.text = text;
- summary.details.push(dDetail);
- if (dDetail.skipped) {
- summary.skipped += 1;
- summary.warnings.push('私信 @' + dUid + ' 跳过:' + dDetail.reason);
- consecutiveFail += 1;
- } else if (dDetail.dm && dDetail.dm.ok) {
- if (dDetail.dm.skipped) {
- /* dryRun:演练成功(输入到位、发送键也定位到了),但没真发 */
- summary.dryRunCount += 1;
- } else {
- summary.sent += 1;
- usedTexts.push(text);
- }
- consecutiveFail = 0;
- } else {
- summary.failed += 1;
- summary.warnings.push('私信 @' + dUid + ' 失败:' +
- ((dDetail.dm && dDetail.dm.reason) || '未知'));
- consecutiveFail += 1;
- }
- done += 1;
- if (consecutiveFail >= CONSECUTIVE_FAIL_LIMIT) {
- summary.warnings.push('连续 ' + consecutiveFail +
- ' 个账号都失败,提前结束(可能是未登录 / 被限流 / 页面改版)');
- break;
- }
- rest(config, 'dmflow:rest');
- }
- }
- summary.ok = summary.followed > 0 || summary.sent > 0 || summary.dryRunCount > 0;
- if (!summary.ok && summary.errors.length === 0) {
- summary.errors.push('一个动作都没做成(关注 ' + summary.followed +
- ' / 私信 ' + summary.sent + ')');
- }
- log.step('dmflow', '私信任务结束:关注 ' + summary.followed + ' 个,私信 ' +
- summary.sent + ' 条' +
- (summary.dryRun ? '(演练 ' + summary.dryRunCount + ' 条,未真发)' : '') +
- ',跳过 ' + summary.skipped + ' 个,失败 ' + summary.failed + ' 个');
- return summary;
- }
- return {
- runSession: runSession,
- readUids: readUids,
- collectOwnFans: collectOwnFans,
- pickText: pickText,
- CONSECUTIVE_FAIL_LIMIT: CONSECUTIVE_FAIL_LIMIT
- };
- }());
- /* ============================== src/main.js ============================== */
- /**
- * main.js — 入口与收尾
- *
- * 入口只负责三件事:读配置、开守护线程、跑流程。
- * 收尾负责四件**必须保证**的事:
- * 1. 无论成功失败都打印 `__END__`(否则 PC 侧执行器会一直等到超时);
- * 2. 关掉守护线程(`threads.shutDownAll()`,否则进程不退出);
- * 3. 打印一行机器可读的 RESULT,让 PC 侧能直接断言而不是解析自然语言日志;
- * 4. 向界面宿主上报终态(被界面唤起时才有这一步,见 bridge.js)。
- *
- * 依赖:全部模块
- */
- TikTokAuto.main = (function () {
- 'use strict';
- var RESULT_MARKER = '__END__';
- function emptySummary(reason) {
- return {
- ok: false,
- attemptedVideos: 0,
- postedComments: 0,
- details: [],
- errors: [reason]
- };
- }
- /**
- * 去掉占位原因。
- * `emptySummary('未开始')` 的那条只在"一次都没跑起来"时才有意义;
- * 流程已经跑过一段后再追加真实原因,留着它会让界面把"未开始"显示成第一条错误。
- */
- function dropPlaceholder(errors) {
- var kept = [];
- for (var i = 0; i < errors.length; i += 1) {
- if (errors[i] !== '未开始') {
- kept.push(errors[i]);
- }
- }
- return kept;
- }
- /**
- * 按 `config.task` 分派。
- *
- * 入口只有这一个分岔点:两条链路都走同一套"配置 → 守护线程 → 流程 → 终态上报",
- * 差别只在中间调哪个 runSession。所以 `__END__`、RESULT、终态上报这些收尾逻辑
- * **一份就够** —— 各写一份的话,两边的收尾行为迟早会不一致。
- */
- function runByTask(config) {
- if (config.task === 'id') {
- return TikTokAuto.idflow.runSession(config);
- }
- if (config.task === 'nurture') {
- return TikTokAuto.nurture.runSession(config);
- }
- if (config.task === 'dm') {
- return TikTokAuto.dmflow.runSession(config);
- }
- return TikTokAuto.flow.runSession(config);
- }
- /**
- * @param injected PC 侧注入的配置覆盖项
- * @returns 结构化结果
- */
- function run(injected) {
- var log = TikTokAuto.logger;
- var bridge = TikTokAuto.bridge;
- var summary = emptySummary('未开始');
- var guardState = null;
- var config = null;
- /* 是"用户叫停"还是"出错了",收尾时的上报状态不同 */
- var stoppedByUser = false;
- try {
- log.step('main', '=== TikTok 自动互动开始 ===');
- bridge.report({ status: 'running', phase: '准备', done: 0, total: 0 });
- try {
- config = TikTokAuto.config.resolve(injected);
- } catch (configError) {
- log.error('main', '配置无效:' + configError.message + ' / detail=' + JSON.stringify(configError.detail));
- summary = emptySummary('配置无效:' + configError.message);
- return summary;
- }
- /* 配置里带着"这次属于哪个 runId",校验它对不对是 config.resolve 的事;
- * 这里只把它登记到状态里,让宿主在排障时一眼看出跑的是哪个任务。 */
- bridge.setTask(config.task);
- bridge.report({ phase: '启动应用', total: config.task === 'id' ? 0 : config.target.maxVideos });
- guardState = TikTokAuto.popups.startGuard(config, log);
- var guardUp = TikTokAuto.wait.waitFor('guard', function () {
- return guardState.passes >= 1 ? true : null;
- }, config.guard.firstScanTimeoutMs, 200);
- if (!guardUp) {
- log.warn('guard', '守护线程首次巡检未确认,继续执行(不阻塞主流程)');
- }
- summary = runByTask(config);
- log.step('main', '=== 流程结束,耗时 ' + log.elapsedMs() + 'ms ===');
- } catch (error) {
- if (bridge.isStopSignal(error)) {
- /* 停止是**正常收尾**,不是崩溃:不记 ERROR、不把堆栈当异常信息 */
- stoppedByUser = true;
- log.warn('main', '收到停止请求,提前结束本次运行');
- /* ID 提取的统计在 idflow 里已经算好了(含刚刷盘的那个数),
- * 这里取它 —— 否则界面会显示"提取 0 个",而文件里明明有内容。 */
- var partial = TikTokAuto.idflow ? TikTokAuto.idflow.lastSummary() : null;
- if (partial) {
- summary = partial;
- }
- summary.ok = false;
- summary.errors = dropPlaceholder(summary.errors || []);
- summary.errors.push('用户停止');
- } else {
- var detail = error && error.stack ? String(error.stack) : String(error);
- log.error('main', '未捕获异常:' + detail);
- summary.ok = false;
- summary.errors.push(String(error));
- }
- } finally {
- TikTokAuto.popups.stopGuard(guardState, log);
- log.result(summary);
- /* 先把终态交给宿主,再收线程:顺序反了的话,界面可能永远等不到"结束",
- * 状态条会一直显示"运行中"。
- *
- * ★ finish 必须包 try/finally:它自己一旦抛错(summary 结构异常之类),
- * 心跳线程就永远不被收掉 —— 而这是 finally 里,没有下一层兜底,
- * `shutDownAll` 与 `__END__` 都会被一起吞掉,宿主只能等 15 秒判死。 */
- try {
- bridge.finish(
- stoppedByUser ? 'stopped' : (summary.ok ? 'finished' : 'failed'),
- summary
- );
- } finally {
- bridge.heartbeatStop();
- }
- // 顺序很重要:先让日志刷出去,再收线程,最后打结束标记
- try {
- threads.shutDownAll();
- } catch (ignored) {
- // 没有线程在跑时该调用可能抛错,忽略即可
- }
- console.log(RESULT_MARKER);
- }
- return summary;
- }
-
- return {
- RESULT_MARKER: RESULT_MARKER,
- run: run
- };
- }());
- /* PC 侧下发时可替换下面这一行的 null 来注入配置覆盖项 */
- var INJECTED_CONFIG = /*TTA_INJECTED_CONFIG*/null;
- /* 被界面宿主唤起时(engines.execScriptFile 带 --config/--run-id 参数),
- * fromExecArgv 会把参数翻译成「配置路径 + 桥的开关」;独立运行时是空操作。 */
- TikTokAuto.main.run(TikTokAuto.bridge.fromExecArgv(INJECTED_CONFIG));
- }());
|