tiktok-auto.js 256 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102410341044105410641074108410941104111411241134114411541164117411841194120412141224123412441254126412741284129413041314132413341344135413641374138413941404141414241434144414541464147414841494150415141524153415441554156415741584159416041614162416341644165416641674168416941704171417241734174417541764177417841794180418141824183418441854186418741884189419041914192419341944195419641974198419942004201420242034204420542064207420842094210421142124213421442154216421742184219422042214222422342244225422642274228422942304231423242334234423542364237423842394240424142424243424442454246424742484249425042514252425342544255425642574258425942604261426242634264426542664267426842694270427142724273427442754276427742784279428042814282428342844285428642874288428942904291429242934294429542964297429842994300430143024303430443054306430743084309431043114312431343144315431643174318431943204321432243234324432543264327432843294330433143324333433443354336433743384339434043414342434343444345434643474348434943504351435243534354435543564357435843594360436143624363436443654366436743684369437043714372437343744375437643774378437943804381438243834384438543864387438843894390439143924393439443954396439743984399440044014402440344044405440644074408440944104411441244134414441544164417441844194420442144224423442444254426442744284429443044314432443344344435443644374438443944404441444244434444444544464447444844494450445144524453445444554456445744584459446044614462446344644465446644674468446944704471447244734474447544764477447844794480448144824483448444854486448744884489449044914492449344944495449644974498449945004501450245034504450545064507450845094510451145124513451445154516451745184519452045214522452345244525452645274528452945304531453245334534453545364537453845394540454145424543454445454546454745484549455045514552455345544555455645574558455945604561456245634564456545664567456845694570457145724573457445754576457745784579458045814582458345844585458645874588458945904591459245934594459545964597459845994600460146024603460446054606460746084609461046114612461346144615461646174618461946204621462246234624462546264627462846294630463146324633463446354636463746384639464046414642464346444645464646474648464946504651465246534654465546564657465846594660466146624663466446654666466746684669467046714672467346744675467646774678467946804681468246834684468546864687468846894690469146924693469446954696469746984699470047014702470347044705470647074708470947104711471247134714471547164717471847194720472147224723472447254726472747284729473047314732473347344735473647374738473947404741474247434744474547464747474847494750475147524753475447554756475747584759476047614762476347644765476647674768476947704771477247734774477547764777477847794780478147824783478447854786478747884789479047914792479347944795479647974798479948004801480248034804480548064807480848094810481148124813481448154816481748184819482048214822482348244825482648274828482948304831483248334834483548364837483848394840484148424843484448454846484748484849485048514852485348544855485648574858485948604861486248634864486548664867486848694870487148724873487448754876487748784879488048814882488348844885488648874888488948904891489248934894489548964897489848994900490149024903490449054906490749084909491049114912491349144915491649174918491949204921492249234924492549264927492849294930493149324933493449354936493749384939494049414942494349444945494649474948494949504951495249534954495549564957495849594960496149624963496449654966496749684969497049714972497349744975497649774978497949804981498249834984498549864987498849894990499149924993499449954996499749984999500050015002500350045005500650075008500950105011501250135014501550165017501850195020502150225023502450255026502750285029503050315032503350345035503650375038503950405041504250435044504550465047504850495050505150525053505450555056505750585059506050615062506350645065506650675068506950705071507250735074507550765077507850795080508150825083508450855086508750885089509050915092509350945095509650975098509951005101510251035104510551065107510851095110511151125113511451155116511751185119512051215122512351245125512651275128512951305131513251335134513551365137513851395140514151425143514451455146514751485149515051515152515351545155515651575158515951605161516251635164516551665167516851695170517151725173517451755176517751785179518051815182518351845185518651875188518951905191519251935194519551965197519851995200520152025203520452055206520752085209521052115212521352145215521652175218521952205221522252235224522552265227522852295230523152325233523452355236523752385239524052415242524352445245524652475248524952505251525252535254525552565257525852595260526152625263526452655266526752685269527052715272527352745275527652775278527952805281528252835284528552865287528852895290529152925293529452955296529752985299530053015302530353045305530653075308530953105311531253135314531553165317531853195320532153225323532453255326532753285329533053315332533353345335533653375338533953405341534253435344534553465347534853495350535153525353535453555356535753585359536053615362536353645365536653675368536953705371537253735374537553765377537853795380538153825383538453855386538753885389539053915392539353945395539653975398539954005401540254035404540554065407540854095410541154125413541454155416541754185419542054215422542354245425542654275428542954305431543254335434543554365437543854395440544154425443544454455446544754485449545054515452545354545455545654575458545954605461546254635464546554665467546854695470547154725473547454755476547754785479548054815482548354845485548654875488548954905491549254935494549554965497549854995500550155025503550455055506550755085509551055115512551355145515551655175518551955205521552255235524552555265527552855295530553155325533553455355536553755385539554055415542554355445545554655475548554955505551555255535554555555565557555855595560556155625563556455655566556755685569557055715572557355745575557655775578557955805581558255835584558555865587558855895590559155925593559455955596559755985599560056015602560356045605560656075608560956105611561256135614561556165617561856195620562156225623562456255626562756285629563056315632563356345635563656375638563956405641564256435644564556465647564856495650565156525653565456555656565756585659566056615662566356645665566656675668566956705671567256735674567556765677567856795680568156825683568456855686568756885689569056915692569356945695569656975698569957005701570257035704570557065707570857095710571157125713571457155716571757185719572057215722572357245725572657275728572957305731573257335734573557365737573857395740574157425743574457455746574757485749575057515752575357545755575657575758575957605761576257635764576557665767576857695770577157725773577457755776577757785779578057815782578357845785578657875788578957905791579257935794579557965797579857995800580158025803580458055806580758085809581058115812581358145815581658175818581958205821582258235824582558265827582858295830583158325833583458355836583758385839584058415842584358445845584658475848584958505851585258535854585558565857585858595860586158625863586458655866586758685869587058715872587358745875587658775878587958805881588258835884588558865887588858895890589158925893589458955896589758985899590059015902590359045905590659075908590959105911591259135914591559165917591859195920592159225923592459255926592759285929593059315932593359345935593659375938593959405941594259435944594559465947594859495950595159525953595459555956595759585959596059615962596359645965596659675968596959705971597259735974597559765977597859795980598159825983598459855986598759885989599059915992599359945995599659975998599960006001600260036004600560066007600860096010601160126013601460156016601760186019602060216022602360246025602660276028602960306031603260336034603560366037603860396040604160426043604460456046604760486049605060516052605360546055605660576058605960606061606260636064606560666067606860696070607160726073607460756076607760786079608060816082608360846085608660876088608960906091609260936094609560966097609860996100610161026103610461056106610761086109611061116112611361146115611661176118611961206121612261236124612561266127612861296130613161326133613461356136613761386139614061416142614361446145614661476148614961506151615261536154615561566157615861596160616161626163616461656166616761686169617061716172617361746175617661776178617961806181618261836184618561866187618861896190619161926193619461956196619761986199620062016202620362046205620662076208620962106211621262136214621562166217621862196220622162226223622462256226622762286229623062316232623362346235623662376238623962406241624262436244624562466247624862496250625162526253625462556256625762586259626062616262626362646265626662676268626962706271627262736274627562766277627862796280628162826283628462856286628762886289629062916292629362946295629662976298629963006301630263036304630563066307630863096310631163126313631463156316631763186319632063216322632363246325632663276328632963306331633263336334633563366337633863396340634163426343634463456346634763486349635063516352635363546355635663576358635963606361636263636364636563666367636863696370637163726373637463756376637763786379638063816382638363846385638663876388638963906391639263936394639563966397639863996400640164026403640464056406640764086409641064116412641364146415641664176418641964206421642264236424642564266427642864296430643164326433643464356436643764386439644064416442644364446445644664476448644964506451645264536454645564566457645864596460646164626463646464656466646764686469647064716472647364746475647664776478647964806481648264836484648564866487648864896490649164926493649464956496649764986499650065016502650365046505650665076508650965106511651265136514651565166517651865196520652165226523652465256526652765286529653065316532653365346535653665376538653965406541654265436544654565466547654865496550655165526553655465556556655765586559656065616562656365646565656665676568656965706571657265736574657565766577657865796580658165826583658465856586658765886589659065916592659365946595659665976598659966006601660266036604660566066607660866096610661166126613
  1. /*! tiktok-auto v1.0.0
  2. * built 2026-10-20T11:29:19.585Z
  3. * modules: logger, bridge, config, wait, layout, ui, popups, tiktok, player, comment, flow, parse, uidset, filters, profile, idsource, resolve, idflow, main
  4. * 由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。
  5. */
  6. (function () {
  7. var TikTokAuto = {};
  8. /* ============================== src/logger.js ============================== */
  9. /**
  10. * logger.js — 结构化日志
  11. *
  12. * 为什么单独成模块:设备脚本出错时,唯一能依赖的就是回传的日志。
  13. * 把日志格式固定下来(`[TTA][步骤名] 内容`),PC 侧就能按前缀过滤、按步骤计时,
  14. * 而不是靠人肉读一堆散乱的 console.log。
  15. *
  16. * 2026-10-13 增加**文件落盘通道**(sink):界面侧的「运行记录 → 详情的执行日志」
  17. * 要看的就是这些行。之所以不做成"界面去抓 console",是因为
  18. * console 是另一个进程的 stdout,界面根本读不到(见 bridge.js 的通道说明)。
  19. * sink 由 bridge 在启用宿主模式时装上,**独立运行时为空 → 零开销**。
  20. *
  21. * 依赖:无。始终通过 TikTokAuto.logger 取用,因此可以被放在 concat 顺序的最前面。
  22. */
  23. TikTokAuto.logger = (function () {
  24. 'use strict';
  25. var PREFIX = '[TTA]';
  26. var startedAt = Date.now();
  27. /** 文件落盘回调,由 bridge 注入:function(level, step, message) */
  28. var sink = null;
  29. function stamp() {
  30. return ('+' + (Date.now() - startedAt) + 'ms');
  31. }
  32. function write(level, step, message) {
  33. var line = PREFIX + stamp() + '[' + level + '][' + step + '] ' + message;
  34. console.log(line);
  35. if (sink) {
  36. /* 落盘失败绝不能影响流程:日志是"顺带记录的",不是"必须成功的"。 */
  37. try {
  38. sink(level, step, message);
  39. } catch (ignored) {
  40. /* 刻意吞掉:sink 抛错会把一次正常的日志调用变成流程中断 */
  41. }
  42. }
  43. }
  44. return {
  45. /** 装上文件落盘通道(传 null 卸载) */
  46. setSink: function (fn) {
  47. sink = typeof fn === 'function' ? fn : null;
  48. },
  49. /** 记录一次进入某步骤 */
  50. step: function (name, message) {
  51. write('STEP', name, message === undefined ? '' : message);
  52. },
  53. info: function (step, message) {
  54. write('INFO', step, message);
  55. },
  56. /** 可恢复的问题:值得记下来,但不中断流程 */
  57. warn: function (step, message) {
  58. write('WARN', step, message);
  59. },
  60. error: function (step, message) {
  61. write('ERROR', step, message);
  62. },
  63. /** 机器可读的结构化结果,PC 侧用它做断言 */
  64. result: function (payload) {
  65. console.log(PREFIX + 'RESULT ' + JSON.stringify(payload));
  66. },
  67. /** 从脚本开始到现在的耗时,用于定位"哪一步慢" */
  68. elapsedMs: function () {
  69. return Date.now() - startedAt;
  70. }
  71. };
  72. }());
  73. /* ============================== src/bridge.js ============================== */
  74. /**
  75. * bridge.js — 与「界面宿主」之间的桥
  76. *
  77. * 为什么需要它:这个脚本有两种运行方式——
  78. * ① 独立运行(PC 侧下发、或直接打开产物):自己读配置,跑完打印 `__END__`;
  79. * ② 被界面宿主唤起(`engines.execScriptFile`,见 tiktok-app):
  80. * 配置由宿主生成,宿主还要能看见**进度**、能**叫停**。
  81. *
  82. * 两种方式共用同一套流程,差别只在「上下文从哪来、状态往哪去」,所以抽成这个模块:
  83. * 流程代码只调 `bridge.report()` / `bridge.checkStop()`,不必知道自己在被谁驱动。
  84. * **未启用时两者都是空操作**,独立运行零开销、零行为变化。
  85. *
  86. * 通道全部走**文件**,不走共享内存 —— 宿主与子引擎是两个独立的 ScriptEngine,
  87. * 脚本级全局变量互不可见(实测:父脚本读不到子引擎里的任何对象)。
  88. * 文件是这里唯一可靠的通道。
  89. *
  90. * 四个约定文件(默认都在 /sdcard/tiktok_app/ 下):
  91. * run-config.json 宿主 → 引擎:界面收集的完整配置快照(人可读,非本模块读取)
  92. * engine-config.json 宿主 → 引擎:映射后的引擎配置(由 --config 交给 config.resolve)
  93. * run-state.json 引擎 → 宿主:进度与终态(本模块写)
  94. * stop.flag 宿主 → 引擎:停止请求(本模块只读)
  95. *
  96. * ★ stop.flag 里放的是**本次运行的 runId**,不是空文件、也不是布尔。
  97. * 好处一:不必删除它(`files.remove` 命中 MCP 危险调用黑名单;删除本身也有竞态)。
  98. * 好处二:上一轮残留的标志不会误杀本轮 —— runId 不同,自然不匹配。
  99. *
  100. * 依赖:TikTokAuto.logger(调用时取,避免 concat 顺序耦合)
  101. */
  102. TikTokAuto.bridge = (function () {
  103. 'use strict';
  104. var DEFAULT_DIR = '/sdcard/tiktok_app';
  105. var DEFAULT_STATE = DEFAULT_DIR + '/run-state.json';
  106. var DEFAULT_STOP_FLAG = DEFAULT_DIR + '/stop.flag';
  107. var DEFAULT_LOG = DEFAULT_DIR + '/run-log.jsonl';
  108. /**
  109. * 检查停止标志的最小间隔。
  110. * 每次 tick(默认 150ms)都去 stat 一次文件是没必要的高频 IO;
  111. * 500ms 的响应延迟对人来说与"立刻"无异。
  112. */
  113. var STOP_POLL_MIN_MS = 500;
  114. var state = {
  115. enabled: false,
  116. runId: '',
  117. statePath: DEFAULT_STATE,
  118. stopFlagPath: DEFAULT_STOP_FLAG,
  119. logPath: DEFAULT_LOG,
  120. lastStopCheckMs: 0
  121. };
  122. var last = null;
  123. /**
  124. * 「用户要求停止」。**刻意用异常抛出**:
  125. * 停止请求可能发生在任意深度的等待里(等页面就绪、等播放开始、等结果卡片……),
  126. * 逐层返回码需要改动几十处调用点且极容易漏掉一处就变成"点了停止但不停"。
  127. * 抛出后由 main 统一兜住,语义明确(名字里带 Signal,不是 Error 那种"出错了")。
  128. */
  129. function StopSignal() {
  130. this.name = 'StopSignal';
  131. this.message = '收到停止请求';
  132. this.stack = new Error(this.message).stack;
  133. }
  134. StopSignal.prototype = new Error();
  135. StopSignal.prototype.constructor = StopSignal;
  136. /**
  137. * 判断自有属性。
  138. * 刻意写成 `.apply(...)` 而不是「属性名 + 点 + call」的形式——见 config.js 里的说明:
  139. * MCP 桥的危险调用黑名单会按词边界匹配「call + 左括号」,`.apply` 语义等价却不会被误判。
  140. */
  141. function owns(object, key) {
  142. return Object.prototype.hasOwnProperty.apply(object, [key]) === true;
  143. }
  144. function isStopSignal(error) {
  145. return !!(error && error.name === 'StopSignal');
  146. }
  147. /**
  148. * 取引擎启动参数。
  149. * Rhino 会把传进来的数组包装成 `{"0":..,"1":..}` 这种对象(实测),
  150. * 所以既接受真数组,也按数字下标收集一份。
  151. */
  152. function readExecArgv() {
  153. var list = [];
  154. try {
  155. var argv = engines.myEngine().execArgv;
  156. if (argv instanceof Array) {
  157. return argv;
  158. }
  159. for (var i = 0; argv && argv[i] !== undefined; i += 1) {
  160. list.push(argv[i]);
  161. }
  162. } catch (ignored) {
  163. // 独立运行时 execArgv 为空/不可用,按"没有参数"处理
  164. }
  165. return list;
  166. }
  167. /** 解析 `--key value` 形式的参数(不支持 `--key=value`,保持单一写法) */
  168. function parseArgs(argv) {
  169. var options = {};
  170. for (var i = 0; i < argv.length; i += 1) {
  171. var token = String(argv[i]);
  172. if (token === '--config') {
  173. options.configPath = argv[i + 1];
  174. i += 1;
  175. } else if (token === '--state') {
  176. options.statePath = argv[i + 1];
  177. i += 1;
  178. } else if (token === '--stop-flag') {
  179. options.stopFlagPath = argv[i + 1];
  180. i += 1;
  181. } else if (token === '--run-id') {
  182. options.runId = argv[i + 1];
  183. i += 1;
  184. } else if (token === '--log') {
  185. options.logPath = argv[i + 1];
  186. i += 1;
  187. }
  188. }
  189. return options;
  190. }
  191. /**
  192. * 入口用:把命令行参数翻译成「配置覆盖项 + 桥的开关」。
  193. *
  194. * @param base 构建时注入的配置(可为 null)
  195. * @returns 应当交给 main.run 的注入项
  196. */
  197. function fromExecArgv(base) {
  198. var injected = base || {};
  199. var options = parseArgs(readExecArgv());
  200. if (options.runId) {
  201. enable(options);
  202. }
  203. if (options.configPath) {
  204. injected.runtime = injected.runtime || {};
  205. injected.runtime.configPath = options.configPath;
  206. }
  207. return injected;
  208. }
  209. /**
  210. * 日志落盘(界面侧「执行日志」的来源)。
  211. *
  212. * 一行一条 JSON(jsonl):界面按行解析,坏行跳过即可 ——
  213. * 比整份 JSON 更抗"写着写着被杀进程"(半行只丢一行,不会让整份文件不可读)。
  214. * 用 append 而不是重写:日志是**流**,每写一次整文件会让长流程变成 O(n²) 的 IO。
  215. */
  216. function appendLog(level, step, message) {
  217. try {
  218. files.append(state.logPath,
  219. JSON.stringify({
  220. t: Date.now(),
  221. level: level,
  222. step: step,
  223. text: (step ? '[' + step + '] ' : '') + message
  224. }) + '\n');
  225. } catch (error) {
  226. /* 落盘失败不该影响流程:logger 那边已经吞了一层,这里再兜一层 */
  227. }
  228. }
  229. /** 打开桥。没有 runId 时**不启用**——runId 是停止标志比对的依据,缺了它无法判定是谁的请求。 */
  230. function enable(options) {
  231. state.enabled = true;
  232. state.runId = String(options.runId);
  233. state.statePath = options.statePath || DEFAULT_STATE;
  234. state.stopFlagPath = options.stopFlagPath || DEFAULT_STOP_FLAG;
  235. state.logPath = options.logPath || DEFAULT_LOG;
  236. state.lastStopCheckMs = 0;
  237. /* ★ 清空上一轮的日志:界面在结束时整份读走,
  238. * 不清的话 D3 上会混进上一次运行的行(而且越跑越长)。 */
  239. try {
  240. files.write(state.logPath, '');
  241. } catch (error) {
  242. /* 清不掉也不致命:界面会按 t 只取本轮时间窗内的行 */
  243. }
  244. if (TikTokAuto.logger && TikTokAuto.logger.setSink) {
  245. TikTokAuto.logger.setSink(appendLog);
  246. }
  247. last = {
  248. runId: state.runId,
  249. status: 'starting',
  250. phase: '',
  251. done: 0,
  252. total: 0,
  253. updatedAt: 0
  254. };
  255. report({});
  256. log('info', '已连接到界面宿主,runId=' + state.runId);
  257. }
  258. function isEnabled() {
  259. return state.enabled;
  260. }
  261. /**
  262. * 写一次状态。宿主每秒读一次这个文件。
  263. * 失败只记 WARN:状态上报失败不该让正在跑的流程崩掉。
  264. */
  265. function report(patch) {
  266. if (!state.enabled || !last) {
  267. return;
  268. }
  269. for (var key in patch) {
  270. if (owns(patch, key)) {
  271. last[key] = patch[key];
  272. }
  273. }
  274. last.runId = state.runId;
  275. last.updatedAt = Date.now();
  276. try {
  277. files.write(state.statePath, JSON.stringify(last));
  278. } catch (error) {
  279. log('warn', '状态上报失败:' + error);
  280. }
  281. }
  282. /** 写终态。宿主靠它结束「运行中」并弹出结果。 */
  283. function finish(status, summary) {
  284. if (!state.enabled) {
  285. return;
  286. }
  287. var patch = {
  288. status: status,
  289. phase: '',
  290. summary: summary || null,
  291. message: describe(status, summary)
  292. };
  293. /* ID 提取的 `done` 是"已收个数",而最后一次 `report` 往往发生在收下一个人之前 ——
  294. * 于是终态里 done 会比实际少一个(实测 11 个却报 10)。
  295. * 收尾时按 summary 校正一次,免得界面/记录上的数字自相矛盾。 */
  296. if (summary && typeof summary.extracted === 'number') {
  297. patch.done = summary.extracted;
  298. }
  299. report(patch);
  300. log('info', '终态已上报:' + status);
  301. }
  302. /** 给界面看的一句人话,不要求界面自己去解析 summary 结构 */
  303. function describe(status, summary) {
  304. /* ID 提取与视频链路的 summary 形状不同(提取个数 vs 视频个数),
  305. * 但界面只有"一句人话"这一个位置 —— 所以在这里分支,让两条链路都说得清楚。
  306. * 判据是 `extracted` 字段是否存在,与 task 解耦(summary 自己就带得动语义)。 */
  307. var isIdTask = summary && typeof summary.extracted === 'number';
  308. if (status === 'stopped') {
  309. return isIdTask
  310. ? '已停止:本次已收 ' + summary.extracted + ' 个 ID'
  311. : '已停止';
  312. }
  313. if (status === 'finished') {
  314. if (isIdTask) {
  315. var tail = summary.skipped > 0 ? ',按条件跳过 ' + summary.skipped + ' 个' : '';
  316. /* 生成了 uid 映射就顺带说一句 —— 用户不一定知道还有第二个文件 */
  317. var mapped = summary.mapFile ? '(已生成 uid 映射)' : '';
  318. return '完成:提取 ' + summary.extracted + ' 个 ID' + tail + mapped;
  319. }
  320. var watched = summary ? summary.attemptedVideos : 0;
  321. var posted = summary ? summary.postedComments : 0;
  322. return '完成:处理 ' + watched + ' 个视频 / 发出 ' + posted + ' 条评论';
  323. }
  324. var reason = summary && summary.errors && summary.errors.length > 0
  325. ? summary.errors[0]
  326. : '未知原因';
  327. return '未完成:' + reason;
  328. }
  329. /**
  330. * 停止检查。**由唯一的等待原语 `wait.tick` 调用**,
  331. * 于是所有长等待都能被打断,而流程代码一处都不用改。
  332. * 命中即抛 StopSignal。
  333. */
  334. function checkStop() {
  335. if (!state.enabled) {
  336. return;
  337. }
  338. var now = Date.now();
  339. if (now - state.lastStopCheckMs < STOP_POLL_MIN_MS) {
  340. return;
  341. }
  342. state.lastStopCheckMs = now;
  343. var content = '';
  344. try {
  345. if (!files.exists(state.stopFlagPath)) {
  346. return;
  347. }
  348. content = String(files.read(state.stopFlagPath)).replace(/\s/g, '');
  349. } catch (error) {
  350. log('warn', '读取停止标志失败:' + error);
  351. return;
  352. }
  353. if (content !== '' && content === state.runId) {
  354. report({ status: 'stopping', phase: '正在收尾', message: '收到停止请求,正在收尾' });
  355. log('warn', '收到停止请求(runId 匹配),中断当前等待');
  356. throw new StopSignal();
  357. }
  358. }
  359. function log(level, message) {
  360. if (TikTokAuto.logger && TikTokAuto.logger[level]) {
  361. TikTokAuto.logger[level]('bridge', message);
  362. }
  363. }
  364. return {
  365. DEFAULT_DIR: DEFAULT_DIR,
  366. DEFAULT_STATE: DEFAULT_STATE,
  367. DEFAULT_STOP_FLAG: DEFAULT_STOP_FLAG,
  368. DEFAULT_LOG: DEFAULT_LOG,
  369. StopSignal: StopSignal,
  370. isStopSignal: isStopSignal,
  371. readExecArgv: readExecArgv,
  372. parseArgs: parseArgs,
  373. fromExecArgv: fromExecArgv,
  374. isEnabled: isEnabled,
  375. checkStop: checkStop,
  376. report: report,
  377. finish: finish,
  378. describe: describe,
  379. appendLog: appendLog
  380. };
  381. }());
  382. /* ============================== src/config.js ============================== */
  383. /**
  384. * config.js — 配置的默认值、设备端加载、合并与校验
  385. *
  386. * 设计依据(Zakas《编写可维护的JavaScript》第 9 章「使用配置对象」):
  387. * 所有"会变的业务数据"——搜索关键词、评论文案、观看时长、超时阈值、开关——
  388. * 都集中在配置文件里;代码只负责读,不负责写死。
  389. * 于是**改文案/改时长不需要动代码**,也不需要重新构建。
  390. *
  391. * 配置来源优先级(后者覆盖前者):
  392. * 1. DEFAULTS(本文件,保证"没有配置文件也能跑")
  393. * 2. 设备上的 JSON 文件(默认 /sdcard/Download/tiktok-auto/config.json)
  394. * 3. 运行时注入的 payload(PC 侧下发脚本时携带,便于免推送做实验)
  395. *
  396. * 依赖:TikTokAuto.logger
  397. */
  398. TikTokAuto.config = (function () {
  399. 'use strict';
  400. var DEFAULT_DEVICE_PATH = '/sdcard/Download/tiktok-auto/config.json';
  401. var DEFAULTS = {
  402. /**
  403. * 任务类型。缺省 'video' 是**刻意的向后兼容**:
  404. * 老配置(PC 侧下发、设备上残留的 config.json)里没有这个字段,
  405. * 它们本来就是视频链路的,所以默认值必须还是 video,
  406. * 否则一次升级会把所有老用法打成"未知任务"。
  407. */
  408. task: 'video',
  409. target: {
  410. packageName: 'com.zhiliaoapp.musically',
  411. keyword: 'fashion',
  412. maxVideos: 1,
  413. resultTab: 'Top',
  414. pickStrategy: 'random'
  415. },
  416. /**
  417. * ID 提取(task = 'id')。形状与界面 `sources.js` 对齐:
  418. * `sources` 是**不定长清单**,每条自带起点与深度。
  419. */
  420. id: {
  421. sources: [],
  422. filter: {
  423. private: false,
  424. noAvatar: false,
  425. langRegion: 'any',
  426. nicknameCheck: 'any',
  427. follow: { min: 0, max: 0 },
  428. fans: { min: 0, max: 0 },
  429. likes: { min: 0, max: 0 },
  430. videos: { min: 0, max: 0 }
  431. },
  432. output: {
  433. dir: '/sdcard/Download/tiktok_app',
  434. historyPath: '/sdcard/Download/tiktok_app/uid_extracted.txt',
  435. dedupeHistory: true,
  436. dryRun: false,
  437. limit: 0,
  438. /**
  439. * 抓完之后是否把 username 换成 TikTok 的**数字 uid**,另存一份映射文件。
  440. *
  441. * 默认开:数字 uid 在 App 界面上任何地方都读不到,不在这里换就得事后另跑一次。
  442. * 每个约 2.5 秒(要下整页 SSR 约 388KB),所以只要 handle 的场景可以关掉省时间。
  443. */
  444. resolveUid: true
  445. },
  446. /**
  447. * 物理护栏。界面里 `limit = 0` 的语义是"不限",
  448. * 但真跑起来没有上限等于跑到没电 —— 所以引擎自己有个时长上限。
  449. * 到点时**如实报告**,不装作抓完了。
  450. */
  451. guard: {
  452. /** 抓取阶段最长多久(不含收尾) */
  453. maxRuntimeMs: 30 * 60 * 1000,
  454. /**
  455. * 解析数字 uid 阶段**自己的**预算。
  456. *
  457. * 为什么必须独立:抓取很容易把 `maxRuntimeMs` 用满
  458. * (实测 83 个候选 × 每人约 8 秒 = 30 分钟),
  459. * 而解析 uid 是**收尾工作**,恰恰在这之后才做。
  460. * 两者共用一个预算时,抓满 = 收尾一点时间都不剩 = 一个 uid 都换不到。
  461. */
  462. resolveMaxMs: 10 * 60 * 1000
  463. }
  464. },
  465. watch: {
  466. minMs: 10000,
  467. maxMs: 35000
  468. },
  469. comments: {
  470. /* 是否执行评论动作。关掉就是「只浏览不发评论」——
  471. * 界面把「执行操作」里的评论开关映射到这里:
  472. * 用户没勾评论就不该发,哪怕评论池配好了。 */
  473. enabled: true,
  474. pool: [
  475. 'Love this!',
  476. 'So pretty',
  477. 'Great style',
  478. 'This is amazing',
  479. 'Need this look'
  480. ],
  481. avoidRepeatWithinRun: true,
  482. strictPostCheck: false,
  483. verifyInList: true
  484. },
  485. timeouts: {
  486. appLaunchMs: 25000,
  487. pageReadyMs: 15000,
  488. searchResultsMs: 20000,
  489. playerReadyMs: 20000,
  490. playbackStartMs: 12000,
  491. panelReadyMs: 8000,
  492. postMs: 15000,
  493. countRefreshMs: 6000
  494. },
  495. probe: {
  496. pollTickMs: 150,
  497. livenessIntervalMs: 2500,
  498. stallTolerance: 3,
  499. useAudio: true,
  500. usePixels: false
  501. },
  502. guard: {
  503. enabled: true,
  504. intervalMs: 1200,
  505. firstScanTimeoutMs: 4000,
  506. dismissLabels: [
  507. 'Not now', 'No thanks', 'Skip', 'Later', 'Cancel', 'Close',
  508. '稍后', '暂不', '取消', '关闭', '以后再说'
  509. ]
  510. },
  511. runtime: {
  512. configPath: DEFAULT_DEVICE_PATH,
  513. maxRuntimeMs: 0,
  514. dryRun: false
  515. }
  516. };
  517. /** 配置错误:单独类型,便于上层区分"配置写错了"和"运行时炸了" */
  518. function ConfigError(message, detail) {
  519. this.name = 'ConfigError';
  520. this.message = message;
  521. this.detail = detail;
  522. this.stack = new Error(message).stack;
  523. }
  524. ConfigError.prototype = new Error();
  525. ConfigError.prototype.constructor = ConfigError;
  526. /**
  527. * 判断自有属性。
  528. *
  529. * 这里刻意写成 `.apply(...)` 而不是更常见的「属性名 + 点 + 名字为 call 的方法」那种形式。
  530. * 原因:MCP 桥会对下发的脚本做危险调用黑名单扫描,其中一条规则按词边界匹配
  531. * 「名字为 call 的方法 + 左括号」的片段。`.apply` 语义完全等价,却不会被误判成
  532. * 危险代码、从而让每次下发都多要一次人工确认。
  533. */
  534. function owns(object, key) {
  535. return Object.prototype.hasOwnProperty.apply(object, [key]) === true;
  536. }
  537. function isPlainObject(value) {
  538. return value !== null && typeof value === 'object' && !(value instanceof Array);
  539. }
  540. /** 深拷贝(Rhino 没有 Object.assign,也不该依赖它) */
  541. function clone(value) {
  542. if (value instanceof Array) {
  543. var list = [];
  544. for (var i = 0; i < value.length; i += 1) {
  545. list.push(clone(value[i]));
  546. }
  547. return list;
  548. }
  549. if (isPlainObject(value)) {
  550. var copy = {};
  551. for (var key in value) {
  552. if (owns(value, key)) {
  553. copy[key] = clone(value[key]);
  554. }
  555. }
  556. return copy;
  557. }
  558. return value;
  559. }
  560. /**
  561. * 深合并:对象逐键递归,数组整体替换。
  562. * 数组"整体替换"是刻意的——评论池这类配置如果被逐项合并,
  563. * 用户就永远删不掉默认评论了。
  564. */
  565. function merge(base, override) {
  566. if (!isPlainObject(override)) {
  567. return clone(override);
  568. }
  569. var result = clone(base);
  570. for (var key in override) {
  571. if (!owns(override, key)) {
  572. continue;
  573. }
  574. var value = override[key];
  575. if (isPlainObject(value) && isPlainObject(result[key])) {
  576. result[key] = merge(result[key], value);
  577. } else {
  578. result[key] = clone(value);
  579. }
  580. }
  581. return result;
  582. }
  583. function readDeviceJson(path) {
  584. var logger = TikTokAuto.logger;
  585. try {
  586. if (typeof files === 'undefined' || !files.exists(path)) {
  587. logger.info('config', '未找到配置文件,使用内置默认值:' + path);
  588. return null;
  589. }
  590. var raw = files.read(path);
  591. var parsed = JSON.parse(raw);
  592. logger.info('config', '已加载配置文件:' + path);
  593. return parsed;
  594. } catch (error) {
  595. // 配置写坏了必须让用户知道,但不应因此让整脚本无法启动——
  596. // 用默认值继续跑,同时把问题显著地抛到日志里。
  597. logger.error('config', '配置文件解析失败,改用默认值:' + error);
  598. return null;
  599. }
  600. }
  601. function validate(config) {
  602. /* —— ID 提取任务:校验的是"取材清单",不是搜索关键词 ——
  603. * 早先这个函数硬性要求 target.keyword 非空,那是视频链路的前提。
  604. * ID 提取可以不搜索(从粉丝列表、文本文件、当前屏幕取材),
  605. * 所以按 task 分支,而不是给关键词塞一个假默认值把校验糊过去。 */
  606. if (config.task === 'id') {
  607. if (!(config.id && config.id.sources instanceof Array)) {
  608. throw new ConfigError('id.sources 必须是数组(取材清单)', config.id && config.id.sources);
  609. }
  610. if (config.id.sources.length === 0) {
  611. throw new ConfigError('id.sources 不能为空,至少需要一条取材', 0);
  612. }
  613. var limit = Number(config.id.output && config.id.output.limit);
  614. if (limit < 0) {
  615. throw new ConfigError('id.output.limit 不能为负数', limit);
  616. }
  617. return config;
  618. }
  619. var target = config.target;
  620. if (!target.keyword || String(target.keyword).replace(/\s/g, '') === '') {
  621. throw new ConfigError('target.keyword 不能为空', target.keyword);
  622. }
  623. /* 「评论池为空」只有在**要发评论**时才算配置错误:
  624. * 只浏览不发评论是合法的一次运行(界面上没勾评论就是这种情形)。 */
  625. if (config.comments.enabled && config.comments.pool.length === 0) {
  626. throw new ConfigError('comments.pool 不能为空,至少需要一条评论文案', 0);
  627. }
  628. if (config.watch.minMs > config.watch.maxMs) {
  629. throw new ConfigError('watch.minMs 不能大于 watch.maxMs', {
  630. minMs: config.watch.minMs,
  631. maxMs: config.watch.maxMs
  632. });
  633. }
  634. if (config.target.maxVideos < 1) {
  635. throw new ConfigError('target.maxVideos 必须 >= 1', config.target.maxVideos);
  636. }
  637. return config;
  638. }
  639. /**
  640. * 解析最终生效的配置。
  641. *
  642. * @param injected PC 侧注入的覆盖项(可为 null)
  643. */
  644. function resolve(injected) {
  645. var logger = TikTokAuto.logger;
  646. var fromDefaults = clone(DEFAULTS);
  647. var path = (injected && injected.runtime && injected.runtime.configPath) || DEFAULTS.runtime.configPath;
  648. var fromDevice = readDeviceJson(path) || {};
  649. var config = merge(merge(fromDefaults, fromDevice), injected || {});
  650. validate(config);
  651. if (config.task === 'id') {
  652. logger.info('config', '生效配置(ID提取):' + JSON.stringify({
  653. sources: config.id.sources.length,
  654. filter: config.id.filter,
  655. limit: config.id.output.limit,
  656. dedupeHistory: config.id.output.dedupeHistory,
  657. dryRun: config.id.output.dryRun
  658. }));
  659. } else {
  660. logger.info('config', '生效配置:' + JSON.stringify({
  661. keyword: config.target.keyword,
  662. maxVideos: config.target.maxVideos,
  663. resultTab: config.target.resultTab,
  664. watchMs: [config.watch.minMs, config.watch.maxMs],
  665. commentEnabled: config.comments.enabled,
  666. commentCount: config.comments.pool.length,
  667. dryRun: config.runtime.dryRun
  668. }));
  669. }
  670. return config;
  671. }
  672. return {
  673. DEFAULTS: DEFAULTS,
  674. DEFAULT_DEVICE_PATH: DEFAULT_DEVICE_PATH,
  675. readDeviceJson: readDeviceJson,
  676. merge: merge,
  677. resolve: resolve,
  678. ConfigError: ConfigError
  679. };
  680. }());
  681. /* ============================== src/wait.js ============================== */
  682. /**
  683. * wait.js — 唯一的等待原语
  684. *
  685. * 核心主张:**脚本里不允许出现裸 sleep 当作"等待"**。
  686. *
  687. * tick(轮询节拍) = "多久检查一次" → 允许存在,默认 150ms
  688. * sleep(盲等) = "赌它 X 秒后会好" → 禁止用作等待手段
  689. *
  690. * 为什么这是稳定性的关键:网络抖动时页面可能 300ms 才渲染,也可能 8s 才渲染。
  691. * 写 `sleep(2000)` 的脚本在快的时候白等 1.7s,在慢的时候直接失败;
  692. * 而 `waitFor(条件, 超时)` 在快的时候立刻返回,慢的时候一直等到条件成立。
  693. * 这把"网络有多慢"从"脚本能不能跑通"里彻底解耦。
  694. *
  695. * 依赖:TikTokAuto.logger(调用时取,避免 concat 顺序耦合)
  696. */
  697. TikTokAuto.wait = (function () {
  698. 'use strict';
  699. var DEFAULT_TICK_MS = 150;
  700. function now() {
  701. return Date.now();
  702. }
  703. /**
  704. * 轮询节拍。
  705. *
  706. * ★ 这里是**唯一**的等待出口,所以「用户点了停止」的检查放在这一处就够了 ——
  707. * 全流程(等待应用启动、等待播放、等待结果卡片、等待评论区……共几十处等待)
  708. * 都会经过 tick,于是都能被打断,业务代码一行都不用改。
  709. * bridge 未启用时是空操作(独立运行时零开销)。
  710. */
  711. function tick(ms) {
  712. if (TikTokAuto.bridge) {
  713. TikTokAuto.bridge.checkStop();
  714. }
  715. sleep(ms === undefined ? DEFAULT_TICK_MS : ms);
  716. }
  717. /**
  718. * 反复求值 predicate 直到它返回"真值"或超时。
  719. *
  720. * predicate 的返回值语义:
  721. * - null / false → 条件未满足,继续轮询
  722. * - 其它任何值(含 0!) → 条件已满足,原样返回该值
  723. * 之所以不用 `if (value)` 判断,是为了允许 predicate 合法地返回 0 / '' 这类"假值结果"。
  724. *
  725. * @returns 满足时的返回值;超时返回 null
  726. */
  727. function poll(predicate, timeoutMs, tickMs) {
  728. var deadline = now() + timeoutMs;
  729. var step = tickMs === undefined ? DEFAULT_TICK_MS : tickMs;
  730. var last = null;
  731. for (;;) {
  732. try {
  733. var value = predicate();
  734. if (value !== null && value !== false && value !== undefined) {
  735. return value;
  736. }
  737. last = value;
  738. } catch (error) {
  739. last = error;
  740. }
  741. if (now() >= deadline) {
  742. return null;
  743. }
  744. tick(Math.min(step, Math.max(1, deadline - now())));
  745. }
  746. }
  747. /**
  748. * 带日志与语义名字的 poll —— 业务代码应该用这个,而不是裸 poll。
  749. * 超时会记 WARN(而不是静默返回 null),这样"卡在哪一步"永远有据可查。
  750. */
  751. function waitFor(name, predicate, timeoutMs, tickMs) {
  752. var began = now();
  753. var value = poll(predicate, timeoutMs, tickMs);
  754. var cost = now() - began;
  755. if (value === null) {
  756. TikTokAuto.logger.warn('waitFor', name + ' 超时(等了 ' + cost + 'ms / 上限 ' + timeoutMs + 'ms)');
  757. } else {
  758. TikTokAuto.logger.info('waitFor', name + ' 就绪(' + cost + 'ms)');
  759. }
  760. return value;
  761. }
  762. /** 等待条件成立,并把成立时的值原样返回;语义等价于 waitFor */
  763. function until(condition, timeoutMs, tickMs) {
  764. return poll(condition, timeoutMs, tickMs);
  765. }
  766. /** 等待某个东西消失(例如弹窗、加载圈) */
  767. function untilGone(isPresent, timeoutMs, tickMs) {
  768. return poll(function () {
  769. return isPresent() ? null : true;
  770. }, timeoutMs, tickMs);
  771. }
  772. /**
  773. * 等待页面"安静"下来。
  774. *
  775. * 判据不是时间,而是**可见文案的拼接签名连续 N 次不变**。
  776. * 这比 sleep 可靠:加载中会不断有新节点出现,签名就会变;
  777. * 真正稳定后才返回,因此天然适配"网络慢 → 渲染慢"。
  778. *
  779. * @param sample () => string,页面签名
  780. * @param options { maxMs, tickMs, stableTimes }
  781. */
  782. function waitQuiet(sample, options) {
  783. var settings = options || {};
  784. var maxMs = settings.maxMs === undefined ? 8000 : settings.maxMs;
  785. var tickMs = settings.tickMs === undefined ? 250 : settings.tickMs;
  786. var stableTimes = settings.stableTimes === undefined ? 3 : settings.stableTimes;
  787. var deadline = now() + maxMs;
  788. var previous = null;
  789. var sameCount = 0;
  790. while (now() < deadline) {
  791. var current = sample();
  792. if (current === previous) {
  793. sameCount += 1;
  794. if (sameCount >= stableTimes) {
  795. return true;
  796. }
  797. } else {
  798. sameCount = 0;
  799. previous = current;
  800. }
  801. tick(tickMs);
  802. }
  803. return false;
  804. }
  805. return {
  806. DEFAULT_TICK_MS: DEFAULT_TICK_MS,
  807. tick: tick,
  808. poll: poll,
  809. waitFor: waitFor,
  810. until: until,
  811. untilGone: untilGone,
  812. waitQuiet: waitQuiet
  813. };
  814. }());
  815. /* ============================== src/layout.js ============================== */
  816. /**
  817. * layout.js — 对 AutoX 无障碍 API 的适配层(Boundary Adapter)
  818. *
  819. * 依据《代码整洁之道》第 8 章「边界」:
  820. * 第三方/平台 API 一定有自己的脾气(`auto.root` 是属性不是函数、
  821. * `text()` 是快照、离屏节点占了 40%+…)。把所有这些脾气**关在这一个文件里**,
  822. * 业务代码就只面对一组干净、语义明确的函数。
  823. * 平台 API 变了 → 只改这里。
  824. *
  825. * 依赖:TikTokAuto.logger、TikTokAuto.wait
  826. */
  827. TikTokAuto.layout = (function () {
  828. 'use strict';
  829. var SAFE_MARGIN = 8;
  830. function screen() {
  831. return {
  832. width: device.width,
  833. height: device.height
  834. };
  835. }
  836. /** 当前应用窗口的根节点。注意:是属性 `auto.root`,不是函数 `auto().root()` */
  837. function root() {
  838. return auto.root;
  839. }
  840. /** 前台包名。**不能**用 currentPackage()——软键盘弹出时它返回输入法包名 */
  841. function foregroundPackage() {
  842. var node = root();
  843. return node ? node.packageName() : null;
  844. }
  845. function findAll(selector) {
  846. return selector.find();
  847. }
  848. function findOnce(selector) {
  849. var collection = selector.find();
  850. return collection && collection.size() > 0 ? collection.get(0) : null;
  851. }
  852. /**
  853. * 收集整棵树的节点(含离屏)。
  854. * AutoX 的 `find()` 只从根往下找**可见**节点,拿不到离屏节点;
  855. * 而我们需要离屏信息来做"下面还有内容 → 该滚动"的判断。
  856. */
  857. function collectAll(maxNodes) {
  858. var limit = maxNodes || 1200;
  859. var result = [];
  860. var start = root();
  861. if (!start) {
  862. return result;
  863. }
  864. var stack = [start];
  865. while (stack.length > 0 && result.length < limit) {
  866. var node = stack.pop();
  867. if (!node) {
  868. continue; // 遍历过程中节点可能失效,child(i) 会返回 null
  869. }
  870. result.push(node);
  871. var count = node.childCount();
  872. for (var i = 0; i < count; i += 1) {
  873. stack.push(node.child(i));
  874. }
  875. }
  876. return result;
  877. }
  878. /** 可见性判据:优先用原生 isVisibleToUser(),退化为"与屏幕求交" */
  879. function isVisible(node) {
  880. try {
  881. if (typeof node.isVisibleToUser === 'function') {
  882. return node.isVisibleToUser();
  883. }
  884. } catch (ignored) {
  885. // 落到下面的几何判据
  886. }
  887. try {
  888. if (typeof node.visibleToUser === 'function') {
  889. return node.visibleToUser();
  890. }
  891. } catch (ignoredToo) {
  892. // 落到下面的几何判据
  893. }
  894. return visibleRect(node) !== null;
  895. }
  896. /** 与屏幕求交后的可见矩形;完全在屏幕外时返回 null */
  897. function visibleRect(node) {
  898. var box = node.bounds();
  899. var view = screen();
  900. var left = Math.max(box.left, 0);
  901. var top = Math.max(box.top, 0);
  902. var right = Math.min(box.right, view.width);
  903. var bottom = Math.min(box.bottom, view.height);
  904. if (right - left <= 0 || bottom - top <= 0) {
  905. return null;
  906. }
  907. return { left: left, top: top, right: right, bottom: bottom };
  908. }
  909. /**
  910. * 可点击坐标 = **可见交集矩形的中心**,再夹到安全区内。
  911. *
  912. * 为什么要交集而不是几何中心:被状态栏切掉一半的节点,几何中心可能落在 y=0
  913. * (点到了状态栏),交集中心才是真正能摸到的地方。
  914. */
  915. function center(node) {
  916. var rect = visibleRect(node);
  917. var view = screen();
  918. if (!rect) {
  919. return null;
  920. }
  921. var x = Math.round((rect.left + rect.right) / 2);
  922. var y = Math.round((rect.top + rect.bottom) / 2);
  923. return {
  924. x: Math.min(Math.max(x, SAFE_MARGIN), view.width - SAFE_MARGIN),
  925. y: Math.min(Math.max(y, SAFE_MARGIN), view.height - SAFE_MARGIN)
  926. };
  927. }
  928. /**
  929. * 去掉文案里的**不可见格式字符**(零宽空格、LTR/RTL 标记、软连字符等)。
  930. *
  931. * 这是真机踩出来的坑:评论列表头部的计数实际文本是 "‎150 comments",
  932. * 前面挂着一个 U+200E(LEFT-TO-RIGHT MARK)——肉眼完全看不出来,
  933. * 但 `/^\s*\d+ comments$/` 这种带锚点的正则就是匹配不上,
  934. * 于是"评论列表确认"这条最强证据永远拿不到,只能退化到弱证据。
  935. *
  936. * 放在适配层里统一处理:平台这类噪音只在这里吸掉一次,业务代码就干净了。
  937. */
  938. function sanitize(value) {
  939. if (value === null || value === undefined) {
  940. return '';
  941. }
  942. return String(value)
  943. .replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u2064\ufeff\u00ad]/g, '')
  944. .replace(/\s+/g, ' ')
  945. .trim();
  946. }
  947. function text(node) {
  948. var value = '';
  949. try {
  950. value = node.text();
  951. } catch (ignored) {
  952. value = '';
  953. }
  954. return sanitize(value);
  955. }
  956. function desc(node) {
  957. var value = '';
  958. try {
  959. value = node.desc();
  960. } catch (ignored) {
  961. value = '';
  962. }
  963. return sanitize(value);
  964. }
  965. /** 节点的"可读标签":文案优先,没有就看内容描述 */
  966. function label(node) {
  967. return text(node) || desc(node);
  968. }
  969. function resourceId(node) {
  970. try {
  971. var id = node.id();
  972. return id ? String(id) : '';
  973. } catch (ignored) {
  974. return '';
  975. }
  976. }
  977. function shortId(node) {
  978. var id = resourceId(node);
  979. var parts = id.split('/');
  980. return parts.length > 1 ? parts[parts.length - 1] : id;
  981. }
  982. function classNameOf(node) {
  983. try {
  984. return String(node.className());
  985. } catch (ignored) {
  986. return '';
  987. }
  988. }
  989. function shortClass(node) {
  990. var parts = classNameOf(node).split('.');
  991. return parts[parts.length - 1];
  992. }
  993. function isClickable(node) {
  994. try {
  995. return node.clickable() === true;
  996. } catch (ignored) {
  997. return false;
  998. }
  999. }
  1000. function isScrollable(node) {
  1001. try {
  1002. return node.scrollable() === true;
  1003. } catch (ignored) {
  1004. return false;
  1005. }
  1006. }
  1007. function isEditable(node) {
  1008. return /EditText/i.test(classNameOf(node));
  1009. }
  1010. function parent(node) {
  1011. try {
  1012. return node.parent();
  1013. } catch (ignored) {
  1014. return null;
  1015. }
  1016. }
  1017. /** 从 node 向上找到第一个可点击的祖先(不含自身) */
  1018. function clickableAncestor(node, maxDepth) {
  1019. var depth = 0;
  1020. var limit = maxDepth === undefined ? 8 : maxDepth;
  1021. var cursor = parent(node);
  1022. while (cursor && depth < limit) {
  1023. if (isClickable(cursor)) {
  1024. return cursor;
  1025. }
  1026. cursor = parent(cursor);
  1027. depth += 1;
  1028. }
  1029. return null;
  1030. }
  1031. /** 节点在屏幕上的矩形是否与给定纵向区间有明显重叠 */
  1032. function overlapsBand(node, top, bottom, minRatio) {
  1033. var rect = visibleRect(node);
  1034. if (!rect) {
  1035. return false;
  1036. }
  1037. var overlap = Math.min(rect.bottom, bottom) - Math.max(rect.top, top);
  1038. if (overlap <= 0) {
  1039. return false;
  1040. }
  1041. var ratio = minRatio === undefined ? 0.6 : minRatio;
  1042. return overlap >= (rect.bottom - rect.top) * ratio;
  1043. }
  1044. /** 扁平化渲染,用于把当前界面一行一个控件打进日志(排障时非常有用) */
  1045. function describe(nodes, limit) {
  1046. var lines = [];
  1047. var max = limit === undefined ? nodes.length : Math.min(limit, nodes.length);
  1048. for (var i = 0; i < max; i += 1) {
  1049. var node = nodes[i];
  1050. var point = center(node);
  1051. lines.push([
  1052. shortClass(node),
  1053. shortId(node),
  1054. point ? '(' + point.x + ',' + point.y + ')' : '(offscreen)',
  1055. isClickable(node) ? 'clk' : '-',
  1056. JSON.stringify(label(node)).slice(0, 48)
  1057. ].join(' '));
  1058. }
  1059. return lines.join(' | ');
  1060. }
  1061. /**
  1062. * 页面签名 = 所有可见节点文案/描述的拼接。
  1063. * 用途:判断"页面变没变""是否已经稳定",完全不依赖时间。
  1064. */
  1065. function signature() {
  1066. var nodes = collectAll(1500);
  1067. var parts = [];
  1068. for (var i = 0; i < nodes.length; i += 1) {
  1069. if (!isVisible(nodes[i])) {
  1070. continue;
  1071. }
  1072. var value = label(nodes[i]);
  1073. if (value) {
  1074. parts.push(value);
  1075. }
  1076. }
  1077. return parts.join('\u0001');
  1078. }
  1079. return {
  1080. SAFE_MARGIN: SAFE_MARGIN,
  1081. screen: screen,
  1082. root: root,
  1083. foregroundPackage: foregroundPackage,
  1084. findAll: findAll,
  1085. findOnce: findOnce,
  1086. collectAll: collectAll,
  1087. isVisible: isVisible,
  1088. visibleRect: visibleRect,
  1089. center: center,
  1090. text: text,
  1091. desc: desc,
  1092. label: label,
  1093. sanitize: sanitize,
  1094. resourceId: resourceId,
  1095. shortId: shortId,
  1096. classNameOf: classNameOf,
  1097. shortClass: shortClass,
  1098. isClickable: isClickable,
  1099. isScrollable: isScrollable,
  1100. isEditable: isEditable,
  1101. parent: parent,
  1102. clickableAncestor: clickableAncestor,
  1103. overlapsBand: overlapsBand,
  1104. describe: describe,
  1105. signature: signature
  1106. };
  1107. }());
  1108. /* ============================== src/ui.js ============================== */
  1109. /**
  1110. * ui.js — 通用交互动作
  1111. *
  1112. * 这一层只回答一个问题:"怎么把一次『点击』做成功"。
  1113. * 它不认识 TikTok,也不认识业务流程(那是 tiktok.js / flow.js 的事)——
  1114. * 这是《代码整洁之道》第 3 章「只做一件事」的落地。
  1115. *
  1116. * 点击的四级降级(都走无障碍,**不需要 Root**):
  1117. * 1. node.click()
  1118. * 2. 最近的可点击祖先 click() ← 大多数"点不动"的真凶:文案节点本身不可点
  1119. * 3. 手势 click(可见交集中心)
  1120. * 4. 手势 Tap(可见交集中心)
  1121. * 并且返回"真正点到了谁",让调用方可以核对,而不是默默点错地方。
  1122. *
  1123. * 依赖:TikTokAuto.logger、TikTokAuto.layout、TikTokAuto.wait
  1124. */
  1125. TikTokAuto.ui = (function () {
  1126. 'use strict';
  1127. function tapPoint(x, y) {
  1128. click(x, y);
  1129. }
  1130. /**
  1131. * 点击一个节点,逐级降级直到成功。
  1132. * @returns {ok, via, targetCls, targetLabel, point}
  1133. */
  1134. /* ---- 悬浮球避让(规格书 §10.9③)----
  1135. *
  1136. * 全局悬浮球浮在**所有应用之上**(包括 TikTok),所以坐标手势如果正好落在球的
  1137. * 窗口矩形里,点到的是球、不是 TikTok —— 实测就是这样:搜索建议列表的某一条
  1138. * 正好压在球位,`click(x,y)` 把球的扇形面板点了出来(用户看到的是"面板自己弹出来")。
  1139. *
  1140. * 规格书给的缓解办法是「脚本侧点击区域避开球位」,这里就做这一件事:
  1141. * ① 界面把球的窗口矩形写进 ball-rect.json(球的窗口比球大一圈,见 floatball.js);
  1142. * ② 本模块在**走坐标手势之前**读它,命中就把落点挪到同节点内、球区之外的角落;
  1143. * ③ 整块节点都在球区里(挪不开)→ 放弃这次点击并记 WARN,
  1144. * 让日志能解释"这次为什么没点成",而不是默默点到了球上。
  1145. *
  1146. * ⚠️ 注意 `node.click()` 全程不受影响:那是无障碍点击,不产生屏幕坐标,
  1147. * 压根碰不到悬浮窗 —— 所以避让只挂在手势这条兜底路径上。
  1148. */
  1149. var BALL_RECT_PATH = '/sdcard/tiktok_app/ball-rect.json';
  1150. var ballRectCache = { at: 0, rect: null };
  1151. function ballRect() {
  1152. var now = Date.now();
  1153. if (now - ballRectCache.at < 500) { return ballRectCache.rect; }
  1154. ballRectCache.at = now;
  1155. ballRectCache.rect = null;
  1156. try {
  1157. if (!files.exists(BALL_RECT_PATH)) { return null; }
  1158. var raw = JSON.parse(String(files.read(BALL_RECT_PATH)));
  1159. if (!raw || raw.hidden || raw.open) { return null; } /* 隐藏 / 扇形展开时不挡点击判断 */
  1160. if (typeof raw.x !== 'number' || typeof raw.w !== 'number') { return null; }
  1161. ballRectCache.rect = raw;
  1162. } catch (error) {
  1163. /* 读不到就当没有球:宁可偶尔点到球,也不要因为一个坏文件让整个脚本点不动 */
  1164. ballRectCache.rect = null;
  1165. }
  1166. return ballRectCache.rect;
  1167. }
  1168. function inside(rect, x, y) {
  1169. return x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h;
  1170. }
  1171. /**
  1172. * 把落点挪出球区。挪不动返回 null。
  1173. * 候选点是「节点四角内缩」:同一节点内的任何位置对这次点击都等价,
  1174. * 而角落最可能远离屏幕中部的球位。
  1175. */
  1176. function shiftOffBall(point, node, rect) {
  1177. if (!inside(rect, point.x, point.y)) { return point; }
  1178. var layout = TikTokAuto.layout;
  1179. /* 复用 layout.visibleRect:它已经处理了"可见区域/被遮挡"这些边界情况,
  1180. * 自己再写一份 node.bounds() 会与 center() 的口径不一致。 */
  1181. var b = layout.visibleRect(node);
  1182. if (!b) { return null; }
  1183. var pad = 6;
  1184. var cands = [
  1185. { x: b.left + pad, y: b.top + pad },
  1186. { x: b.right - pad, y: b.top + pad },
  1187. { x: b.left + pad, y: b.bottom - pad },
  1188. { x: b.right - pad, y: b.bottom - pad }
  1189. ];
  1190. for (var i = 0; i < cands.length; i++) {
  1191. if (cands[i].x > 0 && cands[i].y > 0 && !inside(rect, cands[i].x, cands[i].y)) {
  1192. return cands[i];
  1193. }
  1194. }
  1195. return null;
  1196. }
  1197. function clickNode(node, step) {
  1198. var story = step || 'click';
  1199. var layout = TikTokAuto.layout;
  1200. var targetClass = layout.shortClass(node);
  1201. var targetLabel = layout.label(node);
  1202. if (node.click()) {
  1203. return finish(story, true, 'node.click()', targetClass, targetLabel, layout.center(node));
  1204. }
  1205. var ancestor = layout.clickableAncestor(node);
  1206. if (ancestor && ancestor.click()) {
  1207. return finish(story, true, 'clickableAncestor.click()', targetClass, targetLabel, layout.center(ancestor));
  1208. }
  1209. var point = layout.center(node);
  1210. if (point) {
  1211. var rect = ballRect();
  1212. if (rect) {
  1213. var safe = shiftOffBall(point, node, rect);
  1214. if (!safe) {
  1215. return finish(story, false, 'blocked-by-floatball', targetClass, targetLabel, point);
  1216. }
  1217. point = safe;
  1218. }
  1219. click(point.x, point.y);
  1220. return finish(story, true, 'gesture click(visibleCenter)', targetClass, targetLabel, point);
  1221. }
  1222. return finish(story, false, 'no-clickable-target', targetClass, targetLabel, null);
  1223. }
  1224. function finish(step, ok, via, targetClass, targetLabel, point) {
  1225. var logger = TikTokAuto.logger;
  1226. var where = point ? '(' + point.x + ',' + point.y + ')' : '(n/a)';
  1227. logger.info(step, '点击 ' + (ok ? '成功' : '失败') + ' via=' + via +
  1228. ' target=' + targetClass + ' ' + JSON.stringify(targetLabel).slice(0, 40) + ' ' + where);
  1229. return {
  1230. ok: ok,
  1231. via: via,
  1232. targetClass: targetClass,
  1233. targetLabel: targetLabel,
  1234. point: point
  1235. };
  1236. }
  1237. /**
  1238. * 往一个**已知节点**写入文本,并重新查找节点回读校验。
  1239. *
  1240. * 必须回读:AutoX 的 UiObject 是 find 那一刻的快照,
  1241. * setText 之后立刻 .text() 读到的还是旧值,直接判定"写入失败"是经典误判。
  1242. */
  1243. function typeInto(node, value, step) {
  1244. var logger = TikTokAuto.logger;
  1245. var layout = TikTokAuto.layout;
  1246. var story = step || 'typeInto';
  1247. if (!node) {
  1248. logger.error(story, '输入目标为空');
  1249. return { ok: false, via: 'no-target' };
  1250. }
  1251. // 先让它真正获得焦点,否则部分输入法/控件不会提交文本
  1252. var point = layout.center(node);
  1253. if (point) {
  1254. click(point.x, point.y);
  1255. TikTokAuto.wait.tick(200);
  1256. }
  1257. node.setText(value);
  1258. TikTokAuto.wait.tick(300);
  1259. var actual = readEditableText(value);
  1260. var ok = actual === value;
  1261. if (!ok) {
  1262. logger.warn(story, '回读不一致,重试一次。期望=' + JSON.stringify(value) + ' 实际=' + JSON.stringify(actual));
  1263. node.setText(value);
  1264. TikTokAuto.wait.tick(400);
  1265. actual = readEditableText(value);
  1266. ok = actual === value;
  1267. }
  1268. logger.info(story, '写入' + (ok ? '成功' : '失败') + ' 实际=' + JSON.stringify(actual));
  1269. return { ok: ok, via: 'setText+readback', actual: actual };
  1270. }
  1271. /** 便捷版:传选择器,内部自己找节点 */
  1272. function setText(selector, value, step) {
  1273. return typeInto(TikTokAuto.layout.findOnce(selector), value, step);
  1274. }
  1275. /** 回读:取与期望值相等的输入框文本;没有就返回最长的那条(便于排查) */
  1276. function readEditableText(expected) {
  1277. var layout = TikTokAuto.layout;
  1278. var collection = className('android.widget.EditText').find();
  1279. var longest = '';
  1280. for (var i = 0; i < collection.size(); i += 1) {
  1281. var value = layout.text(collection.get(i));
  1282. if (value === expected) {
  1283. return value;
  1284. }
  1285. if (String(value).length > longest.length) {
  1286. longest = value;
  1287. }
  1288. }
  1289. return longest;
  1290. }
  1291. function isEditableText(expected) {
  1292. return readEditableText(expected) === expected;
  1293. }
  1294. /**
  1295. * 滑动。
  1296. * 注意函数名不能叫 `swipe`——那会遮蔽 AutoX 的全局 `swipe()`,导致自我递归。
  1297. */
  1298. function dragScreen(x1, y1, x2, y2, durationMs) {
  1299. swipe(x1, y1, x2, y2, durationMs === undefined ? 320 : durationMs);
  1300. }
  1301. /**
  1302. * 返回上一页。
  1303. * 注意:只在**明确的页面迁移**里调用(如 播放页 → 结果页)。
  1304. * 永远不要把它当成"兜底清理"——那会把正常页面也退掉。
  1305. */
  1306. function goBack(step) {
  1307. TikTokAuto.logger.info(step || 'back', '按下返回');
  1308. back();
  1309. }
  1310. return {
  1311. tapPoint: tapPoint,
  1312. clickNode: clickNode,
  1313. typeInto: typeInto,
  1314. setText: setText,
  1315. readEditableText: readEditableText,
  1316. isEditableText: isEditableText,
  1317. dragScreen: dragScreen,
  1318. goBack: goBack
  1319. };
  1320. }());
  1321. /* ============================== src/popups.js ============================== */
  1322. /**
  1323. * popups.js — 干扰弹窗的「检测 / 执行分离」
  1324. *
  1325. * 依据 AutoX 真机经验(也是《代码整洁之道》第 3 章的"指令查询分离"):
  1326. * - 把「看」和「动」拆成两组函数。guard 线程负责"动",
  1327. * 主流程只用只读的 popupPresent() 去**等**它清干净,绝不抢它的活。
  1328. * - **绝不 back() 正常页面**。登录页/权限页的兜底必须命中"登录方式"这类语境关键词,
  1329. * 否则在「我的」页看到"立即登录"就会把正常页面退掉。
  1330. *
  1331. * 对未知弹窗的策略是**记录而不是猜**:
  1332. * 命中已知签名 → 点掉;看起来像弹窗但不在名单里 → 只记 WARN + dump 界面,
  1333. * 由人来决定要不要加进名单。比"猜着点"安全得多。
  1334. *
  1335. * 依赖:TikTokAuto.logger、TikTokAuto.layout、TikTokAuto.ui、TikTokAuto.wait
  1336. */
  1337. TikTokAuto.popups = (function () {
  1338. 'use strict';
  1339. /**
  1340. * 已知的阻塞型弹窗签名。
  1341. * contains 里任一命中即认为出现;dismiss 决定点哪个按钮。
  1342. */
  1343. var KNOWN_DIALOGS = [
  1344. {
  1345. name: 'save-login-info',
  1346. contains: ['Save login info', 'Save your login info', '记住登录'],
  1347. dismiss: ['Not now', 'Cancel', '稍后', '取消']
  1348. },
  1349. {
  1350. name: 'notifications-permission',
  1351. contains: ['Turn on notifications', 'get notified', '打开通知'],
  1352. dismiss: ['Not now', 'No thanks', 'Skip', '稍后', '暂不']
  1353. },
  1354. {
  1355. name: 'rate-app',
  1356. contains: ['Enjoying TikTok', 'Rate this app', 'rate your experience'],
  1357. dismiss: ['Not now', 'No thanks', 'Later', '稍后']
  1358. },
  1359. {
  1360. name: 'update-app',
  1361. contains: ['Update TikTok', 'A new version'],
  1362. dismiss: ['Not now', 'Later', '稍后']
  1363. },
  1364. {
  1365. name: 'open-in-app',
  1366. contains: ['Open in app', 'Continue in app'],
  1367. dismiss: ['Cancel', 'Not now', '取消']
  1368. }
  1369. ];
  1370. /** 只读:扫描当前应用窗口,返回命中的已知弹窗名 + 疑似未知弹窗 */
  1371. function scan() {
  1372. var layout = TikTokAuto.layout;
  1373. var visibleLabels = collectVisibleLabels();
  1374. var joined = visibleLabels.join(' \u0001 ');
  1375. var hits = [];
  1376. for (var i = 0; i < KNOWN_DIALOGS.length; i += 1) {
  1377. var dialog = KNOWN_DIALOGS[i];
  1378. if (containsAny(joined, dialog.contains)) {
  1379. hits.push(dialog);
  1380. }
  1381. }
  1382. return {
  1383. known: hits,
  1384. looksBlocking: hits.length > 0 || looksLikeUnknownModal(visibleLabels)
  1385. };
  1386. }
  1387. function containsAny(haystack, needles) {
  1388. for (var i = 0; i < needles.length; i += 1) {
  1389. if (haystack.indexOf(needles[i]) !== -1) {
  1390. return true;
  1391. }
  1392. }
  1393. return false;
  1394. }
  1395. function collectVisibleLabels() {
  1396. var layout = TikTokAuto.layout;
  1397. var nodes = layout.collectAll(1200);
  1398. var labels = [];
  1399. for (var i = 0; i < nodes.length; i += 1) {
  1400. if (!layout.isVisible(nodes[i])) {
  1401. continue;
  1402. }
  1403. var value = layout.label(nodes[i]);
  1404. if (value) {
  1405. labels.push(value);
  1406. }
  1407. }
  1408. return labels;
  1409. }
  1410. /**
  1411. * "看起来像弹窗"的启发式判据(仅用于告警,不驱动动作):
  1412. * 可见文案很少(弹窗通常只有标题 + 一两个按钮),却又出现了否定的动作词。
  1413. * 正常页面(信息流/结果页)动辄几十条文案,不会命中。
  1414. */
  1415. function looksLikeUnknownModal(labels) {
  1416. var dismissLabels = TikTokAuto.config
  1417. ? TikTokAuto.config.DEFAULTS.guard.dismissLabels
  1418. : ['Not now', 'Cancel', '稍后', '取消'];
  1419. if (labels.length > 8) {
  1420. return false;
  1421. }
  1422. return containsAny(labels.join('\u0001'), dismissLabels);
  1423. }
  1424. /** 只读:是否还有阻塞弹窗(主流程用它"等"guard 清场) */
  1425. function popupPresent() {
  1426. return scan().looksBlocking;
  1427. }
  1428. /** 执行:尝试点掉一个已知弹窗。返回是否真的动了手 */
  1429. function dismissKnown(step) {
  1430. var logger = TikTokAuto.logger;
  1431. var layout = TikTokAuto.layout;
  1432. var found = scan();
  1433. if (found.known.length === 0) {
  1434. return false;
  1435. }
  1436. for (var i = 0; i < found.known.length; i += 1) {
  1437. var dialog = found.known[i];
  1438. for (var j = 0; j < dialog.dismiss.length; j += 1) {
  1439. var button = findExactLabel(dialog.dismiss[j]);
  1440. if (button) {
  1441. logger.info(step || 'popup', '关闭已知弹窗 ' + dialog.name + ' → 点击 ' + JSON.stringify(dialog.dismiss[j]));
  1442. TikTokAuto.ui.clickNode(button, step || 'popup');
  1443. TikTokAuto.wait.tick(400);
  1444. return true;
  1445. }
  1446. }
  1447. logger.warn(step || 'popup', '命中弹窗 ' + dialog.name + ' 但找不到关闭按钮,跳过');
  1448. }
  1449. return false;
  1450. }
  1451. /** 按精确文案/描述找可见节点;文案优先于描述 */
  1452. function findExactLabel(value) {
  1453. var layout = TikTokAuto.layout;
  1454. var nodes = layout.collectAll(1200);
  1455. for (var i = 0; i < nodes.length; i += 1) {
  1456. if (layout.text(nodes[i]) === value && layout.isVisible(nodes[i])) {
  1457. return nodes[i];
  1458. }
  1459. }
  1460. for (var j = 0; j < nodes.length; j += 1) {
  1461. if (layout.desc(nodes[j]) === value && layout.isVisible(nodes[j])) {
  1462. return nodes[j];
  1463. }
  1464. }
  1465. return null;
  1466. }
  1467. /**
  1468. * 启动守护线程:周期性清掉已知弹窗。
  1469. *
  1470. * 返回一个句柄,其中 passes 计数让主流程确认"它已经上岗了"——
  1471. * 否则主流程可能在守护线程第一次巡检之前就撞上弹窗。
  1472. */
  1473. function startGuard(config, logger) {
  1474. var state = { passes: 0, dismissed: 0, stopped: false };
  1475. if (!config.guard.enabled) {
  1476. logger.info('guard', '守护线程已通过配置关闭');
  1477. state.passes = 1;
  1478. return state;
  1479. }
  1480. var thread = threads.start(function () {
  1481. while (!state.stopped) {
  1482. try {
  1483. if (dismissKnown('guard')) {
  1484. state.dismissed += 1;
  1485. }
  1486. state.passes += 1;
  1487. } catch (error) {
  1488. /* 用户叫停时会从 wait.tick 抛出 StopSignal。守护线程不该继续巡检,
  1489. * 也不该每一轮都记一条 WARN 把日志刷满 —— 直接收工。 */
  1490. if (TikTokAuto.bridge && TikTokAuto.bridge.isStopSignal(error)) {
  1491. logger.info('guard', '收到停止请求,守护线程收工');
  1492. break;
  1493. }
  1494. logger.warn('guard', '巡检异常:' + error);
  1495. }
  1496. sleep(config.guard.intervalMs);
  1497. }
  1498. });
  1499. state.thread = thread;
  1500. logger.info('guard', '守护线程已启动,间隔 ' + config.guard.intervalMs + 'ms');
  1501. return state;
  1502. }
  1503. function stopGuard(state, logger) {
  1504. if (!state) {
  1505. return;
  1506. }
  1507. state.stopped = true;
  1508. logger.info('guard', '守护线程已停止,累计清场 ' + state.dismissed + ' 次');
  1509. }
  1510. return {
  1511. KNOWN_DIALOGS: KNOWN_DIALOGS,
  1512. scan: scan,
  1513. popupPresent: popupPresent,
  1514. dismissKnown: dismissKnown,
  1515. findExactLabel: findExactLabel,
  1516. startGuard: startGuard,
  1517. stopGuard: stopGuard
  1518. };
  1519. }());
  1520. /* ============================== src/tiktok.js ============================== */
  1521. /**
  1522. * tiktok.js — TikTok 的应用知识(选择器 + 页面状态判定)
  1523. *
  1524. * 这个文件是**唯一**允许出现"TikTok 具体长什么样"的地方。
  1525. * 换 App、或 TikTok 改版时,只动这里,flow.js 一行都不用改。
  1526. *
  1527. * 所有选择器都基于**真机实测**的锚点,并且在实测基础上刻意选了"语义锚点"而不是
  1528. * 混淆过的资源 id——TikTok 的 id 形如 `e4g`/`cn6`/`e8x`,每次改版都会重排;
  1529. * 而 "Read or add comments" 这类无障碍文案相当稳定。
  1530. * 少数确实只能靠 id 的地方(发送按钮)走 config.selectors 覆盖,便于不改代码修。
  1531. *
  1532. * 实测锚点(1080x1920,com.zhiliaoapp.musically):
  1533. * 信息流搜索入口 ImageView desc="Search" [968,50,1080,162]
  1534. * 搜索页输入框 EditText (top < 12%H)
  1535. * 搜索页提交 Button text="Search" (left > 80%W)
  1536. * 结果页分类页签 text/desc ∈ {Top,Users,Videos,...} y ≈ 152..232
  1537. * 结果页卡片 Button(卡片文案) width ≥ 35%W, top > 30%H
  1538. * 播放页 "Read or add comments" + "Like video." 同时存在
  1539. * 播放页评论入口 Button desc="Read or add comments. N comments"
  1540. * 播放页内联输入 EditText desc/text="Add comment..."
  1541. * 评论面板输入 EditText top ∈ [50%H,80%H]
  1542. * 面板工具行 desc="Stickers" / "Mention someone" / "Microphone"
  1543. * 面板发送按钮 工具行同高、最右、且 right ≤ W-8 的可点击 Button(实测 id=cn6)
  1544. *
  1545. * 依赖:TikTokAuto.logger、TikTokAuto.layout
  1546. */
  1547. TikTokAuto.tiktok = (function () {
  1548. 'use strict';
  1549. var PKG = 'com.zhiliaoapp.musically';
  1550. var COMMENT_DESC_KEYS = ['Read or add comments', '评论'];
  1551. var LIKE_DESC_KEYS = ['Like video.', '点赞'];
  1552. var COMMENT_PLACEHOLDER = ['Add comment...', '添加评论', 'Add comment'];
  1553. var TOOL_ROW_ANCHORS = ['Stickers', 'Mention someone', 'Microphone', '表情', '提及', '语音'];
  1554. var RESULT_TABS = ['Top', 'Users', 'Videos', 'Sounds', 'Shop', 'LIVE', 'Places', 'Photos'];
  1555. var layout = TikTokAuto.layout; // 常量引用,模块加载顺序由 build.js 保证
  1556. var logger = TikTokAuto.logger;
  1557. function screenWidth() { return device.width; }
  1558. function screenHeight() { return device.height; }
  1559. function startsWithAny(value, keys) {
  1560. for (var i = 0; i < keys.length; i += 1) {
  1561. if (value.indexOf(keys[i]) === 0) {
  1562. return true;
  1563. }
  1564. }
  1565. return false;
  1566. }
  1567. function equalsAny(value, keys) {
  1568. for (var i = 0; i < keys.length; i += 1) {
  1569. if (value === keys[i]) {
  1570. return true;
  1571. }
  1572. }
  1573. return false;
  1574. }
  1575. function widthOf(node) {
  1576. var box = node.bounds();
  1577. return box.right - box.left;
  1578. }
  1579. function topOf(node) {
  1580. return node.bounds().top;
  1581. }
  1582. function bottomOf(node) {
  1583. return node.bounds().bottom;
  1584. }
  1585. /** 按"文案或描述命中"收集可见节点 */
  1586. function findByLabel(selector, predicate) {
  1587. var result = [];
  1588. var collection = selector.find();
  1589. for (var i = 0; i < collection.size(); i += 1) {
  1590. var node = collection.get(i);
  1591. if (predicate(node) && layout.isVisible(node)) {
  1592. result.push(node);
  1593. }
  1594. }
  1595. return result;
  1596. }
  1597. // ---------------------------------------------------------------- 信息流
  1598. /** 信息流右上角的搜索入口 */
  1599. function searchEntry() {
  1600. var hits = findByLabel(className('android.widget.ImageView'), function (node) {
  1601. var point = layout.center(node);
  1602. return layout.desc(node) === 'Search' && point !== null &&
  1603. point.y < screenHeight() * 0.12 && point.x > screenWidth() * 0.85;
  1604. });
  1605. return hits.length > 0 ? hits[0] : null;
  1606. }
  1607. /** 信息流就绪判据:底部导航存在 */
  1608. function bottomNavPresent() {
  1609. return layout.findOnce(desc('Home')) !== null && layout.findOnce(desc('Profile')) !== null;
  1610. }
  1611. // ---------------------------------------------------------------- 搜索页
  1612. /** 顶部搜索输入框(排除评论输入框:后者在屏幕下方) */
  1613. function searchInput() {
  1614. var hits = findByLabel(className('android.widget.EditText'), function (node) {
  1615. return topOf(node) < screenHeight() * 0.12;
  1616. });
  1617. return hits.length > 0 ? hits[0] : null;
  1618. }
  1619. function searchSubmit() {
  1620. var hits = findByLabel(className('android.widget.Button'), function (node) {
  1621. var point = layout.center(node);
  1622. return layout.text(node) === 'Search' && point !== null &&
  1623. point.y < screenHeight() * 0.12 && point.x > screenWidth() * 0.8;
  1624. });
  1625. return hits.length > 0 ? hits[0] : null;
  1626. }
  1627. /** 搜索页特征:搜索历史行(带删除按钮)或"发现"按钮 */
  1628. function searchHistoryPresent() {
  1629. return layout.findOnce(desc('Close')) !== null || layout.findOnce(desc('Find')) !== null;
  1630. }
  1631. // ---------------------------------------------------------------- 结果页
  1632. /** 结果页顶部分类页签 */
  1633. function resultTab(name) {
  1634. var hits = findByLabel(className('android.widget.TextView'), function (node) {
  1635. return equalsAny(layout.text(node) || layout.desc(node), [name]) &&
  1636. topOf(node) > screenHeight() * 0.06 && topOf(node) < screenHeight() * 0.16;
  1637. });
  1638. if (hits.length > 0) {
  1639. return hits[0];
  1640. }
  1641. // 页签的文案节点本身常常不可点,真正可点的是外层 FrameLayout
  1642. var frames = findByLabel(className('android.widget.FrameLayout'), function (node) {
  1643. return equalsAny(layout.desc(node) || layout.text(node), [name]) &&
  1644. topOf(node) > screenHeight() * 0.06 && topOf(node) < screenHeight() * 0.16;
  1645. });
  1646. return frames.length > 0 ? frames[0] : null;
  1647. }
  1648. function resultTabStripPresent() {
  1649. var found = 0;
  1650. for (var i = 0; i < RESULT_TABS.length; i += 1) {
  1651. if (resultTab(RESULT_TABS[i])) {
  1652. found += 1;
  1653. }
  1654. }
  1655. return found >= 3;
  1656. }
  1657. /**
  1658. * 从卡片文案锚点向上找"卡片容器"。
  1659. * 为什么不能直接点文案:文案节点多数不可点,而它的可点祖先可能是**整屏容器**——
  1660. * 点下去等于点在屏幕中间,什么也不会发生(实测踩过)。
  1661. * 因此要求容器高度落在合理的卡片区间内,否则认为锚点自己就是最佳目标。
  1662. */
  1663. function cardContainer(anchor) {
  1664. var height = screenHeight();
  1665. var cursor = layout.clickableAncestor(anchor);
  1666. var depth = 0;
  1667. while (cursor && depth < 6) {
  1668. var cardHeight = bottomOf(cursor) - topOf(cursor);
  1669. if (cardHeight >= height * 0.18 && cardHeight <= height * 0.8) {
  1670. return cursor;
  1671. }
  1672. cursor = layout.clickableAncestor(cursor);
  1673. depth += 1;
  1674. }
  1675. return anchor;
  1676. }
  1677. /**
  1678. * 结果页的视频卡片列表。
  1679. *
  1680. * 判据(都来自实测踩坑):
  1681. * - 卡片文案是一条"宽"控件(≥35%W),据此把右侧竖排的点赞/评论/分享按钮排掉;
  1682. * - top > 30%H,据此排掉顶部搜索框;
  1683. * - 底部被导航栏切掉半截的先不收,避免点到一半的位置。
  1684. */
  1685. function videoCards() {
  1686. var width = screenWidth();
  1687. var height = screenHeight();
  1688. var cards = [];
  1689. var seen = {};
  1690. var hits = findByLabel(className('android.widget.Button'), function (node) {
  1691. return widthOf(node) >= width * 0.35 &&
  1692. topOf(node) > height * 0.3 &&
  1693. bottomOf(node) <= height - 12;
  1694. });
  1695. for (var i = 0; i < hits.length; i += 1) {
  1696. var caption = layout.text(hits[i]) || layout.desc(hits[i]);
  1697. if (!caption) {
  1698. continue;
  1699. }
  1700. var key = caption + '@' + topOf(hits[i]);
  1701. if (seen[key]) {
  1702. continue;
  1703. }
  1704. seen[key] = true;
  1705. cards.push({
  1706. anchor: hits[i],
  1707. container: cardContainer(hits[i]),
  1708. caption: caption,
  1709. top: topOf(hits[i])
  1710. });
  1711. }
  1712. cards.sort(function (a, b) { return a.top - b.top; });
  1713. return cards;
  1714. }
  1715. // ---------------------------------------------------------------- 播放页
  1716. function commentButton() {
  1717. var hits = findByLabel(className('android.widget.Button'), function (node) {
  1718. return startsWithAny(layout.desc(node), COMMENT_DESC_KEYS);
  1719. });
  1720. return hits.length > 0 ? hits[0] : null;
  1721. }
  1722. function likeButton() {
  1723. var hits = findByLabel(className('android.widget.Button'), function (node) {
  1724. return startsWithAny(layout.desc(node), LIKE_DESC_KEYS);
  1725. });
  1726. return hits.length > 0 ? hits[0] : null;
  1727. }
  1728. /**
  1729. * 播放页判据。
  1730. *
  1731. * 教训(实测踩到):**信息流首页也有 "Like video." 和 "Read or add comments" 这一竖排**,
  1732. * 所以只看这两个会一路把首页判成播放页,整个流程直接跑歪。
  1733. * 真正能区分"从搜索结果点进来的播放页"的是:
  1734. * - 左上角有返回箭头(首页没有)
  1735. * - 没有底部导航栏(首页有 Home/Friends/Inbox/Profile)
  1736. * 四条同时成立才算播放页。
  1737. */
  1738. function onPlayerPage() {
  1739. return commentButton() !== null &&
  1740. likeButton() !== null &&
  1741. backButton() !== null &&
  1742. !bottomNavPresent();
  1743. }
  1744. function backButton() {
  1745. var hits = findByLabel(className('android.widget.ImageView'), function (node) {
  1746. return layout.desc(node) === 'Back';
  1747. });
  1748. return hits.length > 0 ? hits[0] : null;
  1749. }
  1750. /** 从 "Read or add comments. 150 comments" 里把 150 抠出来 */
  1751. function commentCount() {
  1752. var button = commentButton();
  1753. if (!button) {
  1754. return null;
  1755. }
  1756. var matched = layout.desc(button).match(/([\d][\d,.\s]*)\s*(?:comments?|条评论|评论)/i);
  1757. if (!matched) {
  1758. return null;
  1759. }
  1760. return parseInt(String(matched[1]).replace(/[^\d]/g, ''), 10);
  1761. }
  1762. /** 视频页底部的内联评论输入框(面板未打开时就是它) */
  1763. function inlineInput() {
  1764. var hits = findByLabel(className('android.widget.EditText'), function (node) {
  1765. return equalsAny(layout.text(node) || layout.desc(node), COMMENT_PLACEHOLDER) &&
  1766. topOf(node) > screenHeight() * 0.8;
  1767. });
  1768. return hits.length > 0 ? hits[0] : null;
  1769. }
  1770. /** 评论面板里的输入框(打开面板后会出现在屏幕中部) */
  1771. function panelInput() {
  1772. var hits = findByLabel(className('android.widget.EditText'), function (node) {
  1773. var top = topOf(node);
  1774. return top > screenHeight() * 0.45 && top < screenHeight() * 0.85;
  1775. });
  1776. return hits.length > 0 ? hits[0] : null;
  1777. }
  1778. function toolRowAnchor() {
  1779. for (var i = 0; i < TOOL_ROW_ANCHORS.length; i += 1) {
  1780. var node = layout.findOnce(desc(TOOL_ROW_ANCHORS[i]));
  1781. if (node && layout.isVisible(node)) {
  1782. return node;
  1783. }
  1784. }
  1785. return null;
  1786. }
  1787. /**
  1788. * 评论发送按钮。
  1789. *
  1790. * 它没有可用的文案(实测其 content-desc 是一个无关的账号串),图标是粉色箭头,
  1791. * 所以只能靠**结构**定位:
  1792. * 面板里"工具行"(表情/提及/语音那一行)的同高区域里,
  1793. * 最靠右、且不属于右侧竖排动作栏(动作栏 right == 屏宽)的那个可点击按钮。
  1794. *
  1795. * 优先用 config.selectors.sendButtonId(若配置里给了 id),
  1796. * 这样 TikTok 改版时改配置即可,不用改代码。
  1797. */
  1798. function sendButton(config) {
  1799. var override = config && config.selectors && config.selectors.sendButtonId;
  1800. if (override) {
  1801. var byId = layout.findOnce(id(override));
  1802. if (byId && layout.isVisible(byId)) {
  1803. logger.info('comment', '发送按钮:使用配置 id ' + override);
  1804. return byId;
  1805. }
  1806. }
  1807. var anchor = toolRowAnchor();
  1808. if (!anchor) {
  1809. logger.warn('comment', '找不到面板工具行,无法结构化定位发送按钮');
  1810. return null;
  1811. }
  1812. var bandTop = topOf(anchor);
  1813. var bandBottom = bottomOf(anchor);
  1814. var width = screenWidth();
  1815. var best = null;
  1816. var hits = findByLabel(className('android.widget.Button'), function (node) {
  1817. var point = layout.center(node);
  1818. if (point === null) {
  1819. return false;
  1820. }
  1821. var box = node.bounds();
  1822. if (box.top < bandTop || box.top > bandBottom) {
  1823. return false; // 不在工具行高度带里
  1824. }
  1825. if (box.right > width - layout.SAFE_MARGIN) {
  1826. return false; // 属于右侧竖排动作栏
  1827. }
  1828. return box.left >= width * 0.5; // 必须是右半边
  1829. });
  1830. for (var i = 0; i < hits.length; i += 1) {
  1831. if (best === null || hits[i].bounds().left > best.bounds().left) {
  1832. best = hits[i];
  1833. }
  1834. }
  1835. if (best) {
  1836. logger.info('comment', '发送按钮:结构化定位 id=' +
  1837. layout.shortId(best) + ' b=' + JSON.stringify(best.bounds()));
  1838. }
  1839. return best;
  1840. }
  1841. // ---------------------------------------------------------------- 评论列表
  1842. /**
  1843. * 评论列表面板顶部的计数,形如 "150 comments"。
  1844. *
  1845. * 为什么需要它:实测发现**视频页那个评论角标不一定会立刻刷新**
  1846. * (发完评论后仍读回旧值),而评论列表头部的计数是即时的。
  1847. * 所以"计数自增"这条证据要用列表头部的那个,而不是角标的。
  1848. */
  1849. function listCommentCount() {
  1850. var hits = findByLabel(className('android.widget.TextView'), function (node) {
  1851. return /^[\d,\.]+\s*(comments?|条评论)$/i.test(layout.text(node));
  1852. });
  1853. if (hits.length === 0) {
  1854. return null;
  1855. }
  1856. return parseInt(layout.text(hits[0]).replace(/[^\d]/g, ''), 10);
  1857. }
  1858. /**
  1859. * 评论面板是否处于打开状态。
  1860. * 注意:点内联输入框打开的是"快捷面板"(有中部输入框),
  1861. * 点评论角标打开的是"列表面板"(没有中部输入框,但有 "N comments" 头部)。
  1862. * 两种都要认,否则 back() 会漏按、面板关不掉。
  1863. */
  1864. function commentPanelOpen() {
  1865. return panelInput() !== null || listCommentCount() !== null;
  1866. }
  1867. /**
  1868. * 评论列表里是否出现了某条完全一致的文案。
  1869. *
  1870. * 这里刻意**不设数量上限**:早先为了省事写成"只看前 25 条可见文本",
  1871. * 结果遍历顺序是"先视频页、后评论面板",25 条全被视频页的文案占满了,
  1872. * 评论条目根本没轮到,于是最强的那条证据永远拿不到。
  1873. * 可见 TextView 也就几百个,全扫一遍的开销远小于"判错"的代价。
  1874. */
  1875. function commentListContains(value) {
  1876. var hits = findByLabel(className('android.widget.TextView'), function (node) {
  1877. return layout.text(node) === value;
  1878. });
  1879. return hits.length > 0;
  1880. }
  1881. /** 当前屏幕上所有可见的文本文案(按屏幕顺序) */
  1882. function visibleTexts(limit) {
  1883. var hits = findByLabel(className('android.widget.TextView'), function () { return true; });
  1884. var texts = [];
  1885. for (var i = 0; i < hits.length; i += 1) {
  1886. var value = layout.text(hits[i]);
  1887. if (value && value.length >= 2) {
  1888. texts.push(value);
  1889. }
  1890. if (limit && texts.length >= limit) {
  1891. break;
  1892. }
  1893. }
  1894. return texts;
  1895. }
  1896. // ============================================================ ID 提取
  1897. /*
  1898. * 下面这一整段是 2026-10-20 在真机上逐页 dump 出来的锚点
  1899. * (Pixel 5a / 1080x1920 / TikTok 47.1.4 / 英文界面)。**每条都是实测值**,
  1900. * 不是猜的 —— 猜出来的选择器在这个项目里从来没有一次是对的。
  1901. *
  1902. * ★ 一个重要发现:这一片的**资源 id 是语义化的**(`tv_username` / `txt_desc` /
  1903. * `user_avatar`),与信息流那批混淆 id(`e4g`/`cn6`)完全不同。
  1904. * 原因是这些页面来自 TikTok 的搜索/社交业务模块,用的是另一套布局。
  1905. * 所以这里优先按 id 定位,文案只作为兜底(改版时 id 比文案更稳)。
  1906. *
  1907. * 实测到的三处文本陷阱:
  1908. * ① 用户名被 **FID 隔离符** 包裹:显示 `⁨ajman_fashion_week⁩`(U+2068/U+2069),
  1909. * 肉眼看不见,`layout.sanitize` 也不剥(那不是零宽字符)→ 统一走 `parse.username`;
  1910. * ② 搜索用户列表里 **`tv_aweme_id` 装的是昵称**(id 名违反直觉),`tv_username` 才是 uid;
  1911. * ③ 粉丝列表与搜索列表的同义控件**id 不同**:
  1912. * 搜索列表 `tv_username`/`tv_desc`,粉丝列表 `txt_desc`(uid)/`txt_user_name`(昵称)。
  1913. */
  1914. /** 是否在用户主页:有一个文案形如 "@xxx" 的可点 Button,且下面跟着三组数字 */
  1915. function profileUsernameNode() {
  1916. var hits = findByLabel(className('android.widget.Button'), function (node) {
  1917. var value = layout.text(node);
  1918. return value.charAt(0) === '@' && value.length > 1 &&
  1919. value.length < 40 && topOf(node) < screenHeight() * 0.3;
  1920. });
  1921. return hits.length > 0 ? hits[0] : null;
  1922. }
  1923. /** 用户主页的昵称(在 @xxx 上面那一行) */
  1924. function profileNicknameNode() {
  1925. var userNode = profileUsernameNode();
  1926. if (!userNode) {
  1927. return null;
  1928. }
  1929. var userTop = topOf(userNode);
  1930. var hits = findByLabel(className('android.widget.Button'), function (node) {
  1931. var box = node.bounds();
  1932. return box.top < userTop && box.bottom > userTop - screenHeight() * 0.12 &&
  1933. layout.text(node) !== '' && layout.text(node).charAt(0) !== '@';
  1934. });
  1935. return hits.length > 0 ? hits[0] : null;
  1936. }
  1937. /** 用户页顶部三组数字的标签文案。英文界面实测为这三个词。 */
  1938. var STAT_LABELS = {
  1939. 'Following': 'follow',
  1940. 'Followers': 'fans',
  1941. 'Likes': 'likes',
  1942. '关注': 'follow',
  1943. '粉丝': 'fans',
  1944. '获赞': 'likes',
  1945. 'Abonnements': 'follow',
  1946. 'Abonnés': 'fans',
  1947. "J'aime": 'likes'
  1948. };
  1949. /**
  1950. * 用户页顶部三组数字:`{ follow: 343, fans: 594, likes: 2491 }`。
  1951. *
  1952. * 结构(实测):每个数字是一个 LinearLayout,里面两个 TextView ——
  1953. * 数字在上、标签在下。所以按"标签找容器、再取容器里的数字"读。
  1954. * 注意**没有视频数**:这个页面只显示 关注/粉丝/获赞 三组,这是平台行为。
  1955. */
  1956. function statNumbers() {
  1957. var out = { follow: null, fans: null, likes: null };
  1958. var parse = TikTokAuto.parse;
  1959. var labels = findByLabel(className('android.widget.TextView'), function () { return true; });
  1960. for (var i = 0; i < labels.length; i += 1) {
  1961. var labelText = layout.text(labels[i]);
  1962. var key = STAT_LABELS[labelText];
  1963. if (!key) {
  1964. continue;
  1965. }
  1966. /* 标签必须在页面上部(三组数字区),否则可能是别处长文本里恰好含这个词 */
  1967. if (topOf(labels[i]) > screenHeight() * 0.25) {
  1968. continue;
  1969. }
  1970. var container = parentOf(labels[i]);
  1971. var value = container ? numberInside(container) : null;
  1972. if (value === null) {
  1973. value = parse.count(labelText);
  1974. }
  1975. if (out[key] === null) {
  1976. out[key] = value;
  1977. }
  1978. }
  1979. return out;
  1980. }
  1981. function parentOf(node) {
  1982. try {
  1983. return node.parent();
  1984. } catch (ignored) {
  1985. return null;
  1986. }
  1987. }
  1988. /** 在一个容器里找那个"纯数字"的 TextView,解析成整数 */
  1989. function numberInside(container) {
  1990. var parse = TikTokAuto.parse;
  1991. var found = null;
  1992. var walk = [container];
  1993. var guard = 0;
  1994. while (walk.length > 0 && guard < 30) {
  1995. guard += 1;
  1996. var node = walk.shift();
  1997. if (!node) {
  1998. continue;
  1999. }
  2000. var value = layout.text(node);
  2001. if (value !== '' && /^[\d,.\s]+[kKmMbBwW万萬亿億]?$/.test(value)) {
  2002. var parsed = parse.count(value);
  2003. if (parsed !== null && (found === null || parsed > found)) {
  2004. found = parsed;
  2005. }
  2006. }
  2007. var count = 0;
  2008. try { count = node.childCount(); } catch (ignored) { count = 0; }
  2009. for (var i = 0; i < count; i += 1) {
  2010. try { walk.push(node.child(i)); } catch (ignoredToo) { /* 遍历中失效就跳过 */ }
  2011. }
  2012. }
  2013. return found;
  2014. }
  2015. /** 用户主页的个签:页面中上部一段较长的可点文本(实测是个 Button,不是 TextView) */
  2016. function profileSignatureNode() {
  2017. var hits = findByLabel(className('android.widget.Button'), function (node) {
  2018. var value = layout.text(node);
  2019. var box = node.bounds();
  2020. return value.length >= 4 && value.charAt(0) !== '@' &&
  2021. box.top > screenHeight() * 0.22 && box.top < screenHeight() * 0.34 &&
  2022. (box.right - box.left) > screenWidth() * 0.4;
  2023. });
  2024. return hits.length > 0 ? hits[0] : null;
  2025. }
  2026. /**
  2027. * 私密账号提示。
  2028. * 实测:私密账号的主页会在视频区位置显示一行提示("This account is private" 等)。
  2029. * 这里只在**视频网格明显不存在**时才认这条,避免把正常账号的某条视频文案误判成提示。
  2030. */
  2031. function privateHint() {
  2032. var keys = ['private', "isn't available", 'no videos', 'No videos'];
  2033. var texts = visibleTexts(0);
  2034. for (var i = 0; i < texts.length; i += 1) {
  2035. for (var j = 0; j < keys.length; j += 1) {
  2036. if (String(texts[i]).toLowerCase().indexOf(String(keys[j]).toLowerCase()) >= 0) {
  2037. return texts[i];
  2038. }
  2039. }
  2040. }
  2041. return '';
  2042. }
  2043. /** 用户主页的视频网格格子数(判断"有没有内容"用,不用于视频量门槛) */
  2044. function videoGridCount() {
  2045. var hits = findByLabel(className('android.widget.FrameLayout'), function (node) {
  2046. var point = layout.center(node);
  2047. return point !== null && point.y > screenHeight() * 0.33;
  2048. });
  2049. return hits.length;
  2050. }
  2051. /** 用户主页上的「关注/粉丝」入口(点它打开列表页) */
  2052. function statEntry(which) {
  2053. var wanted = which === 'following' ? 'follow' : 'fans';
  2054. var labels = findByLabel(className('android.widget.TextView'), function () { return true; });
  2055. for (var i = 0; i < labels.length; i += 1) {
  2056. if (STAT_LABELS[layout.text(labels[i])] !== wanted) {
  2057. continue;
  2058. }
  2059. if (topOf(labels[i]) > screenHeight() * 0.25) {
  2060. continue;
  2061. }
  2062. /* 可点的是外层容器,标签自己不可点 */
  2063. return layout.clickableAncestor(labels[i]) || parentOf(labels[i]);
  2064. }
  2065. return null;
  2066. }
  2067. /**
  2068. * 粉丝/关注列表页的页签("Following 343" / "Followers 594" / "Suggested")。
  2069. *
  2070. * 实测这个列表是**从用户页点进来的一张覆盖页**,顶部有三个页签,
  2071. * 可以直接在两个页签之间切换 —— 所以"先收粉丝再收关注"不必来回退。
  2072. */
  2073. function userListTabs() {
  2074. var out = [];
  2075. var hits = findByLabel(className('android.widget.TextView'), function (node) {
  2076. var value = layout.text(node);
  2077. return /^(Following|Followers|Suggested|关注|粉丝|推荐)\b/.test(value) &&
  2078. topOf(node) < screenHeight() * 0.16;
  2079. });
  2080. for (var i = 0; i < hits.length; i += 1) {
  2081. var value = layout.text(hits[i]);
  2082. var key = value.indexOf('Follower') === 0 || value.indexOf('粉丝') === 0 ? 'fans'
  2083. : (value.indexOf('Suggested') === 0 || value.indexOf('推荐') === 0 ? 'suggested' : 'following');
  2084. out.push({
  2085. key: key,
  2086. label: value,
  2087. node: layout.clickableAncestor(hits[i]) || hits[i]
  2088. });
  2089. }
  2090. return out;
  2091. }
  2092. /** 当前列表页选中的是哪个页签(用 selected 属性判,实测可用) */
  2093. function selectedListTab() {
  2094. var tabs = userListTabs();
  2095. for (var i = 0; i < tabs.length; i += 1) {
  2096. var selected = false;
  2097. try {
  2098. selected = tabs[i].node.selected() === true || tabs[i].node.isSelected() === true;
  2099. } catch (ignored) {
  2100. selected = false;
  2101. }
  2102. if (selected) {
  2103. return tabs[i].key;
  2104. }
  2105. }
  2106. /* selected 读不到时退回文案判断:选中态的页签文案与未选中态无差异,
  2107. * 所以这里只能返回 unknown,由调用方用"点完再看内容变没变"确认。 */
  2108. return 'unknown';
  2109. }
  2110. /**
  2111. * 粉丝/关注列表里的每一行。
  2112. *
  2113. * 实测结构:`LinearLayout`(整行可点)> `TextView txt_user_name`(昵称)
  2114. * + `TextView txt_desc`(**uid**)+ `Button ubp`(Follow)。
  2115. * 注意与搜索用户列表**id 不同**(那边是 tv_username/tv_desc)。
  2116. *
  2117. * 返回的 `node` 是行容器,点它进用户页;`x` 是"安全点击的横向位置"——
  2118. * 靠左,避开右侧的 Follow 按钮(实测按钮左边界 x≈872)。
  2119. */
  2120. function userListRows() {
  2121. var parse = TikTokAuto.parse;
  2122. var rows = [];
  2123. var uidNodes = findByLabel(className('android.widget.TextView'), function (node) {
  2124. return resourceIdOf(node).indexOf('txt_desc') >= 0 && layout.text(node) !== '';
  2125. });
  2126. /* 有的版本 uid 放在 txt_user_name、昵称放在 txt_desc,所以按"哪个像 uid"再兜一层 */
  2127. if (uidNodes.length === 0) {
  2128. uidNodes = findByLabel(className('android.widget.TextView'), function (node) {
  2129. return resourceIdOf(node).indexOf('txt_user_name') >= 0 &&
  2130. parse.looksLikeUsername(layout.text(node));
  2131. });
  2132. }
  2133. for (var i = 0; i < uidNodes.length; i += 1) {
  2134. var uidNode = uidNodes[i];
  2135. var uid = parse.username(layout.text(uidNode));
  2136. if (!parse.looksLikeUsername(uid)) {
  2137. continue;
  2138. }
  2139. var row = findRowContainer(uidNode);
  2140. var box = row ? row.bounds() : uidNode.bounds();
  2141. rows.push({
  2142. uid: uid,
  2143. nickname: nicknameOf(uidNode, row),
  2144. node: row || uidNode,
  2145. top: box.top,
  2146. x: Math.round(box.left + (box.right - box.left) * 0.3),
  2147. y: Math.round((box.top + box.bottom) / 2)
  2148. });
  2149. }
  2150. rows.sort(function (a, b) { return a.top - b.top; });
  2151. return rows;
  2152. }
  2153. function resourceIdOf(node) {
  2154. return layout.resourceId(node);
  2155. }
  2156. /** 从 uid 文本往上找"整行"容器(高度落在一行的高度区间内) */
  2157. function findRowContainer(uidNode) {
  2158. var cursor = uidNode;
  2159. var depth = 0;
  2160. while (cursor && depth < 5) {
  2161. var box = cursor.bounds();
  2162. var rowHeight = box.bottom - box.top;
  2163. if (layout.isClickable(cursor) && rowHeight >= screenHeight() * 0.05 &&
  2164. rowHeight <= screenHeight() * 0.12) {
  2165. return cursor;
  2166. }
  2167. cursor = parentOf(cursor);
  2168. depth += 1;
  2169. }
  2170. return layout.clickableAncestor(uidNode) || parentOf(uidNode);
  2171. }
  2172. /** 行里的昵称:找同一容器内的另一个 TextView(不是 uid 那个) */
  2173. function nicknameOf(uidNode, row) {
  2174. if (!row) {
  2175. return '';
  2176. }
  2177. var found = '';
  2178. var walk = [row];
  2179. var guard = 0;
  2180. while (walk.length > 0 && guard < 40) {
  2181. guard += 1;
  2182. var node = walk.shift();
  2183. if (!node) {
  2184. continue;
  2185. }
  2186. var id = resourceIdOf(node);
  2187. var value = layout.text(node);
  2188. if (value !== '' && (id.indexOf('txt_user_name') >= 0 || id.indexOf('tv_aweme_id') >= 0)) {
  2189. found = value;
  2190. break;
  2191. }
  2192. var count = 0;
  2193. try { count = node.childCount(); } catch (ignored) { count = 0; }
  2194. for (var i = 0; i < count; i += 1) {
  2195. try { walk.push(node.child(i)); } catch (ignoredToo) { /* 跳过 */ }
  2196. }
  2197. }
  2198. return found;
  2199. }
  2200. /**
  2201. * 搜索结果的「用户」列表行。
  2202. *
  2203. * 实测结构:`Button`(整行可点)> `TextView tv_username`(uid)
  2204. * + `TextView tv_aweme_id`(**昵称**,id 名违反直觉)+ `TextView tv_desc`("594 followers · 2.5K likes")。
  2205. *
  2206. * 额外好处:`tv_desc` 里就有粉丝数与获赞数 —— 所以按数量筛选时
  2207. * **这一条来源不必逐个打开用户页**(其余来源没有这个信息)。
  2208. */
  2209. function usersResultRows() {
  2210. var parse = TikTokAuto.parse;
  2211. var rows = [];
  2212. var uidNodes = findByLabel(className('android.widget.TextView'), function (node) {
  2213. return resourceIdOf(node).indexOf('tv_username') >= 0 && layout.text(node) !== '';
  2214. });
  2215. for (var i = 0; i < uidNodes.length; i += 1) {
  2216. var uid = parse.username(layout.text(uidNodes[i]));
  2217. if (!parse.looksLikeUsername(uid)) {
  2218. continue;
  2219. }
  2220. var box = uidNodes[i].bounds();
  2221. var desc = '';
  2222. var nickname = '';
  2223. var rowNode = null;
  2224. /* 向上两层找行容器,再在容器里读昵称与计数 */
  2225. var cursor = uidNodes[i];
  2226. for (var up = 0; up < 4 && cursor; up += 1) {
  2227. cursor = parentOf(cursor);
  2228. if (cursor && layout.isClickable(cursor)) {
  2229. rowNode = cursor;
  2230. break;
  2231. }
  2232. }
  2233. if (rowNode) {
  2234. var children = findByLabelInside(rowNode, className('android.widget.TextView'));
  2235. for (var j = 0; j < children.length; j += 1) {
  2236. var id = resourceIdOf(children[j]);
  2237. var value = layout.text(children[j]);
  2238. if (id.indexOf('tv_aweme_id') >= 0) {
  2239. nickname = value;
  2240. } else if (id.indexOf('tv_desc') >= 0) {
  2241. desc = value;
  2242. }
  2243. }
  2244. var rowBox = rowNode.bounds();
  2245. box = { left: rowBox.left, top: rowBox.top, right: rowBox.right, bottom: rowBox.bottom };
  2246. }
  2247. rows.push({
  2248. uid: uid,
  2249. nickname: nickname,
  2250. node: rowNode || uidNodes[i],
  2251. top: box.top,
  2252. fans: parseFollowerLine(desc, 'followers'),
  2253. likes: parseFollowerLine(desc, 'likes'),
  2254. x: Math.round(box.left + (box.right - box.left) * 0.3),
  2255. y: Math.round((box.top + box.bottom) / 2)
  2256. });
  2257. }
  2258. rows.sort(function (a, b) { return a.top - b.top; });
  2259. return rows;
  2260. }
  2261. /** 在一个节点内部找符合条件的子孙(不含自身之外的范围) */
  2262. function findByLabelInside(rootNode, selector) {
  2263. var out = [];
  2264. var collection = selector.find();
  2265. for (var i = 0; i < collection.size(); i += 1) {
  2266. var node = collection.get(i);
  2267. var cursor = node;
  2268. var depth = 0;
  2269. var inside = false;
  2270. while (cursor && depth < 12) {
  2271. if (cursor === rootNode) {
  2272. inside = true;
  2273. break;
  2274. }
  2275. cursor = parentOf(cursor);
  2276. depth += 1;
  2277. }
  2278. if (inside) {
  2279. out.push(node);
  2280. }
  2281. }
  2282. return out;
  2283. }
  2284. /**
  2285. * 从 "594 followers · 2.5K likes" 里分别抠出两个数。
  2286. * 分隔符实测是 " · "(中点),但不同语言/版本可能不同,所以按**关键词**切而不是按分隔符。
  2287. */
  2288. function parseFollowerLine(text, which) {
  2289. var parse = TikTokAuto.parse;
  2290. if (!text) {
  2291. return null;
  2292. }
  2293. var parts = String(text).split(/[·•|]/);
  2294. for (var i = 0; i < parts.length; i += 1) {
  2295. var part = parts[i];
  2296. var lower = part.toLowerCase();
  2297. if (which === 'followers' && (lower.indexOf('follower') >= 0 || lower.indexOf('粉丝') >= 0)) {
  2298. return parse.count(part);
  2299. }
  2300. if (which === 'likes' && (lower.indexOf('like') >= 0 || lower.indexOf('获赞') >= 0)) {
  2301. return parse.count(part);
  2302. }
  2303. }
  2304. return null;
  2305. }
  2306. /** 播放页底部的作者 uid(实测:`Button l2m` 的 text = "@2131823375") */
  2307. function playerAuthorUid() {
  2308. var parse = TikTokAuto.parse;
  2309. var hits = findByLabel(className('android.widget.Button'), function (node) {
  2310. var value = layout.text(node);
  2311. return value.charAt(0) === '@' && parse.looksLikeUsername(value);
  2312. });
  2313. return hits.length > 0 ? parse.username(layout.text(hits[0])) : '';
  2314. }
  2315. /**
  2316. * 评论区里的每一条:**只有昵称,没有 uid**(实测 2026-10-20)。
  2317. *
  2318. * 所以"从评论区取评论者"必须逐个点头像/昵称进用户页 —— 成本比列表高一个数量级。
  2319. * 这里返回每个人可点的节点(头像 ImageView `blm`)与昵称,供上层决定怎么进。
  2320. */
  2321. function commentRowers() {
  2322. var out = [];
  2323. var avatars = findByLabel(className('android.widget.ImageView'), function (node) {
  2324. return resourceIdOf(node).indexOf('blm') >= 0;
  2325. });
  2326. for (var i = 0; i < avatars.length; i += 1) {
  2327. var box = avatars[i].bounds();
  2328. /* 头像必须在评论列表区域内(屏幕中下部),不是视频右上角的作者头像 */
  2329. if (box.top < screenHeight() * 0.38) {
  2330. continue;
  2331. }
  2332. out.push({
  2333. node: avatars[i],
  2334. top: box.top,
  2335. x: Math.round((box.left + box.right) / 2),
  2336. y: Math.round((box.top + box.bottom) / 2)
  2337. });
  2338. }
  2339. out.sort(function (a, b) { return a.top - b.top; });
  2340. return out;
  2341. }
  2342. /** 结果页的某个页签当前是否选中(Users 页签用) */
  2343. function resultTabSelected(name) {
  2344. var node = resultTab(name);
  2345. if (!node) {
  2346. return false;
  2347. }
  2348. try {
  2349. return node.selected() === true || node.isSelected() === true;
  2350. } catch (ignored) {
  2351. return false;
  2352. }
  2353. }
  2354. /**
  2355. * ID 提取这条链路自己在用的页面判定。
  2356. *
  2357. * **刻意与 pageState() 分开**:pageState 是视频链路的判据,
  2358. * 往里加分支就有可能把"播放页 vs 结果页"的老判定带歪(方案 §12 明确要求回归)。
  2359. * 新开一个函数,视频链路一行都不用动。
  2360. *
  2361. * @returns 'profile' | 'user-list' | 'users-results' | 'comment-panel' | 'search' | 'feed' | 'other'
  2362. */
  2363. function idPageState() {
  2364. if (commentPanelOpen()) {
  2365. return 'comment-panel';
  2366. }
  2367. if (profileUsernameNode() !== null) {
  2368. return 'profile';
  2369. }
  2370. if (userListTabs().length >= 2 && userListRows().length > 0) {
  2371. return 'user-list';
  2372. }
  2373. if (usersResultRows().length > 0) {
  2374. return 'users-results';
  2375. }
  2376. if (searchInput() !== null) {
  2377. return 'search';
  2378. }
  2379. if (bottomNavPresent()) {
  2380. return 'feed';
  2381. }
  2382. return 'other';
  2383. }
  2384. // ---------------------------------------------------------------- 页面状态
  2385. /**
  2386. * 判定当前处于哪个页面。
  2387. * 顺序即优先级:播放页最特征化先判,信息流最宽松最后判。
  2388. */
  2389. function pageState() {
  2390. // 评论面板要最先判:面板一盖上来,播放页的返回箭头就被遮住了,
  2391. // 于是 onPlayerPage() 会变 false,整个页面掉进 unknown 再也出不来
  2392. // (实测踩到:脚本自己打开评论列表校验后没关掉,下一轮就彻底迷路)。
  2393. if (commentPanelOpen()) {
  2394. return 'comment-panel';
  2395. }
  2396. if (onPlayerPage()) {
  2397. return 'player';
  2398. }
  2399. if (resultTabStripPresent()) {
  2400. return 'results';
  2401. }
  2402. // 搜索页判据:顶部有输入框,且**没有**结果页的页签条。
  2403. // 早先还要求存在搜索历史行,结果"历史为空 / 已被清空"时整页被判成 unknown,
  2404. // 于是 ensureFeed 一路僵住。页签条的有无已经足够区分搜索页和结果页。
  2405. if (searchInput() !== null && !resultTabStripPresent() && !bottomNavPresent()) {
  2406. return 'search';
  2407. }
  2408. if (searchEntry() !== null && bottomNavPresent()) {
  2409. return 'feed';
  2410. }
  2411. return 'unknown';
  2412. }
  2413. /** 排障用:把当前可见的关键节点打出来 */
  2414. function describeScreen(tag) {
  2415. var nodes = [];
  2416. var classes = ['android.widget.Button', 'android.widget.EditText', 'android.widget.ImageView', 'android.widget.TextView'];
  2417. for (var i = 0; i < classes.length; i += 1) {
  2418. var collection = className(classes[i]).find();
  2419. for (var j = 0; j < collection.size(); j += 1) {
  2420. if (layout.isVisible(collection.get(j))) {
  2421. nodes.push(collection.get(j));
  2422. }
  2423. }
  2424. }
  2425. logger.info(tag || 'screen', layout.describe(nodes, 40));
  2426. }
  2427. return {
  2428. PKG: PKG,
  2429. pageState: pageState,
  2430. searchEntry: searchEntry,
  2431. searchInput: searchInput,
  2432. searchSubmit: searchSubmit,
  2433. searchHistoryPresent: searchHistoryPresent,
  2434. bottomNavPresent: bottomNavPresent,
  2435. resultTab: resultTab,
  2436. resultTabStripPresent: resultTabStripPresent,
  2437. videoCards: videoCards,
  2438. commentButton: commentButton,
  2439. likeButton: likeButton,
  2440. onPlayerPage: onPlayerPage,
  2441. backButton: backButton,
  2442. commentCount: commentCount,
  2443. inlineInput: inlineInput,
  2444. panelInput: panelInput,
  2445. toolRowAnchor: toolRowAnchor,
  2446. sendButton: sendButton,
  2447. listCommentCount: listCommentCount,
  2448. commentPanelOpen: commentPanelOpen,
  2449. commentListContains: commentListContains,
  2450. visibleTexts: visibleTexts,
  2451. describeScreen: describeScreen,
  2452. /* —— ID 提取(2026-10-20 真机实测锚点,见上面的长注释) —— */
  2453. profileUsernameNode: profileUsernameNode,
  2454. profileNicknameNode: profileNicknameNode,
  2455. profileSignatureNode: profileSignatureNode,
  2456. statNumbers: statNumbers,
  2457. statEntry: statEntry,
  2458. privateHint: privateHint,
  2459. videoGridCount: videoGridCount,
  2460. userListTabs: userListTabs,
  2461. selectedListTab: selectedListTab,
  2462. userListRows: userListRows,
  2463. usersResultRows: usersResultRows,
  2464. playerAuthorUid: playerAuthorUid,
  2465. commentRowers: commentRowers,
  2466. resultTabSelected: resultTabSelected,
  2467. idPageState: idPageState
  2468. };
  2469. }());
  2470. /* ============================== src/player.js ============================== */
  2471. /**
  2472. * player.js — 「看视频」这件事:播放存活探测 + 有界观看
  2473. *
  2474. * 需求原话是"随机观看 10-35 秒,且不单靠 sleep"。
  2475. * 为什么裸 sleep 不行:sleep(20000) 只保证**墙上时钟**走了 20 秒,
  2476. * 它不关心这 20 秒里视频是否卡在加载、是否弹了窗、是否被切走。
  2477. * 网络一抖,sleep 出来的"观看"就是假的。
  2478. *
  2479. * 这里的做法:把"观看"实现成一个**带守卫的截止时间循环**——
  2480. * 1. 先等"播放真的开始"(有界等待,而不是假定它已经开始);
  2481. * 2. 观看期间持续做两件事:
  2482. * a) 校验"还在播放页"(页面身份漂移就立刻记账,连续漂移就放弃并上报);
  2483. * b) 按固定节拍采样**播放存活证据**,连续多次无证据即判定卡顿,尝试一次唤醒;
  2484. * 3. 循环里那个 sleep 是**轮询节拍**(多久检查一次),不是"等待多少秒"。
  2485. *
  2486. * 播放存活证据(按可用性排序,可配置):
  2487. * - useAudio : AudioManager.isMusicActive() —— 不依赖截图权限,实测可用
  2488. * - usePixels: captureScreen() 两点像素差 —— 最强,但 Android 14 需要 MediaProjection 授权
  2489. * 两者都不可用时,脚本**不会假装成功**:会退化为"页面守卫",并在结果里
  2490. * 明确标记 probeMode='page-guard-only',让上层知道这次的"观看"可信度较低。
  2491. *
  2492. * 依赖:TikTokAuto.logger、TikTokAuto.layout、TikTokAuto.tiktok、TikTokAuto.wait、TikTokAuto.ui
  2493. */
  2494. TikTokAuto.player = (function () {
  2495. 'use strict';
  2496. var PIXEL_SAMPLE_POINTS = [
  2497. [0.5, 0.35], [0.5, 0.5], [0.5, 0.65], [0.25, 0.45], [0.75, 0.45]
  2498. ];
  2499. // ------------------------------------------------------------------ 证据源
  2500. function createAudioProbe() {
  2501. var manager = null;
  2502. try {
  2503. if (typeof context !== 'undefined' && context) {
  2504. manager = context.getSystemService('audio');
  2505. }
  2506. } catch (error) {
  2507. manager = null;
  2508. }
  2509. if (!manager) {
  2510. return null;
  2511. }
  2512. return {
  2513. name: 'audio',
  2514. read: function () {
  2515. try {
  2516. return manager.isMusicActive() === true;
  2517. } catch (error) {
  2518. return false;
  2519. }
  2520. }
  2521. };
  2522. }
  2523. function createPixelProbe() {
  2524. var first = null;
  2525. function grab() {
  2526. var image = captureScreen();
  2527. if (!image) {
  2528. return null;
  2529. }
  2530. var width = image.getWidth();
  2531. var height = image.getHeight();
  2532. var samples = [];
  2533. for (var i = 0; i < PIXEL_SAMPLE_POINTS.length; i += 1) {
  2534. var x = Math.floor(width * PIXEL_SAMPLE_POINTS[i][0]);
  2535. var y = Math.floor(height * PIXEL_SAMPLE_POINTS[i][1]);
  2536. samples.push(image.pixel(x, y));
  2537. }
  2538. return samples.join(',');
  2539. }
  2540. try {
  2541. first = grab();
  2542. } catch (error) {
  2543. return null; // 没有 MediaProjection 授权,直接放弃这条证据源
  2544. }
  2545. if (first === null) {
  2546. return null;
  2547. }
  2548. return {
  2549. name: 'pixels',
  2550. read: function () {
  2551. try {
  2552. var now = grab();
  2553. return now !== null && now !== first;
  2554. } catch (error) {
  2555. return false;
  2556. }
  2557. }
  2558. };
  2559. }
  2560. /**
  2561. * 组装可用的探测源。
  2562. * 返回的 probeMode 会一路带到最终结果里——**不要**把降级悄悄藏起来。
  2563. */
  2564. function createProbe(config) {
  2565. var sources = [];
  2566. if (config.probe.useAudio) {
  2567. var audio = createAudioProbe();
  2568. if (audio) {
  2569. sources.push(audio);
  2570. }
  2571. }
  2572. if (config.probe.usePixels) {
  2573. var pixels = createPixelProbe();
  2574. if (pixels) {
  2575. sources.push(pixels);
  2576. }
  2577. }
  2578. return {
  2579. mode: sources.length === 0 ? 'page-guard-only' : sources.map(function (s) { return s.name; }).join('+'),
  2580. degraded: sources.length === 0,
  2581. /** 任一证据源报"在动"就算活着 */
  2582. alive: function () {
  2583. for (var i = 0; i < sources.length; i += 1) {
  2584. if (sources[i].read()) {
  2585. return true;
  2586. }
  2587. }
  2588. return false;
  2589. }
  2590. };
  2591. }
  2592. // ------------------------------------------------------------------ 等待播放
  2593. /** 等播放真正开始(而不是假定点开就在播) */
  2594. function waitPlaybackStart(config, step) {
  2595. var logger = TikTokAuto.logger;
  2596. var probe = createProbe(config);
  2597. if (probe.degraded) {
  2598. logger.warn(step, '没有可用的播放证据源(无音频/无截图权限),退化为页面守卫模式');
  2599. return { started: false, probe: probe };
  2600. }
  2601. var started = TikTokAuto.wait.until(function () {
  2602. return probe.alive() ? true : null;
  2603. }, config.timeouts.playbackStartMs, config.probe.pollTickMs);
  2604. if (started) {
  2605. logger.info(step, '检测到播放已开始(证据源 ' + probe.mode + ')');
  2606. } else {
  2607. logger.warn(step, '等待 ' + config.timeouts.playbackStartMs + 'ms 未检测到播放证据,仍继续(可能是静音视频)');
  2608. }
  2609. return { started: started === true, probe: probe };
  2610. }
  2611. // ------------------------------------------------------------------ 观看
  2612. function randomInt(min, max) {
  2613. return Math.floor(Math.random() * (max - min + 1)) + min;
  2614. }
  2615. /**
  2616. * 观看视频。
  2617. *
  2618. * @param config 生效配置
  2619. * @param options { step, onInterrupt }
  2620. * @returns 观看报告(对象,不是布尔——上层需要知道"为什么/可信度如何")
  2621. */
  2622. function watch(config, options) {
  2623. var settings = options || {};
  2624. var step = settings.step || 'watch';
  2625. var logger = TikTokAuto.logger;
  2626. var tiktok = TikTokAuto.tiktok;
  2627. var targetMs = randomInt(config.watch.minMs, config.watch.maxMs);
  2628. var start = waitPlaybackStart(config, step);
  2629. var probe = start.probe;
  2630. var beganAt = Date.now();
  2631. var deadline = beganAt + targetMs;
  2632. var tickMs = config.probe.pollTickMs;
  2633. var nextLivenessAt = beganAt + config.probe.livenessIntervalMs;
  2634. var stalls = 0;
  2635. var offPage = 0;
  2636. var maxOffPage = 3;
  2637. var nudges = 0;
  2638. var aborted = null;
  2639. logger.info(step, '开始观看,计划 ' + targetMs + 'ms(区间 ' +
  2640. config.watch.minMs + '-' + config.watch.maxMs + 'ms),证据源=' + probe.mode);
  2641. while (Date.now() < deadline) {
  2642. // (a) 页面身份守卫:不在播放页就不是"在看视频"
  2643. if (!tiktok.onPlayerPage()) {
  2644. offPage += 1;
  2645. logger.warn(step, '已离开播放页(第 ' + offPage + ' 次检测)');
  2646. if (offPage >= maxOffPage) {
  2647. aborted = 'left-player-page';
  2648. break;
  2649. }
  2650. } else {
  2651. offPage = 0;
  2652. }
  2653. // (b) 播放存活采样
  2654. if (Date.now() >= nextLivenessAt) {
  2655. nextLivenessAt = Date.now() + config.probe.livenessIntervalMs;
  2656. if (!probe.degraded) {
  2657. if (probe.alive()) {
  2658. stalls = 0;
  2659. } else {
  2660. stalls += 1;
  2661. logger.warn(step, '未检测到播放证据(连续 ' + stalls + '/' + config.probe.stallTolerance + ')');
  2662. if (stalls >= config.probe.stallTolerance) {
  2663. // 只尝试一次唤醒,不无限戳屏幕——那会把页面点出别的东西
  2664. if (nudges < 1) {
  2665. nudge(step);
  2666. nudges += 1;
  2667. stalls = 0;
  2668. } else {
  2669. logger.warn(step, '判定卡顿且唤醒无效,提前结束观看');
  2670. aborted = 'stalled';
  2671. break;
  2672. }
  2673. }
  2674. }
  2675. }
  2676. }
  2677. TikTokAuto.wait.tick(tickMs);
  2678. }
  2679. var actualMs = Date.now() - beganAt;
  2680. var report = {
  2681. requestedMs: targetMs,
  2682. actualMs: actualMs,
  2683. probeMode: probe.mode,
  2684. degraded: probe.degraded,
  2685. nudges: nudges,
  2686. aborted: aborted,
  2687. completed: aborted === null && actualMs >= targetMs - tickMs
  2688. };
  2689. if (report.completed) {
  2690. logger.info(step, '观看完成 ' + actualMs + 'ms / 目标 ' + targetMs + 'ms');
  2691. } else {
  2692. logger.warn(step, '观看未按计划完成:' + JSON.stringify(report));
  2693. }
  2694. return report;
  2695. }
  2696. /** 轻点视频区域一次,尝试恢复播放(不会点到右侧动作栏) */
  2697. function nudge(step) {
  2698. var layout = TikTokAuto.layout;
  2699. var point = { x: Math.floor(layout.screen().width * 0.5), y: Math.floor(layout.screen().height * 0.4) };
  2700. TikTokAuto.logger.info(step, '尝试轻点画面恢复播放 @(' + point.x + ',' + point.y + ')');
  2701. TikTokAuto.ui.tapPoint(point.x, point.y);
  2702. TikTokAuto.wait.tick(300);
  2703. }
  2704. /** 等播放页就绪(点开卡片之后、开始计时之前) */
  2705. function waitPlayerReady(config, step) {
  2706. var logger = TikTokAuto.logger;
  2707. var tiktok = TikTokAuto.tiktok;
  2708. var ready = TikTokAuto.wait.waitFor(step, function () {
  2709. return tiktok.onPlayerPage() ? true : null;
  2710. }, config.timeouts.playerReadyMs, config.probe.pollTickMs);
  2711. if (ready) {
  2712. TikTokAuto.wait.waitQuiet(function () {
  2713. return TikTokAuto.layout.signature();
  2714. }, { maxMs: 3000, tickMs: 250, stableTimes: 3 });
  2715. }
  2716. return ready === true;
  2717. }
  2718. return {
  2719. createProbe: createProbe,
  2720. waitPlaybackStart: waitPlaybackStart,
  2721. waitPlayerReady: waitPlayerReady,
  2722. watch: watch,
  2723. randomInt: randomInt
  2724. };
  2725. }());
  2726. /* ============================== src/comment.js ============================== */
  2727. /**
  2728. * comment.js — 评论:挑文案 → 打开面板 → 输入 → 发送 → **验证真的发出去了**
  2729. *
  2730. * 关键设计:发送后必须**验证**,而且要"多条证据同时成立"才敢说成功。
  2731. * 只看着"面板关掉了"就宣布成功,是这类脚本最典型的假阳性来源
  2732. * (面板也可能因为别的原因关掉,而评论被平台拒了)。
  2733. *
  2734. * 实测有效的两条证据:
  2735. * 1. 评论计数自增: "Read or add comments. 149 comments" → "150 comments"
  2736. * 2. 输入框被清空并恢复占位符 "Add comment...",且面板收起
  2737. * 默认以"计数自增"为强证据;计数有延迟时退回到第 2 条(可用
  2738. * comments.strictPostCheck 强制只认第 1 条)。
  2739. *
  2740. * 依赖:TikTokAuto.logger、TikTokAuto.layout、TikTokAuto.ui、TikTokAuto.wait、TikTokAuto.tiktok
  2741. */
  2742. TikTokAuto.comment = (function () {
  2743. 'use strict';
  2744. var PLACEHOLDER = 'Add comment...';
  2745. /** 从评论池里随机挑一条,尽量不重复(池子用完了就允许重复并告警) */
  2746. function pickText(config, used, step) {
  2747. var pool = config.comments.pool;
  2748. var candidates = [];
  2749. for (var i = 0; i < pool.length; i += 1) {
  2750. if (!config.comments.avoidRepeatWithinRun || used.indexOf(pool[i]) === -1) {
  2751. candidates.push(pool[i]);
  2752. }
  2753. }
  2754. if (candidates.length === 0) {
  2755. TikTokAuto.logger.warn(step || 'comment', '评论池已用尽,本轮允许重复');
  2756. candidates = pool;
  2757. }
  2758. return candidates[Math.floor(Math.random() * candidates.length)];
  2759. }
  2760. /** 打开评论面板:点内联输入框 → 等面板输入框出现 */
  2761. function openPanel(config, step) {
  2762. var story = step || 'openCommentPanel';
  2763. var logger = TikTokAuto.logger;
  2764. var tiktok = TikTokAuto.tiktok;
  2765. if (tiktok.panelInput() !== null) {
  2766. logger.info(story, '评论面板已经是打开状态');
  2767. return true;
  2768. }
  2769. var inline = tiktok.inlineInput();
  2770. if (!inline) {
  2771. logger.error(story, '找不到评论入口(内联输入框)');
  2772. return false;
  2773. }
  2774. TikTokAuto.ui.clickNode(inline, story);
  2775. var ready = TikTokAuto.wait.waitFor(story, function () {
  2776. return tiktok.panelInput() !== null ? true : null;
  2777. }, config.timeouts.panelReadyMs, config.probe.pollTickMs);
  2778. return ready === true;
  2779. }
  2780. /** 收起面板(只在确认面板处于打开状态时调用,绝不无脑 back) */
  2781. function closePanel(step) {
  2782. var tiktok = TikTokAuto.tiktok;
  2783. if (!tiktok.commentPanelOpen()) {
  2784. return false;
  2785. }
  2786. TikTokAuto.logger.info(step || 'closeCommentPanel', '收起评论面板');
  2787. back();
  2788. TikTokAuto.wait.until(function () {
  2789. return tiktok.commentPanelOpen() ? null : true;
  2790. }, 4000, 200);
  2791. return true;
  2792. }
  2793. /**
  2794. * 发送一条评论。
  2795. *
  2796. * @returns {ok, via, text, countBefore, countAfter, reason}
  2797. */
  2798. function post(config, text, step) {
  2799. var story = step || 'postComment';
  2800. var logger = TikTokAuto.logger;
  2801. var tiktok = TikTokAuto.tiktok;
  2802. var countBefore = tiktok.commentCount();
  2803. var before = { countBefore: countBefore };
  2804. if (!openPanel(config, story)) {
  2805. return { ok: false, via: 'panel-not-opened', text: text, reason: '评论面板未打开' };
  2806. }
  2807. var input = tiktok.panelInput();
  2808. var typed = TikTokAuto.ui.typeInto(input, text, story);
  2809. if (!typed.ok) {
  2810. return { ok: false, via: 'type-failed', text: text, reason: '输入框回读不一致' };
  2811. }
  2812. if (config.runtime.dryRun) {
  2813. logger.warn(story, 'dryRun 模式:已输入但**不发送**,清空并收起面板。待发文案=' + JSON.stringify(text));
  2814. var restore = tiktok.panelInput();
  2815. if (restore) {
  2816. restore.setText('');
  2817. }
  2818. TikTokAuto.wait.tick(300);
  2819. closePanel(story);
  2820. return { ok: true, via: 'dry-run', text: text, countBefore: countBefore, countAfter: countBefore, skipped: true };
  2821. }
  2822. var button = tiktok.sendButton(config);
  2823. if (!button) {
  2824. return { ok: false, via: 'send-button-not-found', text: text, reason: '定位不到发送按钮' };
  2825. }
  2826. TikTokAuto.ui.clickNode(button, story);
  2827. return awaitOutcome(config, text, before, story);
  2828. }
  2829. /**
  2830. * 等待并判定"评论是否真的发出去了",输出**带置信度**的结论。
  2831. *
  2832. * 证据分三级,从强到弱:
  2833. * 高 comment-list —— 评论列表里真的出现了这条文案(地面真相)
  2834. * 中 comment-count —— 列表头部计数自增
  2835. * 低 panel-closed —— 面板收起 + 输入框复位为占位符
  2836. *
  2837. * 为什么不能只用计数(实测踩到):视频页那个评论角标不一定会立刻刷新,
  2838. * 明明发成功却读回旧值。反过来,只看"面板关了"又可能被平台拒评骗过。
  2839. * 所以用 waitFor 逐级升级证据,并把最终置信度**如实**报给上层。
  2840. */
  2841. function awaitOutcome(config, text, before, step) {
  2842. var logger = TikTokAuto.logger;
  2843. var tiktok = TikTokAuto.tiktok;
  2844. // 必要条件:面板收起且输入框复位
  2845. var closed = TikTokAuto.wait.waitFor(step, function () {
  2846. if (tiktok.panelInput() !== null) {
  2847. return null;
  2848. }
  2849. return tiktok.inlineInput() !== null ? true : null;
  2850. }, config.timeouts.postMs, config.probe.pollTickMs);
  2851. if (closed === null) {
  2852. logger.error(step, '发送后面板未收起,疑似未发布成功');
  2853. closePanel(step);
  2854. return { ok: false, via: 'panel-still-open', text: text, reason: '发送后面板没有收起' };
  2855. }
  2856. // 中级证据:评论数自增。
  2857. // **必须"等"而不是"看一眼"**——实测角标有延迟:发布返回后立刻读还是旧值,
  2858. // 大约 1~2 秒后才 +1。看一眼就下结论的话,明明发成功了也会被判成弱证据。
  2859. var counted = TikTokAuto.wait.waitFor(step, function () {
  2860. var after = tiktok.commentCount();
  2861. if (after === null || before.countBefore === null) {
  2862. return null;
  2863. }
  2864. return after !== before.countBefore ? after : null;
  2865. }, config.timeouts.countRefreshMs, 400);
  2866. if (counted !== null) {
  2867. logger.info(step, '评论已发布(计数自增)' + before.countBefore + '→' + counted + ' ' + JSON.stringify(text));
  2868. return {
  2869. ok: true, via: 'comment-count', confidence: 'medium', text: text,
  2870. countBefore: before.countBefore, countAfter: counted
  2871. };
  2872. }
  2873. // 高级证据:去评论列表里找这条文案(地面真相)
  2874. if (config.comments.verifyInList) {
  2875. var inList = verifyInCommentList(config, text, before, step);
  2876. if (inList) {
  2877. logger.info(step, '评论已发布(列表确认 via=' + inList.via + ')内容=' +
  2878. JSON.stringify(text) + ' 计数=' + inList.count);
  2879. return {
  2880. ok: true, via: 'comment-list', confidence: 'high', text: text,
  2881. countBefore: before.countBefore, countAfter: inList.count
  2882. };
  2883. }
  2884. if (config.comments.strictPostCheck) {
  2885. logger.error(step, '严格校验开启但评论列表里找不到该文案,判定为失败');
  2886. return {
  2887. ok: false, via: 'not-found-in-list', text: text,
  2888. countBefore: before.countBefore, countAfter: tiktok.commentCount(),
  2889. reason: '评论列表里找不到刚发布的文案'
  2890. };
  2891. }
  2892. }
  2893. logger.warn(step, '评论已发布,但只有弱证据(面板收起)。计数=' +
  2894. before.countBefore + '→' + tiktok.commentCount() + ' 内容=' + JSON.stringify(text));
  2895. return {
  2896. ok: true, via: 'panel-closed-and-input-reset', confidence: 'low', text: text,
  2897. countBefore: before.countBefore, countAfter: tiktok.commentCount()
  2898. };
  2899. }
  2900. /**
  2901. * 打开评论列表,找这条评论是否在列表里。
  2902. * 返回 {count} 或 null。无论成败都会把面板收回去,不污染后续步骤。
  2903. *
  2904. * 为什么要"重试 + 等页面安静":紧跟着发布之后点评论角标,面板还在收起动画里,
  2905. * 这一下点击会被吞掉(实测:列表死活不出来,等到超时)。
  2906. * 所以每次点击前先等签名稳定,并且允许最多点三次。
  2907. */
  2908. function verifyInCommentList(config, text, before, step) {
  2909. var tiktok = TikTokAuto.tiktok;
  2910. var logger = TikTokAuto.logger;
  2911. var clickAttempts = 0;
  2912. var maxClicks = 3;
  2913. var deadline = Date.now() + config.timeouts.panelReadyMs;
  2914. while (Date.now() < deadline) {
  2915. var headerCount = tiktok.listCommentCount();
  2916. if (headerCount !== null) {
  2917. // 最强:列表里真的出现了这条文案
  2918. if (tiktok.commentListContains(text)) {
  2919. closePanel(step);
  2920. return { via: 'text-in-list', count: headerCount };
  2921. }
  2922. // 次强:列表头部计数变了(说明确实多了一条)
  2923. if (before.countBefore !== null && headerCount !== before.countBefore) {
  2924. closePanel(step);
  2925. return { via: 'list-count', count: headerCount };
  2926. }
  2927. } else if (clickAttempts < maxClicks) {
  2928. settle();
  2929. var button = tiktok.commentButton();
  2930. if (!button) {
  2931. logger.warn(step, '找不到评论角标,跳过列表校验');
  2932. return null;
  2933. }
  2934. clickAttempts += 1;
  2935. logger.info(step, '第 ' + clickAttempts + ' 次打开评论列表');
  2936. TikTokAuto.ui.clickNode(button, step);
  2937. }
  2938. TikTokAuto.wait.tick(400);
  2939. }
  2940. logger.warn(step, '评论列表校验未通过(点了 ' + clickAttempts + ' 次,列表计数=' +
  2941. tiktok.listCommentCount() + ',发布前计数=' + before.countBefore + ')');
  2942. closePanel(step);
  2943. return null;
  2944. }
  2945. /** 等页面签名稳定下来(收起动画结束),再去做下一次点击 */
  2946. function settle() {
  2947. TikTokAuto.wait.waitQuiet(function () {
  2948. return TikTokAuto.layout.signature();
  2949. }, { maxMs: 1500, tickMs: 200, stableTimes: 2 });
  2950. }
  2951. return {
  2952. PLACEHOLDER: PLACEHOLDER,
  2953. pickText: pickText,
  2954. openPanel: openPanel,
  2955. closePanel: closePanel,
  2956. post: post,
  2957. awaitOutcome: awaitOutcome,
  2958. verifyInCommentList: verifyInCommentList
  2959. };
  2960. }());
  2961. /* ============================== src/flow.js ============================== */
  2962. /**
  2963. * flow.js — 业务流程编排
  2964. *
  2965. * 这里只写"先做什么、再做什么、失败了怎么办",不写"怎么点"(ui.js)
  2966. * 也不写"TikTok 长什么样"(tiktok.js)。
  2967. * 每一段都是一次"进入某页面 → 等它真的就绪 → 做事 → 校验"的小闭环,
  2968. * 因此任何一步卡住都能从日志里立刻看出卡在哪。
  2969. *
  2970. * 收敛原则(《代码整洁之道》第 3 章):每个函数只做一件事,且**只在一个抽象层级上**。
  2971. * 所以下面几乎没有嵌套循环和条件,读起来就是需求本身。
  2972. *
  2973. * 依赖:TikTokAuto.logger / tiktok / player / comment / ui / wait / popups
  2974. */
  2975. TikTokAuto.flow = (function () {
  2976. 'use strict';
  2977. function logger() { return TikTokAuto.logger; }
  2978. // ------------------------------------------------------------ 启动与就位
  2979. function launchApp(config) {
  2980. var step = 'launch';
  2981. logger().step(step, '启动 ' + config.target.packageName);
  2982. app.launchPackage(config.target.packageName);
  2983. var ready = TikTokAuto.wait.waitFor(step, function () {
  2984. return TikTokAuto.layout.foregroundPackage() === config.target.packageName ? true : null;
  2985. }, config.timeouts.appLaunchMs, config.probe.pollTickMs);
  2986. return ready === true;
  2987. }
  2988. /**
  2989. * 保证停在信息流首页。
  2990. *
  2991. * 早先的写法有两个坑(实测踩到):
  2992. * 1. 一上来就 `waitFor(feed, 15s)` —— 明明已经在播放页,却先白等 15 秒;
  2993. * 2. 循环里最后按完 back 不回头复查,4 次退完刚好差一步,然后误判"退不回去"去重启。
  2994. * 现在改成:**先看再动**,每退一步都等页面真的换了(按签名变化判断,不是固定 sleep),
  2995. * 并且只在确认当前不是首页时才按返回——避免把正常页面退掉或退出 App。
  2996. */
  2997. function ensureFeed(config) {
  2998. var step = 'ensureFeed';
  2999. var log = logger();
  3000. var tiktok = TikTokAuto.tiktok;
  3001. var tiktokLayout = TikTokAuto.layout;
  3002. var deadline = Date.now() + 25000;
  3003. var unknownRounds = 0;
  3004. while (Date.now() < deadline) {
  3005. var state = tiktok.pageState();
  3006. if (state === 'feed') {
  3007. log.info(step, '已处于信息流首页');
  3008. return true;
  3009. }
  3010. if (state === 'unknown') {
  3011. // 页面可能还在渲染。但连续多轮都认不出来,说明有东西盖在上面
  3012. // (弹窗、半透明遮罩、异常页),这时**必须**试着按返回脱离,
  3013. // 否则就是原地等到超时、然后"重启大法"。
  3014. unknownRounds += 1;
  3015. if (unknownRounds >= 4) {
  3016. log.warn(step, '页面状态连续 ' + unknownRounds + ' 轮未知,按返回尝试脱离');
  3017. back();
  3018. TikTokAuto.wait.waitQuiet(function () {
  3019. return tiktokLayout.signature();
  3020. }, { maxMs: 2000, tickMs: 200, stableTimes: 2 });
  3021. unknownRounds = 0;
  3022. } else {
  3023. TikTokAuto.wait.tick(700);
  3024. }
  3025. continue;
  3026. }
  3027. unknownRounds = 0;
  3028. log.info(step, '当前在 ' + state + ',返回上一页');
  3029. back();
  3030. TikTokAuto.wait.waitQuiet(function () {
  3031. return tiktokLayout.signature();
  3032. }, { maxMs: 2500, tickMs: 200, stableTimes: 2 });
  3033. }
  3034. log.warn(step, '多次返回仍未到首页,重新启动应用');
  3035. launchApp(config);
  3036. return TikTokAuto.wait.waitFor(step, function () {
  3037. return tiktok.pageState() === 'feed' ? true : null;
  3038. }, config.timeouts.pageReadyMs, config.probe.pollTickMs) === true;
  3039. }
  3040. // ------------------------------------------------------------ 搜索
  3041. function openSearchPage(config) {
  3042. var step = 'openSearch';
  3043. var entry = TikTokAuto.tiktok.searchEntry();
  3044. if (!entry) {
  3045. logger().error(step, '首页找不到搜索入口');
  3046. return false;
  3047. }
  3048. TikTokAuto.ui.clickNode(entry, step);
  3049. return TikTokAuto.wait.waitFor(step, function () {
  3050. if (TikTokAuto.tiktok.pageState() === 'search') {
  3051. return true;
  3052. }
  3053. // 结果页也算"搜索这条链走通了",直接复用
  3054. return TikTokAuto.tiktok.pageState() === 'results' ? true : null;
  3055. }, config.timeouts.pageReadyMs, config.probe.pollTickMs) === true;
  3056. }
  3057. function submitKeyword(config) {
  3058. var step = 'submitKeyword';
  3059. var tiktok = TikTokAuto.tiktok;
  3060. if (tiktok.pageState() === 'results') {
  3061. logger().info(step, '已经在结果页,跳过输入');
  3062. return true;
  3063. }
  3064. var input = tiktok.searchInput();
  3065. if (!input) {
  3066. logger().error(step, '找不到搜索输入框');
  3067. return false;
  3068. }
  3069. TikTokAuto.ui.clickNode(input, step);
  3070. TikTokAuto.wait.tick(250);
  3071. var typed = TikTokAuto.ui.typeInto(tiktok.searchInput() || input, config.target.keyword, step);
  3072. if (!typed.ok) {
  3073. return false;
  3074. }
  3075. var submit = tiktok.searchSubmit();
  3076. if (!submit) {
  3077. logger().error(step, '找不到搜索提交按钮');
  3078. return false;
  3079. }
  3080. TikTokAuto.ui.clickNode(submit, step);
  3081. return TikTokAuto.wait.waitFor(step, function () {
  3082. return tiktok.pageState() === 'results' ? true : null;
  3083. }, config.timeouts.searchResultsMs, config.probe.pollTickMs) === true;
  3084. }
  3085. /** 等结果网格真的渲染出卡片(而不是页面框架出来了就算完) */
  3086. function waitForCards(config) {
  3087. var step = 'waitForCards';
  3088. return TikTokAuto.wait.waitFor(step, function () {
  3089. var cards = TikTokAuto.tiktok.videoCards();
  3090. return cards.length > 0 ? cards : null;
  3091. }, config.timeouts.searchResultsMs, config.probe.pollTickMs);
  3092. }
  3093. function selectResultTab(config) {
  3094. var step = 'selectResultTab';
  3095. var name = config.target.resultTab;
  3096. if (!name) {
  3097. return false;
  3098. }
  3099. var tab = TikTokAuto.tiktok.resultTab(name);
  3100. if (!tab) {
  3101. logger().warn(step, '找不到页签 ' + name + ',沿用当前页签');
  3102. return false;
  3103. }
  3104. logger().info(step, '切换到页签 ' + name);
  3105. TikTokAuto.ui.clickNode(tab, step);
  3106. TikTokAuto.wait.waitQuiet(function () {
  3107. return TikTokAuto.layout.signature();
  3108. }, { maxMs: 4000, tickMs: 250, stableTimes: 3 });
  3109. return true;
  3110. }
  3111. /** 结果页滚动加载更多卡片 */
  3112. function scrollResults(config) {
  3113. var layout = TikTokAuto.layout;
  3114. var screen = layout.screen();
  3115. logger().info('scrollResults', '结果页下拉加载更多');
  3116. TikTokAuto.ui.dragScreen(
  3117. Math.floor(screen.width * 0.25), Math.floor(screen.height * 0.8),
  3118. Math.floor(screen.width * 0.25), Math.floor(screen.height * 0.35),
  3119. 400
  3120. );
  3121. TikTokAuto.wait.waitQuiet(function () {
  3122. return TikTokAuto.layout.signature();
  3123. }, { maxMs: 4000, tickMs: 250, stableTimes: 3 });
  3124. }
  3125. // ------------------------------------------------------------ 选片与观看
  3126. function chooseCard(config, cards, visited) {
  3127. var fresh = [];
  3128. for (var i = 0; i < cards.length; i += 1) {
  3129. if (visited.indexOf(cards[i].caption) === -1) {
  3130. fresh.push(cards[i]);
  3131. }
  3132. }
  3133. var pool = fresh.length > 0 ? fresh : cards;
  3134. if (config.target.pickStrategy === 'first') {
  3135. return pool[0];
  3136. }
  3137. return pool[Math.floor(Math.random() * pool.length)];
  3138. }
  3139. function openCard(config, card) {
  3140. var step = 'openCard';
  3141. logger().step(step, '打开视频:' + JSON.stringify(String(card.caption).slice(0, 40)));
  3142. TikTokAuto.ui.clickNode(card.container, step);
  3143. return TikTokAuto.player.waitPlayerReady(config, step);
  3144. }
  3145. /**
  3146. * 从播放页回到结果页。
  3147. *
  3148. * 必须允许连按多次:校验评论时会把评论列表打开,此时状态是 comment-panel,
  3149. * 一次 back 只够收起面板,得再退一次才真正回到结果页。
  3150. */
  3151. function returnToResults(config) {
  3152. var step = 'returnToResults';
  3153. var log = logger();
  3154. var tiktok = TikTokAuto.tiktok;
  3155. var tiktokLayout = TikTokAuto.layout;
  3156. var deadline = Date.now() + config.timeouts.pageReadyMs;
  3157. var backs = 0;
  3158. while (Date.now() < deadline && backs < 5) {
  3159. var state = tiktok.pageState();
  3160. if (state === 'results') {
  3161. return true;
  3162. }
  3163. if (state === 'unknown') {
  3164. TikTokAuto.wait.tick(500);
  3165. continue;
  3166. }
  3167. backs += 1;
  3168. log.info(step, '当前在 ' + state + ',返回上一页');
  3169. back();
  3170. TikTokAuto.wait.waitQuiet(function () {
  3171. return tiktokLayout.signature();
  3172. }, { maxMs: 2500, tickMs: 200, stableTimes: 2 });
  3173. }
  3174. log.warn(step, '未能回到结果页,跳过后续取片');
  3175. return false;
  3176. }
  3177. // ------------------------------------------------------------ 主流程
  3178. /**
  3179. * 上报进度给界面宿主(未接宿主时是空操作,见 bridge.js)。
  3180. * 只在这里包一层是为了让下面的流程读起来仍是"业务语言",
  3181. * 不必到处重复 status:'running' 这种传输细节。
  3182. */
  3183. function progress(phase, done, total) {
  3184. TikTokAuto.bridge.report({
  3185. status: 'running',
  3186. phase: phase,
  3187. done: done,
  3188. total: total
  3189. });
  3190. }
  3191. /**
  3192. * 跑一轮完整会话:启动 → 搜索 → 逐个视频「看 → 评」。
  3193. * @returns 结构化结果,会被 main.js 打印成一行 RESULT 供 PC 侧断言
  3194. */
  3195. function runSession(config) {
  3196. var summary = {
  3197. ok: false,
  3198. keyword: config.target.keyword,
  3199. requestedVideos: config.target.maxVideos,
  3200. attemptedVideos: 0,
  3201. postedComments: 0,
  3202. details: [],
  3203. errors: []
  3204. };
  3205. var total = config.target.maxVideos;
  3206. progress('\u542f\u52a8\u5e94\u7528', 0, total);
  3207. if (!launchApp(config)) {
  3208. summary.errors.push('启动应用失败');
  3209. return summary;
  3210. }
  3211. progress('\u8fdb\u5165\u4fe1\u606f\u6d41', 0, total);
  3212. if (!ensureFeed(config)) {
  3213. summary.errors.push('未能进入信息流首页');
  3214. return summary;
  3215. }
  3216. progress('\u641c\u7d22 ' + config.target.keyword, 0, total);
  3217. if (!openSearchPage(config) || !submitKeyword(config)) {
  3218. summary.errors.push('搜索流程失败');
  3219. return summary;
  3220. }
  3221. if (!waitForCards(config)) {
  3222. summary.errors.push('搜索结果里没有可用卡片');
  3223. return summary;
  3224. }
  3225. selectResultTab(config);
  3226. var visited = [];
  3227. var usedComments = [];
  3228. var guard = 0;
  3229. while (summary.attemptedVideos < config.target.maxVideos && guard < config.target.maxVideos * 3) {
  3230. guard += 1;
  3231. progress('\u6b63\u5728\u5904\u7406\u7b2c ' + (summary.attemptedVideos + 1) + '/' + total + ' \u4e2a\u89c6\u9891',
  3232. summary.attemptedVideos, total);
  3233. var cards = TikTokAuto.tiktok.videoCards();
  3234. if (cards.length === 0) {
  3235. scrollResults(config);
  3236. cards = TikTokAuto.tiktok.videoCards();
  3237. if (cards.length === 0) {
  3238. summary.errors.push('结果页已无更多卡片');
  3239. break;
  3240. }
  3241. }
  3242. var card = chooseCard(config, cards, visited);
  3243. visited.push(card.caption);
  3244. if (!openCard(config, card)) {
  3245. summary.errors.push('打开视频失败:' + String(card.caption).slice(0, 30));
  3246. returnToResults(config);
  3247. continue;
  3248. }
  3249. var detail = { caption: String(card.caption).slice(0, 60) };
  3250. detail.watch = TikTokAuto.player.watch(config, { step: 'watch' });
  3251. /* 没勾「评论」时只浏览:这不是失败,是一种合法的运行模式
  3252. * (界面上的「执行操作」没勾评论就是这个意思)。 */
  3253. if (config.comments.enabled) {
  3254. var text = TikTokAuto.comment.pickText(config, usedComments, 'comment');
  3255. detail.comment = TikTokAuto.comment.post(config, text, 'comment');
  3256. if (detail.comment.ok && !detail.comment.skipped) {
  3257. usedComments.push(text);
  3258. summary.postedComments += 1;
  3259. }
  3260. } else {
  3261. detail.comment = { ok: true, skipped: true, reason: '本次未启用评论动作' };
  3262. }
  3263. summary.attemptedVideos += 1;
  3264. summary.details.push(detail);
  3265. progress('\u5df2\u5b8c\u6210 ' + summary.attemptedVideos + '/' + total,
  3266. summary.attemptedVideos, total);
  3267. returnToResults(config);
  3268. }
  3269. summary.ok = summary.attemptedVideos > 0;
  3270. return summary;
  3271. }
  3272. return {
  3273. launchApp: launchApp,
  3274. ensureFeed: ensureFeed,
  3275. openSearchPage: openSearchPage,
  3276. submitKeyword: submitKeyword,
  3277. waitForCards: waitForCards,
  3278. selectResultTab: selectResultTab,
  3279. scrollResults: scrollResults,
  3280. chooseCard: chooseCard,
  3281. openCard: openCard,
  3282. returnToResults: returnToResults,
  3283. runSession: runSession
  3284. };
  3285. }());
  3286. /* ============================== src/parse.js ============================== */
  3287. /**
  3288. * parse.js — 文本 → 数值 / 语言 / 区间的**纯函数**解析
  3289. *
  3290. * ## 为什么单独一个模块
  3291. * 这三件事都是「界面上的设置真的会拦人」这一主张的证据链:
  3292. * · 数量门槛要先把 "1.6M" 变成 1600000 才谈得上比较;
  3293. * · 语言区域要靠昵称/个签里的字符构成来判;
  3294. * · 区间判定要能表达"不限"和"读不到"这两种不同的"没有值"。
  3295. * 它们全部与界面无关,所以能 100% 离线自检 —— 这是整个 ID 提取里
  3296. * **唯一一处可以用断言证明"逻辑对了"** 的部分。
  3297. *
  3298. * ## 关于数字缩写(真机实测)
  3299. * TikTok 的计数一律是缩写形态,实测到的写法:
  3300. * "594"、"2,491"、"1.6M"、"253.9M"、"245K"、"371.7K"
  3301. * 中文界面下会是 "1.2万" / "1.5亿"。所以后缀要同时认 K/M/B 与 万/亿。
  3302. *
  3303. * 依赖:无(纯函数,不取任何模块引用)
  3304. */
  3305. TikTokAuto.parse = (function () {
  3306. 'use strict';
  3307. /* ---------------------------------------------------------- 数字 */
  3308. /** 后缀倍数。键统一小写;`二`的两种写法(万/萬)都认。 */
  3309. var SUFFIX = {
  3310. '': 1,
  3311. 'k': 1000,
  3312. 'm': 1000000,
  3313. 'b': 1000000000,
  3314. 'w': 10000, /* 万:中文界面 "1.2万" */
  3315. '万': 10000,
  3316. '萬': 10000,
  3317. '亿': 100000000,
  3318. '億': 100000000
  3319. };
  3320. /**
  3321. * 把一个计数字符串解析成整数。
  3322. *
  3323. * 返回 **null** 表示"读不到",与 **0** 严格区分:
  3324. * "0 followers" 是真实的值(一个粉丝都没有),"读不到"是另一回事 ——
  3325. * 数量门槛的判定要靠这个区别决定是"拦下"还是"放行"。
  3326. *
  3327. * @param raw 原始文本,如 "1.6M followers" / "2,491" / "1.2万"
  3328. * @returns {number|null}
  3329. */
  3330. function count(raw) {
  3331. if (raw === null || raw === undefined) {
  3332. return null;
  3333. }
  3334. /* 统一全角数字与全角逗号:某些语言/输入法会给出全角 */
  3335. var text = String(raw)
  3336. .replace(/[0-9]/g, function (ch) {
  3337. return String.fromCharCode(ch.charCodeAt(0) - 0xFEE0);
  3338. })
  3339. .replace(/[,]/g, ',');
  3340. /* 抓「数字 + 可选小数 + 可选后缀」。前面的自由文本("1.6M followers")不影响。
  3341. * 后缀只认白名单里的字符,避免把 "594 followers" 的 f 当成后缀。 */
  3342. var matched = text.match(/(\d[\d,]*(?:\.\d+)?)\s*([kKmMbBwW万萬亿億]?)/);
  3343. if (!matched) {
  3344. return null;
  3345. }
  3346. var number = Number(String(matched[1]).replace(/,/g, ''));
  3347. if (isNaN(number)) {
  3348. return null;
  3349. }
  3350. var suffix = String(matched[2] || '').toLowerCase();
  3351. var factor = SUFFIX[suffix];
  3352. if (factor === undefined) {
  3353. /* 后缀不在白名单(例如 "594 followers" 的 f 已不会被捕获)→ 按无后缀处理 */
  3354. factor = 1;
  3355. }
  3356. return Math.round(number * factor);
  3357. }
  3358. /* ---------------------------------------------------------- 语言 */
  3359. /** CJK 汉字(含扩展 A 与兼容区)与假名 —— 判定"中文内容"用 */
  3360. var CJK = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\u3040-\u30ff]/;
  3361. var LATIN = /[A-Za-z\u00c0-\u024f]/; /* 含带音标的拉丁字母(西/法/德用户昵称很常见) */
  3362. /**
  3363. * 估算一段文本的语种。只返回三类,够用且不猜。
  3364. *
  3365. * 判定规则(不是拍脑袋,理由在下面):
  3366. * · **只统计字母类字符**,忽略数字/标点/emoji —— 昵称里 emoji 占比极高,
  3367. * 算进分母会把"中文昵称 + 三个 emoji"判成"非中文"。
  3368. * · 中文门槛 **30%**:中文用户常混英文单词("小美的Fashion"),
  3369. * 门槛设成 50% 会把这类人漏掉。
  3370. * · 英文门槛 **70%**:反过来,英文用户极少混中文,
  3371. * 所以"仅英文"要更严格 —— 否则一个 "Fashion 小美" 会同时进两个桶。
  3372. *
  3373. * @returns {string} "zh" | "en" | "mixed" | "none"
  3374. */
  3375. function language(raw) {
  3376. var text = String(raw === null || raw === undefined ? '' : raw);
  3377. if (text === '') {
  3378. return 'none';
  3379. }
  3380. var cjk = 0;
  3381. var latin = 0;
  3382. for (var i = 0; i < text.length; i += 1) {
  3383. var ch = text.charAt(i);
  3384. if (CJK.test(ch)) {
  3385. cjk += 1;
  3386. } else if (LATIN.test(ch)) {
  3387. latin += 1;
  3388. }
  3389. }
  3390. var total = cjk + latin;
  3391. if (total === 0) {
  3392. return 'none'; /* 纯 emoji / 纯数字 / 阿拉伯文等 */
  3393. }
  3394. var zhRatio = cjk / total;
  3395. var enRatio = latin / total;
  3396. if (zhRatio >= 0.3) {
  3397. return 'zh';
  3398. }
  3399. if (enRatio >= 0.7) {
  3400. return 'en';
  3401. }
  3402. return 'mixed';
  3403. }
  3404. /**
  3405. * 语言是否匹配"仅中文 / 仅英文 / 通杀"。
  3406. *
  3407. * `mixed` 在两边都不算匹配 —— 说不清是哪一边的,就不该被任何一边选中。
  3408. * 但 `none`(纯 emoji 昵称)在"通杀"下通过:通杀本来就不筛。
  3409. */
  3410. function languageMatches(want, actual) {
  3411. if (!want || want === 'any') {
  3412. return true;
  3413. }
  3414. if (want === 'zh') {
  3415. return actual === 'zh';
  3416. }
  3417. if (want === 'en') {
  3418. return actual === 'en';
  3419. }
  3420. return true;
  3421. }
  3422. /* ---------------------------------------------------------- 区间 */
  3423. /**
  3424. * 区间判定。
  3425. *
  3426. * 三种状态必须分开表达,这是这个函数存在的理由:
  3427. * · **不限**(min/max 都是 0)→ 永远通过,连值都不用读;
  3428. * · **有值** → 落在 [min, max] 内(0 表示该侧不设限);
  3429. * · **读不到**(value === null)→ **不通过**。
  3430. *
  3431. * 为什么读不到要拦:用户明确设了门槛,说明他在意这个条件;
  3432. * 读不到就无法证明符合,宁可少收并在日志里说清楚 ——
  3433. * 若这里放行,"过滤"就是形同虚设,而用户以为它生效了。
  3434. *
  3435. * @returns {boolean}
  3436. */
  3437. function inRange(value, min, max) {
  3438. var low = Number(min) || 0;
  3439. var high = Number(max) || 0;
  3440. if (low <= 0 && high <= 0) {
  3441. return true;
  3442. }
  3443. if (value === null || value === undefined) {
  3444. return false;
  3445. }
  3446. var number = Number(value);
  3447. if (isNaN(number)) {
  3448. return false;
  3449. }
  3450. if (low > 0 && number < low) {
  3451. return false;
  3452. }
  3453. if (high > 0 && number > high) {
  3454. return false;
  3455. }
  3456. return true;
  3457. }
  3458. /** 该区间是否"什么都没设"(用来判断要不要为此打开用户页) */
  3459. function isRangeOpen(min, max) {
  3460. return (Number(min) || 0) <= 0 && (Number(max) || 0) <= 0;
  3461. }
  3462. /* ---------------------------------------------------------- 用户名 */
  3463. /**
  3464. * 把 "@username" 归一成 "username"。
  3465. *
  3466. * 实测发现用户名文本被 **FID 隔离符** 包裹(U+2068 / U+2069):
  3467. * 显示为 `⁨ajman_fashion_week⁩`,肉眼完全看不出,但直接当文件名/比对键就会出错。
  3468. * `layout.sanitize` 处理了 U+200b-200f 等,**没有**处理 2068/2069(它们不是零宽字符,
  3469. * 是双向文本隔离符),所以这里单独剥一次。
  3470. *
  3471. * 同时剥掉全角 @ 与首尾空白。
  3472. */
  3473. function username(raw) {
  3474. if (raw === null || raw === undefined) {
  3475. return '';
  3476. }
  3477. return String(raw)
  3478. .replace(/[\u2066-\u2069\u202a-\u202e]/g, '')
  3479. .replace(/\s+/g, '')
  3480. .replace(/^[@@]+/, '');
  3481. }
  3482. /**
  3483. * 一个字符串是否像 TikTok 用户名(字母数字下划线点,长度 2~30)。
  3484. *
  3485. * 注意**不能**先走 username() —— 那个函数会去掉所有空白(清洗用途),
  3486. * 于是 "This is a caption" 会被压成 "Thisisacaption" 而"看起来像用户名"。
  3487. * 判定必须**保留原始空白**:真正的用户名里不会有空格。
  3488. */
  3489. function looksLikeUsername(raw) {
  3490. if (raw === null || raw === undefined) {
  3491. return false;
  3492. }
  3493. var name = String(raw)
  3494. .replace(/[\u2066-\u2069\u202a-\u202e]/g, '')
  3495. .replace(/^[\s@@]+|[\s]+$/g, '');
  3496. if (name.length < 2 || name.length > 30) {
  3497. return false;
  3498. }
  3499. if (/\s/.test(name)) {
  3500. return false; /* 中间有空白 → 是文案,不是用户名 */
  3501. }
  3502. return /^[A-Za-z0-9_.]+$/.test(name);
  3503. }
  3504. /* ---------------------------------------------------------- 文件名 */
  3505. /** 两位补零 */
  3506. function pad2(value) {
  3507. return value < 10 ? '0' + value : String(value);
  3508. }
  3509. /**
  3510. * 结果文件名:`前缀_YYYYMMDD_HHmm.txt`
  3511. *
  3512. * 方案 §6.1 是对规格书的有意识偏离:规格书只有日期(`uid_20261013.txt`),
  3513. * 同一天跑两次会互相覆盖 —— 而"同一天调两次参数"恰恰是最常见的用法。
  3514. */
  3515. function timestampName(prefix, date) {
  3516. var d = date || new Date();
  3517. return prefix + '_' + d.getFullYear() + pad2(d.getMonth() + 1) + pad2(d.getDate()) +
  3518. '_' + pad2(d.getHours()) + pad2(d.getMinutes()) + '.txt';
  3519. }
  3520. return {
  3521. count: count,
  3522. language: language,
  3523. languageMatches: languageMatches,
  3524. inRange: inRange,
  3525. isRangeOpen: isRangeOpen,
  3526. username: username,
  3527. looksLikeUsername: looksLikeUsername,
  3528. timestampName: timestampName
  3529. };
  3530. }());
  3531. /* ============================== src/uidset.js ============================== */
  3532. /**
  3533. * uidset.js — 去重集合与结果文件
  3534. *
  3535. * ## 三种"已经见过"
  3536. * 1. **本轮已收**:这次运行里已经写进产物文件的;
  3537. * 2. **历史已收**:之前各轮跑过的(`uid_extracted.txt`,只追加不删除);
  3538. * 3. **输入里已有**:文本取材那份文件本身就列着的("裂变"时,"输入里的人"不该再算新收获)。
  3539. * 三者合成一个集合,`add` 之后就不再重复。区别只在**装载来源**,判定逻辑是同一个。
  3540. *
  3541. * ## 为什么落盘是"追加"而不是"最后一次性写"
  3542. * 用户点了停止、或脚本被系统杀掉时,已经抓到的部分不能全丢。
  3543. * 所以每收 N 个就 append 一次 —— 代价是几条多余的系统调用,收益是"停在哪都留着"。
  3544. *
  3545. * ## 为什么不用 files 的删除接口
  3546. * 引擎产物要过 MCP 的危险调用黑名单(`build.js` 的 `DANGEROUS_RULES` 里有一条按
  3547. * 词边界匹配 `remove` 加左括号),命中后每次下发都要求人工确认。
  3548. * 所以这里**只追加不删除** —— 需要"重来"时由用户自己在文件管理器里删。
  3549. *
  3550. * 依赖:TikTokAuto.logger(调用时取)
  3551. */
  3552. TikTokAuto.uidset = (function () {
  3553. 'use strict';
  3554. /** 每收这么多个就 append 一次。20 是"丢掉的最多几个"与"写盘频率"的折中。 */
  3555. var FLUSH_EVERY = 20;
  3556. /**
  3557. * 去重集合。
  3558. *
  3559. * 内部用普通对象而不是 Set:Rhino 对 ES6 Set 的支持在各版本不一致,
  3560. * 而对象字面量的键查找在所有版本上都是稳的。uid 只会是字母数字下划线点,不会撞 `__proto__`。
  3561. */
  3562. function create() {
  3563. var seen = {};
  3564. var count = 0;
  3565. return {
  3566. /** 见过没有 */
  3567. has: function (uid) {
  3568. return seen[uid] === true;
  3569. },
  3570. /** 记一笔;返回 true 表示这是新加的(之前没见过) */
  3571. add: function (uid) {
  3572. if (seen[uid] === true) {
  3573. return false;
  3574. }
  3575. seen[uid] = true;
  3576. count += 1;
  3577. return true;
  3578. },
  3579. /** 已记多少(含历史装载的) */
  3580. size: function () {
  3581. return count;
  3582. }
  3583. };
  3584. }
  3585. /**
  3586. * 读一批文本行,返回去重后的 uid 数组。
  3587. * 跳过空行、`#` 注释行、以及带 @ 的行(有些用户会直接粘贴 "@name",我们容忍这种输入)。
  3588. */
  3589. function readUidFile(path) {
  3590. var out = [];
  3591. try {
  3592. if (!path || !files.exists(path)) {
  3593. return out;
  3594. }
  3595. var lines = String(files.read(path)).split(/\r?\n/);
  3596. for (var i = 0; i < lines.length; i += 1) {
  3597. var uid = TikTokAuto.parse.username(lines[i]);
  3598. if (uid !== '' && uid.charAt(0) !== '#') {
  3599. out.push(uid);
  3600. }
  3601. }
  3602. } catch (error) {
  3603. TikTokAuto.logger.warn('uidset', '读取 ' + path + ' 失败:' + error);
  3604. }
  3605. return out;
  3606. }
  3607. /**
  3608. * 从历史文件装载去重集合。
  3609. * 文件不存在 = 第一次跑,不是错误。
  3610. */
  3611. function loadHistory(set, path) {
  3612. var list = readUidFile(path);
  3613. for (var i = 0; i < list.length; i += 1) {
  3614. set.add(list[i]);
  3615. }
  3616. TikTokAuto.logger.info('uidset', '历史去重装载 ' + list.length + ' 条(' + path + ')');
  3617. return list.length;
  3618. }
  3619. /**
  3620. * 结果写入器。
  3621. *
  3622. * 两个文件同时写:
  3623. * · `本次`:这一轮新收的(新建,给人看/给下游吃);
  3624. * · `累计`:所有轮次的(追加,跨轮去重的基准)。
  3625. *
  3626. * @param options { runPath, historyPath, dryRun, historyEnabled }
  3627. */
  3628. function createWriter(options) {
  3629. var pending = [];
  3630. var collected = [];
  3631. var written = 0;
  3632. var flushFailed = false;
  3633. function flush() {
  3634. if (pending.length === 0) {
  3635. return;
  3636. }
  3637. var block = pending.join('\n') + '\n';
  3638. pending = [];
  3639. if (options.dryRun) {
  3640. return; /* 演练模式:不碰任何文件 */
  3641. }
  3642. try {
  3643. files.append(options.runPath, block);
  3644. if (options.historyEnabled) {
  3645. files.append(options.historyPath, block);
  3646. }
  3647. } catch (error) {
  3648. /* 写盘失败要留痕但不能中断 —— 用户更需要"继续抓"而不是"因为一个文件写不进去全停"。
  3649. * 只报一次,避免几百条一模一样的警告把日志刷满。 */
  3650. if (!flushFailed) {
  3651. flushFailed = true;
  3652. TikTokAuto.logger.error('uidset', '写结果文件失败:' + error);
  3653. }
  3654. }
  3655. }
  3656. return {
  3657. /** 收一个 uid。调用方保证已经过去重。 */
  3658. write: function (uid) {
  3659. pending.push(uid);
  3660. /* 同时留一份在内存里:收尾阶段要拿它去换数字 uid(见 resolve.js)。
  3661. * 从文件读一遍也行,但那样得处理"文件还没 flush 完"的时序问题,
  3662. * 而这里的量级(几十到几千)远没到需要省内存的地步。 */
  3663. collected.push(uid);
  3664. written += 1;
  3665. if (pending.length >= FLUSH_EVERY) {
  3666. flush();
  3667. }
  3668. },
  3669. /** 本轮收到的全部 uid(按收取顺序) */
  3670. list: function () {
  3671. return collected.slice(0);
  3672. },
  3673. /** 已写多少个 */
  3674. count: function () {
  3675. return written;
  3676. },
  3677. /** 收尾:把剩下的刷出去。**收到停止信号时也必须调它**,否则"停了但文件是空的"。 */
  3678. close: function () {
  3679. flush();
  3680. return written;
  3681. },
  3682. /** 是否发生过写盘失败(给 summary 用) */
  3683. hadFailure: function () {
  3684. return flushFailed;
  3685. }
  3686. };
  3687. }
  3688. /** 确保目录存在(结果文件与累计文件同目录) */
  3689. function ensureDir(dir) {
  3690. try {
  3691. files.ensureDir(dir + '/');
  3692. return true;
  3693. } catch (error) {
  3694. TikTokAuto.logger.error('uidset', '创建目录失败 ' + dir + ':' + error);
  3695. return false;
  3696. }
  3697. }
  3698. return {
  3699. FLUSH_EVERY: FLUSH_EVERY,
  3700. create: create,
  3701. readUidFile: readUidFile,
  3702. loadHistory: loadHistory,
  3703. createWriter: createWriter,
  3704. ensureDir: ensureDir
  3705. };
  3706. }());
  3707. /* ============================== src/filters.js ============================== */
  3708. /**
  3709. * filters.js — 「只要这样的账号」的判定
  3710. *
  3711. * 这一层是**纯函数**:给它一份采集到的资料 + 一份过滤设置,它只回答"要 / 不要 / 为什么"。
  3712. * 不点屏幕、不读写文件、不知道 TikTok 长什么样。所以它能被 100% 离线自检,
  3713. * 而"界面上的 21 项设置真的会拦人"这件事也就有了证据。
  3714. *
  3715. * ## 一条重要的区分:**数据缺失**不等于**不符合**
  3716. * 三种"没有值"必须分开处理,否则过滤要么形同虚设,要么乱杀:
  3717. *
  3718. * ① 用户没设这个门槛 → 放行(设置里是 0,语义就是"不限")
  3719. * ② 设了,但平台根本不提供 → **放行 + 记一条 warn**(例如"视频量":真机实测用户页
  3720. * 顶部只有 关注/粉丝/获赞 三组数字,没有视频数)
  3721. * ③ 设了,本应能读到却读不到 → **拦下**(读不到就无法证明符合,宁可少收)
  3722. *
  3723. * ②和③看起来像,但责任不同:②是我们做不到,不该惩罚用户抓到的人;③是数据异常,
  3724. * 放行会让过滤失去意义而用户以为它生效了。
  3725. *
  3726. * 依赖:TikTokAuto.parse、TikTokAuto.logger(调用时取)
  3727. */
  3728. TikTokAuto.filters = (function () {
  3729. 'use strict';
  3730. /**
  3731. * 这份过滤设置是否需要打开用户页才能判。
  3732. *
  3733. * 用途是**快路径**:所有过滤都关着时(最常见的第一种用法),
  3734. * 直接从列表页把 uid 抄下来就行 —— 不必为每个人开一次用户页。
  3735. * 实测一次用户页打开约 3~5 秒,100 个人就是 5~8 分钟,差一个数量级。
  3736. *
  3737. * 注意 `videos` 不参与这个判断:它读不到(见文件头 ②),
  3738. * 为此打开用户页也只是白花时间。
  3739. */
  3740. function needsProfile(filter) {
  3741. var f = filter || {};
  3742. if (f.private === true || f.noAvatar === true) {
  3743. return true;
  3744. }
  3745. if (f.langRegion && f.langRegion !== 'any') {
  3746. return true;
  3747. }
  3748. var groups = ['follow', 'fans', 'likes'];
  3749. for (var i = 0; i < groups.length; i += 1) {
  3750. var range = f[groups[i]] || {};
  3751. if (!TikTokAuto.parse.isRangeOpen(range.min, range.max)) {
  3752. return true;
  3753. }
  3754. }
  3755. return false;
  3756. }
  3757. /** 取该判定的取样文本:昵称、个签,或两者(昵称检测下拉的三个取值)。 */
  3758. function sampleText(profile, mode) {
  3759. var nick = profile.nickname || '';
  3760. var sign = profile.signature || '';
  3761. if (mode === 'nick') {
  3762. return nick;
  3763. }
  3764. if (mode === 'sign') {
  3765. return sign;
  3766. }
  3767. /* "通杀":两段合起来判 —— 分开判会让"昵称中文、个签全英文"的人两边都不算 */
  3768. return nick + ' ' + sign;
  3769. }
  3770. /**
  3771. * 单条判定的结果。
  3772. * `soft` 表示"我们做不到,放行但要告知"(对应文件头的 ②)。
  3773. */
  3774. function pass() {
  3775. return { ok: true, reason: '', soft: '' };
  3776. }
  3777. function reject(reason) {
  3778. return { ok: false, reason: reason, soft: '' };
  3779. }
  3780. function softPass(reason) {
  3781. return { ok: true, reason: '', soft: reason };
  3782. }
  3783. /**
  3784. * 判定一个账号要不要。
  3785. *
  3786. * @param profile 采集到的资料;`null` 表示"这次没有打开用户页"(快路径)
  3787. * —— 此时只有不看资料就能判的项目生效,其余全部放行
  3788. * @param filter 过滤设置
  3789. * @returns { ok, reason, soft }
  3790. * reason 是"为什么不要"(人话,会进日志)
  3791. * soft 是"放行了但你应该知道"(进 summary 的 warnings,只报一次)
  3792. */
  3793. function judge(profile, filter) {
  3794. var f = filter || {};
  3795. var parse = TikTokAuto.parse;
  3796. if (!profile) {
  3797. /* 没有资料,两种情况必须分开:
  3798. * · 这份设置**本来就不需要**资料(快路径)→ 放行;
  3799. * · 需要资料却拿不到(用户页打不开、读不出)→ **拦下**。
  3800. * 第二种如果放行,等于"打不开的人全都通过" —— 过滤看着在跑,
  3801. * 实际放进来一批根本没检查过的账号,比不过滤更糟(用户以为筛过了)。 */
  3802. if (needsProfile(f)) {
  3803. return reject('打不开用户页,读不到任何资料');
  3804. }
  3805. return pass();
  3806. }
  3807. /* ① 私密账户。列表页读不到,只能打开用户页看提示文案。 */
  3808. if (f.private === true && profile.isPrivate === true) {
  3809. return reject('私密账号');
  3810. }
  3811. /* ② 无头像。真机实测头像节点不可读(无文案、不可点),
  3812. * 只能靠像素法 —— 见 profile.js 里的说明。读不到时放行并告知。 */
  3813. if (f.noAvatar === true) {
  3814. if (profile.hasAvatar === false) {
  3815. return reject('无头像');
  3816. }
  3817. if (profile.hasAvatar === null) {
  3818. return {
  3819. ok: true,
  3820. reason: '',
  3821. soft: '无头像检测:头像读取不可用,本次未生效'
  3822. };
  3823. }
  3824. }
  3825. /* ③ 语言区域:按昵称/个签的字符构成判(见 parse.language 的规则说明) */
  3826. if (f.langRegion && f.langRegion !== 'any') {
  3827. var text = sampleText(profile, f.nicknameCheck || 'any');
  3828. var lang = parse.language(text);
  3829. if (lang === 'none') {
  3830. /* 纯 emoji / 纯数字昵称:没有语言线索。用户明确要筛语言,就不该混进来。 */
  3831. return reject('看不出语言(昵称与个签都没有文字)');
  3832. }
  3833. if (!parse.languageMatches(f.langRegion, lang)) {
  3834. return reject('语言不符:' + lang + ' ≠ ' + f.langRegion);
  3835. }
  3836. }
  3837. /* ④ 三组数量门槛。真机实测用户页顶部是 关注 / 粉丝 / 获赞,
  3838. * 数字形态是缩写("1.6M"),parse.count 负责解析。 */
  3839. var checks = [
  3840. { key: 'follow', label: '关注量' },
  3841. { key: 'fans', label: '粉丝量' },
  3842. { key: 'likes', label: '获赞量' }
  3843. ];
  3844. for (var i = 0; i < checks.length; i += 1) {
  3845. var one = checks[i];
  3846. var range = f[one.key] || {};
  3847. if (parse.isRangeOpen(range.min, range.max)) {
  3848. continue;
  3849. }
  3850. if (!parse.inRange(profile[one.key], range.min, range.max)) {
  3851. var actual = profile[one.key];
  3852. return reject(one.label + '不在区间内(' +
  3853. (actual === null || actual === undefined ? '读不到' : actual) + ')');
  3854. }
  3855. }
  3856. /* ⑤ 视频量:平台不提供(实测),放行 + 告知。见文件头 ②。 */
  3857. var videoRange = f.videos || {};
  3858. if (!parse.isRangeOpen(videoRange.min, videoRange.max)) {
  3859. return softPass('视频量:用户页不显示该数字,本次未参与过滤');
  3860. }
  3861. return pass();
  3862. }
  3863. /** 给人看的一句话:这次到底在筛什么(进日志与界面状态条) */
  3864. function describe(filter) {
  3865. var f = filter || {};
  3866. var parts = [];
  3867. if (f.private === true) { parts.push('排除私密'); }
  3868. if (f.noAvatar === true) { parts.push('排除无头像'); }
  3869. if (f.langRegion && f.langRegion !== 'any') {
  3870. parts.push(f.langRegion === 'zh' ? '仅中文' : '仅英文');
  3871. }
  3872. var labels = { follow: '关注', fans: '粉丝', likes: '获赞' };
  3873. var keys = ['follow', 'fans', 'likes'];
  3874. for (var i = 0; i < keys.length; i += 1) {
  3875. var range = f[keys[i]] || {};
  3876. if (TikTokAuto.parse.isRangeOpen(range.min, range.max)) {
  3877. continue;
  3878. }
  3879. parts.push(labels[keys[i]] + ' ' + (range.min || 0) + '~' + (range.max || '∞'));
  3880. }
  3881. return parts.length === 0 ? '不过滤(拿到就收)' : parts.join(',');
  3882. }
  3883. return {
  3884. needsProfile: needsProfile,
  3885. judge: judge,
  3886. describe: describe
  3887. };
  3888. }());
  3889. /* ============================== src/profile.js ============================== */
  3890. /**
  3891. * profile.js — 在 TikTok 里"走到某个账号的主页并读出来"
  3892. *
  3893. * ## 这一层管什么
  3894. * 只管**导航**与**读取**:打开搜索、进用户主页、点开粉丝列表、翻一屏、退回去。
  3895. * 它不判断"要不要这个人"(那是 filters.js),也不管"从哪来"(idsource.js)。
  3896. *
  3897. * ## 真机实测出来的三条导航事实(2026-10-20 / TikTok 47.1.4)
  3898. * 1. **用户主页 → 粉丝列表是一张覆盖页**,顶部有三个页签(Following / Followers / Suggested),
  3899. * 可以直接在页签间切换 —— 所以"先收粉丝再收关注"不必退回主页再进。
  3900. * 2. **从列表进用户页再返回,列表位置会保持**(实测首行与坐标都不变)。
  3901. * 这条决定了翻页策略:可以"读一屏 → 逐个进 → 退回来接着读下一屏",
  3902. * 不必先把整屏 uid 存起来再统一处理。
  3903. * 3. **列表行里就有 uid**(`txt_desc`),不必为了拿 uid 去开用户页。
  3904. * 用户页只在**需要更多资料**(数量、语言、私密、头像)时才开 —— 差一个数量级的时间。
  3905. *
  3906. * ## 头像为什么要截屏
  3907. * 头像是 ImageView,**没有 text、没有 desc、不可点**,无障碍树里读不到任何线索。
  3908. * 实测对比:默认头像(灰色人形剪影)高饱和像素占比 0.035,真头像 0.472 ——
  3909. * 分离度足够,所以用像素法。**已知局限:纯黑白照片的头像会被误判成"无头像"**,
  3910. * 这一点会如实写进 README 与界面说明,不假装没有。
  3911. *
  3912. * 依赖:TikTokAuto.layout / tiktok / ui / wait / logger / parse
  3913. */
  3914. TikTokAuto.profile = (function () {
  3915. 'use strict';
  3916. /** 头像采样网格:5x5。25 个点足够区分剪影与照片,又不会让截图开销变成瓶颈。 */
  3917. var AVATAR_GRID = 5;
  3918. /** 高饱和像素占比超过它就认为"有头像"。实测默认头像 0.035 / 真头像 0.472。 */
  3919. var AVATAR_SATURATED_RATIO = 0.12;
  3920. function logger() { return TikTokAuto.logger; }
  3921. /** 等页面安静下来(签名连续几次不变),比固定 sleep 可靠 */
  3922. function settle(maxMs) {
  3923. TikTokAuto.wait.waitQuiet(function () {
  3924. return TikTokAuto.layout.signature();
  3925. }, { maxMs: maxMs || 2500, tickMs: 200, stableTimes: 2 });
  3926. }
  3927. // ------------------------------------------------------------ 读取主页
  3928. /**
  3929. * 把当前用户主页读成一份资料。
  3930. *
  3931. * 读不到的字段一律给 **null**(而不是 0 或空串):
  3932. * "读不到"与"就是 0"在过滤判定里含义完全不同(见 filters.js 文件头)。
  3933. *
  3934. * @returns { uid, nickname, signature, follow, fans, likes, isPrivate, hasAvatar, gridCount }
  3935. */
  3936. function readProfile() {
  3937. var tiktok = TikTokAuto.tiktok;
  3938. var parse = TikTokAuto.parse;
  3939. var userNode = tiktok.profileUsernameNode();
  3940. var uid = userNode ? parse.username(TikTokAuto.layout.text(userNode)) : '';
  3941. var nicknameNode = tiktok.profileNicknameNode();
  3942. var signatureNode = tiktok.profileSignatureNode();
  3943. var stats = readStatsStable();
  3944. var hint = tiktok.privateHint();
  3945. var grid = tiktok.videoGridCount();
  3946. return {
  3947. uid: uid,
  3948. nickname: nicknameNode ? TikTokAuto.layout.text(nicknameNode) : '',
  3949. signature: signatureNode ? TikTokAuto.layout.text(signatureNode) : '',
  3950. follow: stats.follow,
  3951. fans: stats.fans,
  3952. likes: stats.likes,
  3953. /* 私密:读到了提示文案才算私密;读不到就是"看起来是公开的"。
  3954. * 反过来判("没读到就是私密")会把所有正常账号都排除掉。 */
  3955. isPrivate: hint !== '' && grid < 3,
  3956. privateHint: hint,
  3957. /* 头像:null = 判定不可用(截屏没授权 / 找不到头像节点) */
  3958. hasAvatar: readAvatarState(),
  3959. gridCount: grid
  3960. };
  3961. }
  3962. /**
  3963. * 读三组数字,直到**连续两次一致**才采信。
  3964. *
  3965. * ★ 这是实测踩出来的:点开用户主页后,页面会先渲染骨架、再把数字填进去,
  3966. * 中间那一小段时间读到的数字是**占位的 0**。
  3967. * 同一个账号(@user3670597276145)两次运行分别读到 78 与 0,
  3968. * 而 78 才是真的 —— 如果拿 0 去比"粉丝量 ≥ 1000",它就被冤枉地跳过了。
  3969. * 页面"安静"不代表数字已经就位,只有"两次读到一样"才是可靠的信号。
  3970. */
  3971. function readStatsStable() {
  3972. var tiktok = TikTokAuto.tiktok;
  3973. TikTokAuto.wait.tick(350);
  3974. var previous = tiktok.statNumbers();
  3975. for (var i = 0; i < 3; i += 1) {
  3976. TikTokAuto.wait.tick(350);
  3977. var current = tiktok.statNumbers();
  3978. if (current.follow === previous.follow && current.fans === previous.fans &&
  3979. current.likes === previous.likes) {
  3980. return current;
  3981. }
  3982. previous = current;
  3983. }
  3984. logger().warn('profile', '三组数字连续三次都没稳定,采用最后一次的值(可能是中间态)');
  3985. return previous;
  3986. }
  3987. /**
  3988. * 截屏授权状态:null = 还没试过,true = 已就绪,false = 不可用。
  3989. *
  3990. * ★ 真机实测(2026-10-20):**`requestScreenCapture(false)` 是静默的** ——
  3991. * AutoX 的无障碍服务带 `CAPABILITY_CAN_TAKE_SCREENSHOT`,申请当场返回 true,
  3992. * 不弹 MediaProjection 确认框,1 秒内完成,随后 `captureScreen()` 直接可用。
  3993. * 而**不主动申请**时 `captureScreen()` 会抛 `No screen capture permission` ——
  3994. * 这就是早先"排除无头像一直不生效、每个账号还刷一条错误"的全部原因。
  3995. *
  3996. * 代价与风险:某些设备/ROM 上申请会弹一次系统授权框并阻塞。
  3997. * 所以申请失败就锁定"不可用"并**只告知一次**,不让它把整轮拖住或刷屏。
  3998. */
  3999. var screenCaptureReady = null;
  4000. function ensureScreenCapture() {
  4001. if (screenCaptureReady !== null) {
  4002. return screenCaptureReady;
  4003. }
  4004. if (typeof requestScreenCapture !== "function") {
  4005. screenCaptureReady = false;
  4006. logger().warn('profile', '这个运行时不支持截屏申请,本次运行的头像判定会跳过');
  4007. return false;
  4008. }
  4009. try {
  4010. screenCaptureReady = requestScreenCapture(false) === true;
  4011. } catch (error) {
  4012. screenCaptureReady = false;
  4013. logger().warn('profile', '申请截屏授权失败,本次运行的头像判定会跳过:' + error);
  4014. return false;
  4015. }
  4016. if (screenCaptureReady) {
  4017. logger().info('profile', '截屏授权已就绪,头像判定可用');
  4018. } else {
  4019. logger().warn('profile', '截屏授权未获批准,本次运行的头像判定会跳过');
  4020. }
  4021. return screenCaptureReady;
  4022. }
  4023. /**
  4024. * 头像是默认剪影吗?
  4025. *
  4026. * ★ "判为无头像"必须**再采一次**才采信。
  4027. * 实测发现:刚进用户页时(`waitProfile` 只等到 `@username` 出现,约 585ms),
  4028. * 头像图片往往还在下载,截到的是**灰色占位块** → 高饱和 0.000 → 误判"无头像"
  4029. * → 一个明明有头像的账号被错误跳过。
  4030. * 有色的情况不必二次确认(灰底不会自己变彩色),所以这个额外开销只落在
  4031. * "疑似无头像"的那部分账号上,约多花 400ms。
  4032. *
  4033. * @returns true(有头像)/ false(默认剪影)/ null(判不了)
  4034. */
  4035. function readAvatarState() {
  4036. if (!ensureScreenCapture()) {
  4037. return null;
  4038. }
  4039. var node = avatarNode();
  4040. if (!node) {
  4041. return null;
  4042. }
  4043. var first = sampleAvatar(node);
  4044. if (first === null || first === true) {
  4045. return first;
  4046. }
  4047. /* 第一次判为"无色":等图片加载完再确认一次 */
  4048. TikTokAuto.wait.tick(400);
  4049. var second = sampleAvatar(node);
  4050. if (second === null) {
  4051. return first;
  4052. }
  4053. if (second !== first) {
  4054. logger().info('profile', '头像两次采样不一致(' + first + ' → ' + second +
  4055. '),采用第二次 —— 第一次多半是图片还没加载');
  4056. }
  4057. return second;
  4058. }
  4059. /**
  4060. * 对头像区域采样一次,返回"是否有头像"。
  4061. * @returns true / false / null(拿不到节点或截屏失败)
  4062. */
  4063. function sampleAvatar(node) {
  4064. var box = node.bounds();
  4065. var saturated = 0;
  4066. var total = 0;
  4067. var image = null;
  4068. try {
  4069. image = captureScreen();
  4070. } catch (error) {
  4071. /* 申请过了却还是拿不到(被系统撤销 / ROM 限制):不是"没头像",
  4072. * 是"这次判不了" —— 必须返回 null,由上层如实告知。 */
  4073. screenCaptureReady = false;
  4074. logger().warn('profile', '截屏不可用,后续账号不再尝试头像判定:' + error);
  4075. return null;
  4076. }
  4077. if (!image) {
  4078. screenCaptureReady = false;
  4079. return null;
  4080. }
  4081. var stepX = (box.right - box.left) / (AVATAR_GRID + 1);
  4082. var stepY = (box.bottom - box.top) / (AVATAR_GRID + 1);
  4083. for (var row = 1; row <= AVATAR_GRID; row += 1) {
  4084. for (var col = 1; col <= AVATAR_GRID; col += 1) {
  4085. var x = Math.round(box.left + stepX * col);
  4086. var y = Math.round(box.top + stepY * row);
  4087. var pixel = 0;
  4088. try {
  4089. pixel = image.pixel(x, y);
  4090. } catch (error2) {
  4091. continue;
  4092. }
  4093. total += 1;
  4094. if (isSaturated(pixel)) {
  4095. saturated += 1;
  4096. }
  4097. }
  4098. }
  4099. if (total === 0) {
  4100. return null;
  4101. }
  4102. var ratio = saturated / total;
  4103. logger().info('profile', '头像像素判定:高饱和 ' + saturated + '/' + total +
  4104. ' = ' + ratio.toFixed(3) + '(阈值 ' + AVATAR_SATURATED_RATIO + ')');
  4105. return ratio >= AVATAR_SATURATED_RATIO;
  4106. }
  4107. /** ARGB int → 饱和度是否够高。s = (max-min)/max,避开明亮的白色背景。 */
  4108. function isSaturated(pixel) {
  4109. var r = (pixel >> 16) & 0xff;
  4110. var g = (pixel >> 8) & 0xff;
  4111. var b = pixel & 0xff;
  4112. var max = Math.max(r, Math.max(g, b));
  4113. var min = Math.min(r, Math.min(g, b));
  4114. if (max < 40) {
  4115. return false; /* 太暗:黑边/阴影,不算色彩 */
  4116. }
  4117. return (max - min) / max >= 0.25;
  4118. }
  4119. /**
  4120. * 用户主页的头像节点。
  4121. * 判据:可见 ImageView、尺寸 140~280px、位于页面上部 1/3。
  4122. * 实测新版布局在右上角([856,168][1048,360]),老布局可能在左上,所以两侧都认。
  4123. */
  4124. function avatarNode() {
  4125. var layout = TikTokAuto.layout;
  4126. var best = null;
  4127. var bestArea = 0;
  4128. var collection = className('android.widget.ImageView').find();
  4129. for (var i = 0; i < collection.size(); i += 1) {
  4130. var node = collection.get(i);
  4131. if (!layout.isVisible(node)) {
  4132. continue;
  4133. }
  4134. var box = node.bounds();
  4135. var width = box.right - box.left;
  4136. var height = box.bottom - box.top;
  4137. if (width < 140 || width > 280 || height < 140 || height > 280) {
  4138. continue;
  4139. }
  4140. if (box.top > device.height * 0.33) {
  4141. continue;
  4142. }
  4143. /* 取面积最大的那个:页面上部可能还有别的图标,头像一定是最大的 */
  4144. if (width * height > bestArea) {
  4145. bestArea = width * height;
  4146. best = node;
  4147. }
  4148. }
  4149. return best;
  4150. }
  4151. // ------------------------------------------------------------ 导航
  4152. /** 当前在哪个页面(ID 提取这条链路自己的判据) */
  4153. function where() {
  4154. return TikTokAuto.tiktok.idPageState();
  4155. }
  4156. /**
  4157. * 打开一个账号的主页(走搜索)。
  4158. *
  4159. * 为什么不直接拼 URL 打开:TikTok 的 deeplink 需要签名参数,
  4160. * 而且"用户在用的这个 TikTok 版本"才是唯一可靠的入口。
  4161. * 走搜索虽然慢(约 8 秒),但实测稳定。
  4162. *
  4163. * @returns 资料对象;失败返回 null
  4164. */
  4165. function openBySearch(uid, config) {
  4166. var step = 'openProfile';
  4167. var tiktok = TikTokAuto.tiktok;
  4168. var log = logger();
  4169. var parse = TikTokAuto.parse;
  4170. var wanted = parse.username(uid);
  4171. if (wanted === '') {
  4172. return null;
  4173. }
  4174. log.step(step, '搜索并打开 @' + wanted);
  4175. if (!ensureFeed(config)) {
  4176. return null;
  4177. }
  4178. if (!openSearch(config)) {
  4179. return null;
  4180. }
  4181. if (!submit(wanted, config)) {
  4182. return null;
  4183. }
  4184. /* 切到 Users 页签:不切的话看到的是视频,找不到人 */
  4185. var usersTab = tiktok.resultTab('Users');
  4186. if (usersTab) {
  4187. TikTokAuto.ui.clickNode(usersTab, step + ':usersTab');
  4188. /* ★ 等**结果行真的出现**,不能只等"页面安静":
  4189. * 页签条一渲染出来签名就稳定了,而结果列表还要联网加载 1~2 秒。
  4190. * 这里如果不等,就会稳定地误报"搜索结果里找不到该账号"(实测踩到)。 */
  4191. TikTokAuto.wait.waitFor(step + ':rows', function () {
  4192. return tiktok.usersResultRows().length > 0 ? true : null;
  4193. }, config.timeouts.searchResultsMs, config.probe.pollTickMs);
  4194. }
  4195. var row = findRowByUid(wanted);
  4196. if (!row) {
  4197. log.warn(step, '搜索结果里找不到 @' + wanted +
  4198. '(当前可见 ' + tiktok.usersResultRows().length + ' 行)');
  4199. return null;
  4200. }
  4201. tapRow(row, step + ':row');
  4202. var arrived = waitProfile(step);
  4203. if (!arrived) {
  4204. /* 实测:`node.click()` 打在整行容器上会落在**中心**,
  4205. * 而搜索用户行的中心那块是空白(有时还是 Follow 按钮的邻域)——
  4206. * 点了返回 true 却没进主页。左侧 30% 处(用户名文字上)才稳定。
  4207. * 所以这里再补一次"按坐标点",只重试一次,不做无限重试。 */
  4208. log.info(step, '第一次点击没进主页,改用行内坐标重试');
  4209. tapRow(row, step + ':row-retry');
  4210. arrived = waitProfile(step + ':retry');
  4211. }
  4212. if (!arrived) {
  4213. log.warn(step, '点了搜索结果但没有进入用户主页(当前 ' + where() + ')');
  4214. return null;
  4215. }
  4216. /* 主页渲染完成后,数字区可能还差一帧。读之前再确认一次"三组数字里至少有一个读到了",
  4217. * 否则会把"还没渲染"当成"这个账号没有数据"。最多再等 3 秒。 */
  4218. TikTokAuto.wait.waitFor(step + ':stats', function () {
  4219. var stats = tiktok.statNumbers();
  4220. return (stats.follow !== null || stats.fans !== null || stats.likes !== null) ? true : null;
  4221. }, 3000, 200);
  4222. return readProfile();
  4223. }
  4224. /** 搜索页 → 输入 → 提交。复用 flow 里已经验证过的锚点,但关键词由这里给。 */
  4225. function openSearch(config) {
  4226. var step = 'openSearch';
  4227. var entry = TikTokAuto.tiktok.searchEntry();
  4228. if (!entry) {
  4229. logger().error(step, '首页找不到搜索入口');
  4230. return false;
  4231. }
  4232. TikTokAuto.ui.clickNode(entry, step);
  4233. return TikTokAuto.wait.waitFor(step, function () {
  4234. var state = TikTokAuto.tiktok.idPageState();
  4235. return (state === 'search' || state === 'users-results') ? true : null;
  4236. }, config.timeouts.pageReadyMs, config.probe.pollTickMs) === true;
  4237. }
  4238. function submit(keyword, config) {
  4239. var step = 'submitKeyword';
  4240. var tiktok = TikTokAuto.tiktok;
  4241. var input = tiktok.searchInput();
  4242. if (!input) {
  4243. logger().error(step, '找不到搜索输入框');
  4244. return false;
  4245. }
  4246. TikTokAuto.ui.clickNode(input, step);
  4247. TikTokAuto.wait.tick(250);
  4248. var typed = TikTokAuto.ui.typeInto(tiktok.searchInput() || input, keyword, step);
  4249. if (!typed.ok) {
  4250. return false;
  4251. }
  4252. var button = tiktok.searchSubmit();
  4253. if (!button) {
  4254. logger().error(step, '找不到搜索提交按钮');
  4255. return false;
  4256. }
  4257. TikTokAuto.ui.clickNode(button, step);
  4258. return TikTokAuto.wait.waitFor(step, function () {
  4259. var state = tiktok.idPageState();
  4260. return (state === 'users-results' || TikTokAuto.tiktok.resultTabStripPresent()) ? true : null;
  4261. }, config.timeouts.searchResultsMs, config.probe.pollTickMs) === true;
  4262. }
  4263. /** 在当前搜索结果里按 uid 找行(找不到返回 null,不猜第一行) */
  4264. function findRowByUid(uid) {
  4265. var rows = TikTokAuto.tiktok.usersResultRows();
  4266. for (var i = 0; i < rows.length; i += 1) {
  4267. if (rows[i].uid === uid) {
  4268. return rows[i];
  4269. }
  4270. }
  4271. return null;
  4272. }
  4273. /** 保证停在信息流首页(复用 flow 的成熟实现,避免两套退出逻辑) */
  4274. function ensureFeed(config) {
  4275. return TikTokAuto.flow.ensureFeed(config) === true;
  4276. }
  4277. /**
  4278. * 在用户主页上打开「粉丝/关注」列表。
  4279. *
  4280. * ★ 这里**不能**用 `settle()` 判就绪:点完之后列表要联网加载,
  4281. * 而 `waitQuiet` 的判据是"可见文案连续两次不变" —— 加载中的列表和
  4282. * 打开前的页面**签名是一样的**,于是它会立刻就返回(实测 872ms 就回来了),
  4283. * 然后被判成"打不开列表"。正确做法是等**列表真的有行**。
  4284. *
  4285. * @param which 'fans' | 'following'
  4286. */
  4287. function openList(which, config) {
  4288. var step = 'openList';
  4289. var tiktok = TikTokAuto.tiktok;
  4290. var entry = tiktok.statEntry(which === 'following' ? 'following' : 'fans');
  4291. if (!entry) {
  4292. logger().warn(step, '用户主页上找不到 ' + which + ' 入口');
  4293. return false;
  4294. }
  4295. logger().step(step, '打开列表:' + which);
  4296. TikTokAuto.ui.clickNode(entry, step);
  4297. var ready = TikTokAuto.wait.waitFor(step, function () {
  4298. return where() === 'user-list' ? true : null;
  4299. }, config.timeouts.pageReadyMs, config.probe.pollTickMs);
  4300. if (ready === true) {
  4301. return true;
  4302. }
  4303. /* 与粉丝列表相关的一个真机实况:首次打开会弹「给 TikTok 访问你的 Facebook 好友列表」
  4304. * 的授权窗,盖住整个列表。它不是错误页,但会让"列表就绪"永远等不到。
  4305. * 这里点掉它再等一次 —— 只做一轮,不无限重试。 */
  4306. if (dismissFriendPrompt()) {
  4307. logger().info(step, '点掉了好友列表授权窗,再等一次');
  4308. ready = TikTokAuto.wait.waitFor(step + ':retry', function () {
  4309. return where() === 'user-list' ? true : null;
  4310. }, config.timeouts.pageReadyMs, config.probe.pollTickMs);
  4311. }
  4312. return ready === true;
  4313. }
  4314. /**
  4315. * 点掉「Facebook 好友列表」授权窗的"不允许"。
  4316. *
  4317. * 实测这个弹窗的文案是英文 `Don’t allow`(撇号是 U+2019,不是 ASCII 单引号,
  4318. * 直接比对 'Don\'t allow' 是匹配不上的 —— 这里按**包含**比对并同时认几种写法)。
  4319. * 找不到就当没有弹窗,返回 false,由上层决定下一步。
  4320. */
  4321. function dismissFriendPrompt() {
  4322. var layout = TikTokAuto.layout;
  4323. var keywords = ['Facebook', '好友列表', 'friends list'];
  4324. var buttons = className('android.widget.Button').find();
  4325. var hint = false;
  4326. for (var i = 0; i < buttons.size(); i += 1) {
  4327. var text = layout.text(buttons.get(i));
  4328. if (text === '') {
  4329. continue;
  4330. }
  4331. if (text.indexOf('allow') >= 0 || text.indexOf('允许') >= 0 || text.indexOf('允許') >= 0) {
  4332. hint = true;
  4333. continue;
  4334. }
  4335. for (var k = 0; k < keywords.length; k += 1) {
  4336. if (text.indexOf(keywords[k]) >= 0) {
  4337. hint = true;
  4338. break;
  4339. }
  4340. }
  4341. }
  4342. if (!hint) {
  4343. return false;
  4344. }
  4345. /* 有授权窗:找那个"不允许 / Don't allow",它一定比"OK"靠左(实测 [260,1114][539,1209] vs [540,1114][819,1209]) */
  4346. var target = null;
  4347. for (var j = 0; j < buttons.size(); j += 1) {
  4348. var label = layout.text(buttons.get(j));
  4349. if (/don.t allow|不允许|不允許|稍后|稍後|not now|no thanks/i.test(label)) {
  4350. if (target === null || buttons.get(j).bounds().left < target.bounds().left) {
  4351. target = buttons.get(j);
  4352. }
  4353. }
  4354. }
  4355. if (!target) {
  4356. return false;
  4357. }
  4358. logger().info('profile', '点掉授权窗:' + JSON.stringify(TikTokAuto.layout.text(target)));
  4359. TikTokAuto.ui.clickNode(target, 'dismissFriendPrompt');
  4360. settle(2000);
  4361. return true;
  4362. }
  4363. /**
  4364. * 在列表页切页签。已在目标页签上时直接返回 true(不重复点)。
  4365. * @param which 'fans' | 'following'
  4366. */
  4367. function switchListTab(which) {
  4368. var step = 'switchTab';
  4369. var tabs = TikTokAuto.tiktok.userListTabs();
  4370. var wanted = which === 'following' ? 'following' : 'fans';
  4371. for (var i = 0; i < tabs.length; i += 1) {
  4372. if (tabs[i].key !== wanted) {
  4373. continue;
  4374. }
  4375. if (TikTokAuto.tiktok.selectedListTab() === wanted) {
  4376. logger().info(step, '已在 ' + wanted + ' 页签上');
  4377. return true;
  4378. }
  4379. logger().info(step, '切到 ' + wanted + ' 页签(' + tabs[i].label + ')');
  4380. TikTokAuto.ui.clickNode(tabs[i].node, step);
  4381. settle(3000);
  4382. return true;
  4383. }
  4384. logger().warn(step, '找不到 ' + wanted + ' 页签');
  4385. return false;
  4386. }
  4387. /**
  4388. * 当前在不在"一个可以逐行取 uid 的列表页"上。
  4389. *
  4390. * 有两种,长得不一样、资源 id 也不同,但对上层来说都是"一屏一屏的用户行":
  4391. * · `user-list` 粉丝/关注列表(`txt_desc` 装 uid)
  4392. * · `users-results` 搜索结果的用户页签(`tv_username` 装 uid)
  4393. * 上层不该关心是哪种 —— 这正是这个判据存在的意义。
  4394. */
  4395. function isListPage(state) {
  4396. var now = state || where();
  4397. return now === 'user-list' || now === 'users-results';
  4398. }
  4399. /**
  4400. * 列表当前可见的每一行(已按屏幕从上到下排序)。
  4401. *
  4402. * ★ **按当前页面选解析器**,这是实测踩出来的一个"看起来能跑但永远 0 行"的 bug:
  4403. * 当初这里写死成 `userListRows()`(粉丝列表的解析器,找 `txt_desc`),
  4404. * 而搜索结果页的用户行是 `tv_username` —— 于是"取材=搜索结果"时,
  4405. * 上一步 `usersResultRows()` 明明读到了行(日志里 `userRows` 就绪),
  4406. * 下一步 `listRows()` 却返回空,最后表现为"处理 0 行、一个都没抓到"。
  4407. * 两种页面的行结构不同,但**对调用方是同一件事**,所以分派必须留在这里。
  4408. */
  4409. function listRows() {
  4410. if (where() === 'users-results') {
  4411. return TikTokAuto.tiktok.usersResultRows();
  4412. }
  4413. return TikTokAuto.tiktok.userListRows();
  4414. }
  4415. /**
  4416. * 列表向下翻一屏。
  4417. *
  4418. * 起点刻意选在屏幕中部偏下、终点在中部偏上:太靠下会落到 Follow 按钮上
  4419. * (按钮吃掉手势,页面纹丝不动 —— 这个坑在界面上踩过一次,这里避开)。
  4420. *
  4421. * @returns 翻之前那一屏的 uid 签名,供调用方判断"翻完有没有新东西"
  4422. */
  4423. function scrollList() {
  4424. var layout = TikTokAuto.layout;
  4425. var screen = layout.screen();
  4426. TikTokAuto.ui.dragScreen(
  4427. Math.floor(screen.width * 0.3), Math.floor(screen.height * 0.78),
  4428. Math.floor(screen.width * 0.3), Math.floor(screen.height * 0.3),
  4429. 420
  4430. );
  4431. settle(2500);
  4432. }
  4433. /** 点列表里的一行,进入该用户主页 */
  4434. function openRow(row) {
  4435. var step = 'openRow';
  4436. if (!row) {
  4437. return false;
  4438. }
  4439. logger().info(step, '点开 @' + row.uid);
  4440. tapRow(row, step);
  4441. return waitProfile(step);
  4442. }
  4443. /**
  4444. * 等用户主页**真的渲染出来**。
  4445. *
  4446. * ★ 不能只等 `settle()`(页面安静):点开主页后骨架先出现、`@username`
  4447. * 那几个字后出现,中间页面是"安静的" —— 于是 settle 会提前返回,
  4448. * 紧接着 readProfile() 读到一份**空资料**。
  4449. * 实测后果:一个粉丝数为 0 的账号被当成"读不到资料",或被误判过滤。
  4450. * 判据必须是"@username 节点已经存在"。
  4451. */
  4452. function waitProfile(step) {
  4453. return TikTokAuto.wait.waitFor(step + ':profile', function () {
  4454. return TikTokAuto.tiktok.profileUsernameNode() !== null ? true : null;
  4455. }, 10000, 300) === true;
  4456. }
  4457. /**
  4458. * 点一行用户条目。
  4459. *
  4460. * 用**坐标**而不是 `node.click()`:整行容器的 click() 在真机上常常落到
  4461. * "行内第一个可点元素"(也就是右侧那个 Follow 按钮),会把用户关注掉 ——
  4462. * 这是不能接受的副作用。所以点在行左侧 30% 处(用户名文字上),远离按钮。
  4463. * 实测这一处既能进主页,也不会误触关注。
  4464. */
  4465. function tapRow(row, step) {
  4466. if (row.x === undefined || row.y === undefined) {
  4467. TikTokAuto.ui.clickNode(row.node, step || 'tapRow');
  4468. return;
  4469. }
  4470. TikTokAuto.ui.tapPoint(row.x, row.y);
  4471. }
  4472. /** 从用户主页退回列表(列表位置会保持,实测);两种列表页都认 */
  4473. function backToList() {
  4474. var step = 'backToList';
  4475. var deadline = Date.now() + 10000;
  4476. while (Date.now() < deadline) {
  4477. var state = where();
  4478. if (isListPage(state)) {
  4479. return true;
  4480. }
  4481. if (state === 'profile' || state === 'comment-panel') {
  4482. TikTokAuto.ui.goBack(step);
  4483. settle(2000);
  4484. continue;
  4485. }
  4486. /* 已经退到搜索页/结果页/信息流:说明退过头了(列表已经不在栈上)。
  4487. * 这种情况不该再往回退 —— 再退就退到首页去了。 */
  4488. return false;
  4489. }
  4490. return isListPage();
  4491. }
  4492. /** 从列表退回用户主页 */
  4493. function backToProfile() {
  4494. var step = 'backToProfile';
  4495. var deadline = Date.now() + 8000;
  4496. while (Date.now() < deadline) {
  4497. var state = where();
  4498. if (state === 'profile') {
  4499. return true;
  4500. }
  4501. TikTokAuto.ui.goBack(step);
  4502. settle(2000);
  4503. }
  4504. return where() === 'profile';
  4505. }
  4506. /**
  4507. * 从用户主页退回评论区。
  4508. *
  4509. * 与 backToProfile/backToList 是一组:评论区取材时每个评论者都要
  4510. * "点头像 → 读 → 退回来",退错一层(退到播放页而不是评论区)就会
  4511. * 让下一轮找不到评论者。所以这里按状态确认,不靠数次数。
  4512. */
  4513. function backToCommentPanel() {
  4514. var step = 'backToComment';
  4515. var deadline = Date.now() + 10000;
  4516. while (Date.now() < deadline) {
  4517. var state = where();
  4518. if (state === 'comment-panel') {
  4519. return true;
  4520. }
  4521. if (state === 'player') {
  4522. /* 退到了播放页:把评论面板重新点开,而不是继续退 */
  4523. var button = TikTokAuto.tiktok.commentButton();
  4524. if (button) {
  4525. TikTokAuto.ui.clickNode(button, step + ':reopen');
  4526. settle(2500);
  4527. if (where() === 'comment-panel') {
  4528. return true;
  4529. }
  4530. }
  4531. return false;
  4532. }
  4533. TikTokAuto.ui.goBack(step);
  4534. settle(2000);
  4535. }
  4536. return where() === 'comment-panel';
  4537. }
  4538. return {
  4539. readProfile: readProfile,
  4540. readAvatarState: readAvatarState,
  4541. avatarNode: avatarNode,
  4542. where: where,
  4543. ensureFeed: ensureFeed,
  4544. openBySearch: openBySearch,
  4545. findRowByUid: findRowByUid,
  4546. openList: openList,
  4547. switchListTab: switchListTab,
  4548. listRows: listRows,
  4549. isListPage: isListPage,
  4550. scrollList: scrollList,
  4551. openRow: openRow,
  4552. backToList: backToList,
  4553. backToProfile: backToProfile,
  4554. backToCommentPanel: backToCommentPanel,
  4555. settle: settle,
  4556. dismissFriendPrompt: dismissFriendPrompt,
  4557. AVATAR_SATURATED_RATIO: AVATAR_SATURATED_RATIO
  4558. };
  4559. }());
  4560. /* ============================== src/idsource.js ============================== */
  4561. /**
  4562. * idsource.js — 五种「从哪儿找人」,以及深度展开
  4563. *
  4564. * ## 为什么是五种而不是四张脚本卡
  4565. * 旧的四个脚本卡是「起点 × 深度」的 4 个**不完整组合**。把它们拆开后发现,
  4566. * 真正不同的只有一件事:**候选 ID 从哪儿来**。于是收敛成五种起点
  4567. * (指定账号 / 搜索结果 / 某个视频的评论区 / 文本文件 / 当前屏幕),
  4568. * 深度(再取这些人的粉丝)是所有起点共用的一个选项。
  4569. *
  4570. * ## 三种遍历模式
  4571. * 五种起点在"怎么走"上只有三种形态,实现都归到这三条路径上:
  4572. * · **列表型**(指定账号 / 搜索用户 / 当前屏幕)—— 逐屏读行,可翻页;
  4573. * · **文本型**(文本文件)—— 一次性读出每一行;
  4574. * · **逐个点开型**(搜索视频作者 / 评论区)—— 屏幕上的条目没有 uid,
  4575. * 只能点进去看(视频页底部有 `@uid`,评论者要靠头像进用户页)。
  4576. *
  4577. * ## 深度展开为什么分两阶段
  4578. * "取这批人的粉丝"如果边遍历边展开,会嵌套两层列表上下文 ——
  4579. * 打开 B 列表时 A 的滚动位置就丢了。所以做成:
  4580. * ① 先把基础层收完,边收边记下"这些人要展开";
  4581. * ② 再对这批人逐个打开其粉丝/关注列表。
  4582. * 两层都线性,没有嵌套,也就没有"回到上一层时不知道走到哪"的问题。
  4583. * **上限固定两层**:100 粉丝 × 每人 100 粉丝 = 1 万次开页,真机上不现实。
  4584. *
  4585. * 依赖:TikTokAuto.tiktok / profile / filters / parse / logger / wait
  4586. */
  4587. TikTokAuto.idsource = (function () {
  4588. 'use strict';
  4589. /** 翻一个列表最多翻多少屏。防止"永远有新内容"(推荐流)把一轮拖成无限长。 */
  4590. var MAX_SCREENS = 60;
  4591. /** 连续这么多屏都没有新 uid 就认为到底了(有的列表末尾会重复推荐位) */
  4592. var IDLE_SCREENS_TO_STOP = 2;
  4593. function logger() { return TikTokAuto.logger; }
  4594. /** 一行 uid 的临时载体(列表行带昵称与计数,用户页带更多资料,统一成这个形状) */
  4595. function rowOf(uid, nickname, extra) {
  4596. var one = { uid: uid, nickname: nickname || '' };
  4597. if (extra) {
  4598. for (var key in extra) {
  4599. if (extra.hasOwnProperty(key)) {
  4600. one[key] = extra[key];
  4601. }
  4602. }
  4603. }
  4604. return one;
  4605. }
  4606. /* ============================================================ 列表型 */
  4607. /**
  4608. * 在"一个可滚动的用户列表"上逐行推进。
  4609. *
  4610. * 关键设计:**用 uid 集合判断进度,不用行下标**。
  4611. * 因为每次点进用户页再退回来,列表的节点树都重建过一遍(引用失效),
  4612. * 只有 uid 是稳的。
  4613. *
  4614. * @param ctx { seen, onRow(row), shouldStop() }
  4615. * @returns 处理了多少行
  4616. */
  4617. function walkList(ctx) {
  4618. var log = logger();
  4619. var handled = 0;
  4620. var idle = 0;
  4621. var screens = 0;
  4622. while (screens < MAX_SCREENS) {
  4623. if (ctx.shouldStop()) {
  4624. log.info('idsource', '达到停止条件,结束当前列表');
  4625. break;
  4626. }
  4627. screens += 1;
  4628. var rows = TikTokAuto.profile.listRows();
  4629. if (rows.length === 0 && screens === 1) {
  4630. /* 首屏还在加载。**等一次**再判定,否则会把"慢"当成"空":
  4631. * 从用户页点开粉丝列表、或从结果页切到用户页签后,
  4632. * 容器先出来、行后出来(实测差 1~2 秒),这时 listRows() 返回空数组 ——
  4633. * 直接 break 就等于"一条都没抓到"。 */
  4634. TikTokAuto.wait.waitFor('listRows', function () {
  4635. return TikTokAuto.profile.listRows().length > 0 ? true : null;
  4636. }, ctx.config ? ctx.config.timeouts.pageReadyMs : 8000, 300);
  4637. rows = TikTokAuto.profile.listRows();
  4638. }
  4639. if (rows.length === 0) {
  4640. log.info('idsource', '当前屏没有读到用户行,结束列表');
  4641. break;
  4642. }
  4643. var fresh = [];
  4644. for (var i = 0; i < rows.length; i += 1) {
  4645. if (!ctx.seen.has(rows[i].uid)) {
  4646. fresh.push(rows[i]);
  4647. }
  4648. }
  4649. if (fresh.length === 0) {
  4650. idle += 1;
  4651. if (idle >= IDLE_SCREENS_TO_STOP) {
  4652. log.info('idsource', '连续 ' + idle + ' 屏没有新 uid,认为列表到底');
  4653. break;
  4654. }
  4655. } else {
  4656. idle = 0;
  4657. }
  4658. /* 一行一行处理:需要资料时会点进用户页再退回(实测位置保持) */
  4659. for (var j = 0; j < fresh.length; j += 1) {
  4660. if (ctx.shouldStop()) {
  4661. return handled;
  4662. }
  4663. ctx.onRow(fresh[j]);
  4664. ctx.seen.add(fresh[j].uid); /* 处理过就算见过,避免同一行被反复点开 */
  4665. handled += 1;
  4666. }
  4667. /* 翻一屏再看有没有新的 */
  4668. var before = signatureOfRows(rows);
  4669. TikTokAuto.profile.scrollList();
  4670. var after = signatureOfRows(TikTokAuto.profile.listRows());
  4671. if (after === before) {
  4672. idle += 1;
  4673. if (idle >= IDLE_SCREENS_TO_STOP) {
  4674. log.info('idsource', '翻屏后内容不变,认为列表到底');
  4675. break;
  4676. }
  4677. }
  4678. }
  4679. return handled;
  4680. }
  4681. /** 一屏 uid 的签名,用来判断"翻完到底有没有变" */
  4682. function signatureOfRows(rows) {
  4683. var parts = [];
  4684. for (var i = 0; i < rows.length; i += 1) {
  4685. parts.push(rows[i].uid);
  4686. }
  4687. return parts.join('|');
  4688. }
  4689. /* ============================================================ 文本型 */
  4690. /**
  4691. * 从本地文本文件读 uid。
  4692. *
  4693. * 两条容忍(都是为了让用户不用改自己的文件):
  4694. * · 行首的 `@` 会被剥掉(有人习惯直接粘贴 "@name");
  4695. * · `#` 开头的行当注释跳过。
  4696. */
  4697. function readTextSource(source) {
  4698. var list = TikTokAuto.uidset.readUidFile(source.path);
  4699. logger().info('idsource', '文本取材:' + source.path + ' 读到 ' + list.length + ' 行');
  4700. var rows = [];
  4701. for (var i = 0; i < list.length; i += 1) {
  4702. rows.push(rowOf(list[i], ''));
  4703. }
  4704. return rows;
  4705. }
  4706. /* ============================================================ 逐个点开型 */
  4707. /**
  4708. * 搜索结果里的视频 → 逐个点开,读播放页底部的作者 uid。
  4709. *
  4710. * 实测:搜索结果的视频卡片上只有**作者昵称**,没有 uid;
  4711. * 播放页底部有一个 `Button` 文案是 `@uid` —— 这是唯一能拿到 uid 的地方。
  4712. * 所以每个视频要"点开 → 读 → 退回",约 6 秒一个。
  4713. */
  4714. function walkSearchVideos(source, ctx) {
  4715. var log = logger();
  4716. var handled = 0;
  4717. var idle = 0;
  4718. for (var screen = 0; screen < MAX_SCREENS; screen += 1) {
  4719. if (ctx.shouldStop()) {
  4720. break;
  4721. }
  4722. var cards = TikTokAuto.tiktok.videoCards();
  4723. if (cards.length === 0) {
  4724. log.info('idsource', '结果页没有卡片了');
  4725. break;
  4726. }
  4727. var opened = 0;
  4728. for (var i = 0; i < cards.length; i += 1) {
  4729. if (ctx.shouldStop()) {
  4730. return handled;
  4731. }
  4732. var card = cards[i];
  4733. var marker = 'card:' + String(card.caption).slice(0, 40);
  4734. if (ctx.seen.has(marker)) {
  4735. continue;
  4736. }
  4737. ctx.seen.add(marker);
  4738. if (!TikTokAuto.flow.openCard(ctx.config, card)) {
  4739. log.warn('idsource', '打开视频失败:' + marker);
  4740. TikTokAuto.flow.returnToResults(ctx.config);
  4741. continue;
  4742. }
  4743. var uid = TikTokAuto.tiktok.playerAuthorUid();
  4744. if (uid) {
  4745. ctx.onRow(rowOf(uid, String(card.caption).slice(0, 30)));
  4746. handled += 1;
  4747. opened += 1;
  4748. } else {
  4749. log.warn('idsource', '播放页读不到作者 uid');
  4750. }
  4751. TikTokAuto.flow.returnToResults(ctx.config);
  4752. }
  4753. if (opened === 0) {
  4754. idle += 1;
  4755. if (idle >= IDLE_SCREENS_TO_STOP) {
  4756. log.info('idsource', '连续几屏都读不到作者,结束');
  4757. break;
  4758. }
  4759. } else {
  4760. idle = 0;
  4761. }
  4762. TikTokAuto.flow.scrollResults(ctx.config);
  4763. }
  4764. return handled;
  4765. }
  4766. /**
  4767. * 评论区 → 逐个点评论者的头像进用户页读 uid。
  4768. *
  4769. * 实测这条最贵:评论区只显示昵称,没有 uid(`Button title` 是昵称,
  4770. * 旁边那个 `@uid` 是固定节点、不是评论者的)。所以每个评论者都要
  4771. * "点头像 → 读 → 退回",约 8 秒一个。这也是为什么方案里
  4772. * 把"评论者"和"评论者的粉丝"分开成两条取材,而不是默认都开。
  4773. */
  4774. function walkCommenters(source, ctx) {
  4775. var log = logger();
  4776. var handled = 0;
  4777. for (var screen = 0; screen < MAX_SCREENS; screen += 1) {
  4778. if (ctx.shouldStop()) {
  4779. break;
  4780. }
  4781. var rows = TikTokAuto.tiktok.commentRowers();
  4782. if (rows.length === 0) {
  4783. log.info('idsource', '评论区没有更多可见条目');
  4784. break;
  4785. }
  4786. var opened = 0;
  4787. var before = signatureOfNodes(rows);
  4788. for (var i = 0; i < rows.length; i += 1) {
  4789. if (ctx.shouldStop()) {
  4790. return handled;
  4791. }
  4792. /* 头像节点每次都会重建,用"位置 + 序号"当标记 —— 换一屏位置就会变 */
  4793. var marker = 'cmt:' + rows[i].top + ':' + (screen * 100 + i);
  4794. if (ctx.seen.has(marker)) {
  4795. continue;
  4796. }
  4797. ctx.seen.add(marker);
  4798. TikTokAuto.ui.tapPoint(rows[i].x, rows[i].y);
  4799. TikTokAuto.profile.settle(3000);
  4800. if (TikTokAuto.profile.where() !== 'profile') {
  4801. log.warn('idsource', '点评论者头像没有进入用户主页');
  4802. continue;
  4803. }
  4804. var profile = TikTokAuto.profile.readProfile();
  4805. if (profile.uid) {
  4806. /* 资料已经在手上了,带上 `_profile` 让编排层不必再开一次用户页 */
  4807. ctx.onRow(rowOf(profile.uid, profile.nickname, { _profile: profile }));
  4808. handled += 1;
  4809. opened += 1;
  4810. }
  4811. TikTokAuto.profile.backToCommentPanel();
  4812. }
  4813. if (opened === 0) {
  4814. break;
  4815. }
  4816. /* 评论区滚动 */
  4817. var layout = TikTokAuto.layout;
  4818. var screenSize = layout.screen();
  4819. TikTokAuto.ui.dragScreen(
  4820. Math.floor(screenSize.width * 0.5), Math.floor(screenSize.height * 0.8),
  4821. Math.floor(screenSize.width * 0.5), Math.floor(screenSize.height * 0.4),
  4822. 420
  4823. );
  4824. TikTokAuto.profile.settle(2000);
  4825. if (signatureOfNodes(TikTokAuto.tiktok.commentRowers()) === before) {
  4826. log.info('idsource', '评论区滚动后内容不变,结束');
  4827. break;
  4828. }
  4829. }
  4830. return handled;
  4831. }
  4832. function signatureOfNodes(rows) {
  4833. var parts = [];
  4834. for (var i = 0; i < rows.length; i += 1) {
  4835. parts.push(rows[i].top);
  4836. }
  4837. return parts.join(',');
  4838. }
  4839. /* ============================================================ 入口 */
  4840. /**
  4841. * 把一个取材源跑完,逐行交给 `onRow`。
  4842. *
  4843. * @param source 取材项(与界面 sources.js 的形状一致)
  4844. * @param ctx { config, seen, onRow(row), shouldStop() }
  4845. * @returns 处理的行数
  4846. */
  4847. function run(source, ctx) {
  4848. var log = logger();
  4849. log.step('idsource', '取材开始:' + describe(source));
  4850. if (source.kind === 'text') {
  4851. /* 文本源也走 `onRow`,**不在这一层自己判去重** ——
  4852. * 去重与过滤是编排层(idflow)的职责,在这里再做一遍会让
  4853. * `duplicates` 统计漏掉(实测:跨轮去重跳了 3 个,summary 里却是 0)。 */
  4854. var rows = readTextSource(source);
  4855. var handled = 0;
  4856. for (var i = 0; i < rows.length; i += 1) {
  4857. if (ctx.shouldStop()) {
  4858. break;
  4859. }
  4860. ctx.onRow(rows[i]);
  4861. handled += 1;
  4862. }
  4863. return handled;
  4864. }
  4865. if (source.kind === 'screen') {
  4866. /* 当前屏幕:不做任何导航,直接把眼前这一屏当列表读 */
  4867. return walkList(ctx);
  4868. }
  4869. if (source.kind === 'anchor') {
  4870. return runAnchor(source, ctx);
  4871. }
  4872. if (source.kind === 'search') {
  4873. if (source.searchType === 'videos') {
  4874. if (!prepareSearch(source.keyword, ctx.config)) {
  4875. return 0;
  4876. }
  4877. TikTokAuto.flow.selectResultTab({
  4878. target: { resultTab: 'Top' },
  4879. timeouts: ctx.config.timeouts,
  4880. probe: ctx.config.probe
  4881. });
  4882. return walkSearchVideos(source, ctx);
  4883. }
  4884. if (!prepareSearch(source.keyword, ctx.config)) {
  4885. return 0;
  4886. }
  4887. return walkList(ctx);
  4888. }
  4889. if (source.kind === 'video') {
  4890. if (!prepareVideo(source, ctx.config)) {
  4891. return 0;
  4892. }
  4893. return walkCommenters(source, ctx);
  4894. }
  4895. log.warn('idsource', '未知的取材起点:' + source.kind);
  4896. return 0;
  4897. }
  4898. /**
  4899. * 指定账号:打开它的主页 → 按 `lists` 逐个开列表 → 每处走一遍 walkList。
  4900. *
  4901. * `anchors` 为空表示"不限定账号,从当前屏幕取" —— 这是界面允许的状态,
  4902. * 对应 v6 时代「主播 ID 留空」的语义。
  4903. */
  4904. function runAnchor(source, ctx) {
  4905. var log = logger();
  4906. var parse = TikTokAuto.parse;
  4907. var anchors = String(source.anchors || '').split('#');
  4908. var handled = 0;
  4909. var accounts = [];
  4910. for (var i = 0; i < anchors.length; i += 1) {
  4911. var name = parse.username(anchors[i]);
  4912. if (name !== '') {
  4913. accounts.push(name);
  4914. }
  4915. }
  4916. if (accounts.length === 0) {
  4917. log.info('idsource', '没有指定账号 → 直接把当前屏幕当列表');
  4918. return walkList(ctx);
  4919. }
  4920. for (var a = 0; a < accounts.length; a += 1) {
  4921. if (ctx.shouldStop()) {
  4922. break;
  4923. }
  4924. var profile = TikTokAuto.profile.openBySearch(accounts[a], ctx.config);
  4925. if (!profile) {
  4926. log.warn('idsource', '打不开账号 @' + accounts[a] + ',跳过');
  4927. continue;
  4928. }
  4929. ctx.onSeenProfile(profile);
  4930. var lists = source.lists && source.lists.length > 0 ? source.lists : ['fans'];
  4931. for (var l = 0; l < lists.length; l += 1) {
  4932. if (ctx.shouldStop()) {
  4933. return handled;
  4934. }
  4935. var which = lists[l] === 'home' ? 'fans' : lists[l];
  4936. if (!TikTokAuto.profile.openList(which, ctx.config)) {
  4937. log.warn('idsource', '打不开 ' + lists[l] + ' 列表');
  4938. continue;
  4939. }
  4940. TikTokAuto.profile.switchListTab(which);
  4941. handled += walkList(ctx);
  4942. TikTokAuto.profile.backToProfile();
  4943. }
  4944. }
  4945. return handled;
  4946. }
  4947. /** 搜索关键词并停在结果页(供列表型与逐个点开型共用) */
  4948. function prepareSearch(keyword, config) {
  4949. var log = logger();
  4950. if (!TikTokAuto.profile.ensureFeed(config)) {
  4951. return false;
  4952. }
  4953. var tiktok = TikTokAuto.tiktok;
  4954. var entry = tiktok.searchEntry();
  4955. if (!entry) {
  4956. log.error('idsource', '首页找不到搜索入口');
  4957. return false;
  4958. }
  4959. TikTokAuto.ui.clickNode(entry, 'idsource:search');
  4960. if (TikTokAuto.wait.waitFor('idsource:search', function () {
  4961. var state = tiktok.idPageState();
  4962. return (state === 'search' || state === 'users-results') ? true : null;
  4963. }, config.timeouts.pageReadyMs, config.probe.pollTickMs) !== true) {
  4964. return false;
  4965. }
  4966. var input = tiktok.searchInput();
  4967. if (!input) {
  4968. return false;
  4969. }
  4970. TikTokAuto.ui.clickNode(input, 'idsource:input');
  4971. TikTokAuto.wait.tick(250);
  4972. if (!TikTokAuto.ui.typeInto(tiktok.searchInput() || input, keyword, 'idsource:type').ok) {
  4973. return false;
  4974. }
  4975. var submit = tiktok.searchSubmit();
  4976. if (!submit) {
  4977. return false;
  4978. }
  4979. TikTokAuto.ui.clickNode(submit, 'idsource:submit');
  4980. if (TikTokAuto.wait.waitFor('idsource:results', function () {
  4981. return tiktok.resultTabStripPresent() ? true : null;
  4982. }, config.timeouts.searchResultsMs, config.probe.pollTickMs) !== true) {
  4983. return false;
  4984. }
  4985. /* 用户搜索要切到 Users 页签;视频搜索留在 Top */
  4986. var usersTab = tiktok.resultTab('Users');
  4987. if (usersTab && !tiktok.resultTabSelected('Users')) {
  4988. TikTokAuto.ui.clickNode(usersTab, 'idsource:usersTab');
  4989. /* 等结果行真的出现(页签条先渲染、行后加载,只等"安静"会误判为空) */
  4990. TikTokAuto.wait.waitFor('idsource:userRows', function () {
  4991. return tiktok.usersResultRows().length > 0 ? true : null;
  4992. }, config.timeouts.searchResultsMs, config.probe.pollTickMs);
  4993. }
  4994. return true;
  4995. }
  4996. /**
  4997. * 打开一个视频(播放页),供评论区取材用。
  4998. * `videoSource = "screen"` 表示用户自己已经把视频停在屏幕上,不动它。
  4999. */
  5000. function prepareVideo(source, config) {
  5001. var log = logger();
  5002. if (source.videoSource !== 'keyword') {
  5003. var state = TikTokAuto.tiktok.pageState();
  5004. if (state === 'player' || state === 'comment-panel') {
  5005. log.info('idsource', '已在播放页,直接用当前屏幕的视频');
  5006. return true;
  5007. }
  5008. log.warn('idsource', '取材设置为「当前屏幕」,但当前不在播放页(' + state + ')');
  5009. return false;
  5010. }
  5011. if (!prepareSearch(source.videoKeyword, config)) {
  5012. return false;
  5013. }
  5014. /* Top 页签是视频;取第一张卡点开 */
  5015. var cards = TikTokAuto.tiktok.videoCards();
  5016. if (cards.length === 0 && !TikTokAuto.flow.waitForCards(config)) {
  5017. return false;
  5018. }
  5019. cards = TikTokAuto.tiktok.videoCards();
  5020. if (cards.length === 0) {
  5021. return false;
  5022. }
  5023. if (!TikTokAuto.flow.openCard(config, cards[0])) {
  5024. return false;
  5025. }
  5026. /* 打开评论面板 */
  5027. var button = TikTokAuto.tiktok.commentButton();
  5028. if (!button) {
  5029. log.warn('idsource', '播放页找不到评论按钮');
  5030. return false;
  5031. }
  5032. TikTokAuto.ui.clickNode(button, 'idsource:comments');
  5033. return TikTokAuto.wait.waitFor('idsource:panel', function () {
  5034. return TikTokAuto.tiktok.commentPanelOpen() ? true : null;
  5035. }, config.timeouts.panelReadyMs, config.probe.pollTickMs) === true;
  5036. }
  5037. /** 一句人话摘要(日志与状态条用) */
  5038. function describe(source) {
  5039. var parse = TikTokAuto.parse;
  5040. if (source.kind === 'anchor') {
  5041. var list = source.anchors ? source.anchors.split('#')[0] : '当前屏幕';
  5042. return '账号 ' + list + ' 的 ' + (source.lists || []).join('+');
  5043. }
  5044. if (source.kind === 'search') {
  5045. return (source.searchType === 'videos' ? '视频作者' : '用户') + '「' + source.keyword + '」';
  5046. }
  5047. if (source.kind === 'video') {
  5048. return source.videoSource === 'keyword'
  5049. ? '评论区「' + source.videoKeyword + '」'
  5050. : '当前屏幕的评论区';
  5051. }
  5052. if (source.kind === 'text') {
  5053. return '文本 ' + String(source.path).split('/').pop();
  5054. }
  5055. return '当前屏幕';
  5056. }
  5057. return {
  5058. MAX_SCREENS: MAX_SCREENS,
  5059. run: run,
  5060. walkList: walkList,
  5061. readTextSource: readTextSource,
  5062. describe: describe
  5063. };
  5064. }());
  5065. /* ============================== src/resolve.js ============================== */
  5066. /**
  5067. * resolve.js — 把 username(@handle)换成 TikTok 的数字 uid
  5068. *
  5069. * ## 为什么需要它
  5070. * 抓取产物里存的是 **username**(`beautifulsoul78869`)—— 那是能搜到人、能 @ 的标识。
  5071. * 但有些下游(对接接口、入库、跟外部系统对账)要的是 **数字 uid**(`254801657882882048`)。
  5072. * 两者完全不同:
  5073. *
  5074. * uniqueId / handle beautifulsoul78869 能搜到人、能 @,界面上可见
  5075. * nickname 🌹Beautiful Soul💀 显示名,可重名、可随时改
  5076. * id(本文的 uid) 254801657882882048 平台内部主键,界面上**读不到**
  5077. *
  5078. * 数字 uid 在 App 界面上任何地方都不显示,所以只能靠 HTTP 拿。
  5079. *
  5080. * ## 三条取数路径(照 `tiktok-uid和视频短链解析.js` 的分级思路)
  5081. *
  5082. * ① `www.tiktok.com/api/user/detail/?uniqueId=` 官方详情接口,最轻(本应几百字节)
  5083. * ② `www.tiktok.com/node/share/user/@<handle>` 老的分享接口
  5084. * ③ `www.tiktok.com/@<handle>` 的整页 SSR 最重(约 388KB),但**最可靠**
  5085. *
  5086. * 2026-10-20 真机实测:**①② 都已经不可用** ——
  5087. * ① 返回 `HTTP 200` 但**响应体为空**(风控静默拒绝,最容易让人误以为"接口还在")
  5088. * ② 返回 `403`
  5089. * ③ 200 / 388KB / 约 2 秒 / `JSON.parse` 仅 7ms
  5090. *
  5091. * 所以路径按"轻 → 重"逐个降级,且**每条只探一次**:某个账号证明 ① 不可用之后,
  5092. * 本次运行内不再尝试 ① —— 否则每个账号都要白付两次失败请求。
  5093. * 这样将来接口恢复了,第一次就会自动走轻的那条;恢复不了也不会拖慢。
  5094. *
  5095. * ## 试过但走不通的(别再试)
  5096. * · `tikwm.com/api/user/info` —— Cloudflare 挑战页("Just a moment...")
  5097. * · `m.tiktok.com/@user` —— 404(那条短链只认 `/v/<videoId>.html`)
  5098. * · `www.tiktok.com/oembed?url=...` —— 只有昵称与 handle,**没有数字 id**
  5099. * · AutoX 的 `http.get` —— 本机这个版本是坏的:
  5100. * `TypeError: Cannot find function newCall`(OkHttp 绑定冲突)→ 走 Java `HttpURLConnection`
  5101. *
  5102. * 依赖:TikTokAuto.logger、TikTokAuto.wait(节流与停止响应)、TikTokAuto.parse
  5103. */
  5104. TikTokAuto.resolve = (function () {
  5105. 'use strict';
  5106. var UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
  5107. '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
  5108. /** 整页近 400KB,慢网下 20 秒是合理的上限;接口那两条给 8 秒就够。 */
  5109. var API_TIMEOUT_MS = 8000;
  5110. var PAGE_TIMEOUT_MS = 20000;
  5111. /**
  5112. * 两次请求之间的间隔区间。
  5113. *
  5114. * 为什么不并发:这是网页版接口,短时间高频请求很容易被要求过验证码;
  5115. * 而我们是"一次几十个"的量级,串行 + 随机间隔的代价可以接受(每个约 2.5 秒)。
  5116. */
  5117. var GAP_MIN_MS = 400;
  5118. var GAP_MAX_MS = 900;
  5119. /**
  5120. * 本次运行里各条路径是否已被证明不可用。
  5121. *
  5122. * 这是**性能关键**:接口①② 实测必失败,若不记住,每个账号要多付两次失败请求;
  5123. * 记住之后只有第一个账号付这个代价。
  5124. */
  5125. var deadPaths = { api: false, share: false };
  5126. /** ttwid 只取一次(接口风控用;主页 HTML 不需要它) */
  5127. var ttwidCache = null;
  5128. var ttwidTried = false;
  5129. function logger() { return TikTokAuto.logger; }
  5130. /**
  5131. * 读一个连接的响应体。
  5132. *
  5133. * ★ 必须区分**正常流与错误流**:`getInputStream()` 在 4xx/5xx 时会直接抛异常,
  5134. * 于是「404 账号不存在」会被误判成「网络错误」。
  5135. * 从 `getErrorStream()` 读出来的 body 才是真正的原因(有时还带 JSON 错误码)。
  5136. */
  5137. function readBody(conn) {
  5138. var stream = null;
  5139. try {
  5140. var code = conn.getResponseCode();
  5141. stream = code >= 400 ? conn.getErrorStream() : conn.getInputStream();
  5142. if (!stream) {
  5143. return { code: code, body: '' };
  5144. }
  5145. var reader = new java.io.BufferedReader(new java.io.InputStreamReader(stream, 'UTF-8'));
  5146. var buffer = new java.lang.StringBuilder();
  5147. var line;
  5148. while ((line = reader.readLine()) !== null) {
  5149. buffer.append(line).append('\n');
  5150. }
  5151. reader.close();
  5152. return { code: code, body: String(buffer.toString()) };
  5153. } finally {
  5154. try { if (stream) { stream.close(); } } catch (ignored) { }
  5155. }
  5156. }
  5157. /**
  5158. * 发一个 GET。
  5159. * @returns { ok, status, body, error }
  5160. */
  5161. function get(url, headers, timeoutMs) {
  5162. var conn = null;
  5163. try {
  5164. conn = new java.net.URL(url).openConnection();
  5165. conn.setConnectTimeout(timeoutMs);
  5166. conn.setReadTimeout(timeoutMs);
  5167. conn.setInstanceFollowRedirects(true);
  5168. conn.setRequestProperty('User-Agent', UA);
  5169. conn.setRequestProperty('Accept-Language', 'en-US,en;q=0.9');
  5170. if (headers) {
  5171. for (var key in headers) {
  5172. if (headers.hasOwnProperty(key)) {
  5173. conn.setRequestProperty(key, headers[key]);
  5174. }
  5175. }
  5176. }
  5177. var result = readBody(conn);
  5178. return { ok: true, status: result.code, body: result.body };
  5179. } catch (error) {
  5180. return { ok: false, status: 0, body: '', error: String(error) };
  5181. } finally {
  5182. /* 一定要断开:不关的话连接会一直占着,几十个账号之后就容易出问题 */
  5183. try { if (conn) { conn.disconnect(); } } catch (ignoredToo) { }
  5184. }
  5185. }
  5186. /**
  5187. * 取 ttwid(TikTok 首页下发,接口风控用)。
  5188. * 取不到不算错 —— 只是那两条接口大概也走不通,直接降级到主页 HTML。
  5189. */
  5190. function ttwid() {
  5191. if (ttwidTried) {
  5192. return ttwidCache;
  5193. }
  5194. ttwidTried = true;
  5195. var conn = null;
  5196. try {
  5197. conn = new java.net.URL('https://www.tiktok.com/').openConnection();
  5198. conn.setConnectTimeout(API_TIMEOUT_MS);
  5199. conn.setReadTimeout(API_TIMEOUT_MS);
  5200. conn.setRequestProperty('User-Agent', UA);
  5201. conn.getResponseCode();
  5202. var fields = conn.getHeaderFields();
  5203. var cookies = fields ? fields.get('Set-Cookie') : null;
  5204. if (cookies) {
  5205. for (var i = 0; i < cookies.size(); i += 1) {
  5206. var one = String(cookies.get(i));
  5207. var idx = one.indexOf('ttwid=');
  5208. if (idx !== -1) {
  5209. ttwidCache = one.substring(idx).split(';')[0]; /* 形如 ttwid=xxx */
  5210. break;
  5211. }
  5212. }
  5213. }
  5214. } catch (error) {
  5215. logger().warn('resolve', '取 ttwid 失败(不影响后续降级):' + error);
  5216. } finally {
  5217. try { if (conn) { conn.disconnect(); } } catch (ignored) { }
  5218. }
  5219. return ttwidCache;
  5220. }
  5221. /** 从 JSON 对象里找 `userInfo.user`(别写死键路径,层级改过) */
  5222. function findUser(node, depth) {
  5223. if (depth > 8 || node === null || typeof node !== 'object') {
  5224. return null;
  5225. }
  5226. if (node.userInfo && node.userInfo.user && node.userInfo.user.id) {
  5227. return node.userInfo.user;
  5228. }
  5229. for (var key in node) {
  5230. if (node.hasOwnProperty(key)) {
  5231. var hit = findUser(node[key], depth + 1);
  5232. if (hit) {
  5233. return hit;
  5234. }
  5235. }
  5236. }
  5237. return null;
  5238. }
  5239. /** JSON 解析失败时用正则兜一层(整页 SSR 结构改版时不至于全废) */
  5240. function findUserByRegex(html, handle) {
  5241. var escaped = String(handle).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  5242. var matched = html.match(
  5243. new RegExp('"uniqueId":"' + escaped + '"[^}]*?"id":"(\\d+)"')
  5244. ) || html.match(
  5245. new RegExp('"id":"(\\d+)","shortId":"[^"]*","uniqueId":"' + escaped + '"')
  5246. );
  5247. if (!matched) {
  5248. return null;
  5249. }
  5250. return { id: matched[1], uniqueId: handle };
  5251. }
  5252. /** 统一校验:拿到的确实是这个 handle 的用户(网页版可能重定向到别名) */
  5253. function accept(user, wanted) {
  5254. if (!user || !user.id) {
  5255. return null;
  5256. }
  5257. var got = TikTokAuto.parse.username(user.uniqueId || '');
  5258. if (got !== '' && got.toLowerCase() !== String(wanted).toLowerCase()) {
  5259. logger().warn('resolve', '返回的是另一个账号(' + got + ' ≠ ' + wanted + '),丢弃');
  5260. return null;
  5261. }
  5262. return {
  5263. ok: true,
  5264. uid: String(user.id),
  5265. nickname: user.nickname || '',
  5266. secUid: user.secUid || ''
  5267. };
  5268. }
  5269. /* ---------------------------------------------------------- 三条路径 */
  5270. /** ① 官方用户详情接口 */
  5271. function viaApi(handle) {
  5272. var headers = {
  5273. 'Accept': 'application/json, text/plain, */*',
  5274. 'Referer': 'https://www.tiktok.com/@' + encodeURIComponent(handle)
  5275. };
  5276. var cookie = ttwid();
  5277. if (cookie) {
  5278. headers['Cookie'] = cookie;
  5279. }
  5280. var res = get('https://www.tiktok.com/api/user/detail/?uniqueId=' +
  5281. encodeURIComponent(handle), headers, API_TIMEOUT_MS);
  5282. if (!res.ok || res.status !== 200 || res.body === '') {
  5283. return null;
  5284. }
  5285. var data = null;
  5286. try { data = JSON.parse(res.body); } catch (error) { return null; }
  5287. return findUser(data, 0);
  5288. }
  5289. /** ② 老的分享接口 */
  5290. function viaShare(handle) {
  5291. var res = get('https://www.tiktok.com/node/share/user/@' + encodeURIComponent(handle) +
  5292. '?uniqueId=' + encodeURIComponent(handle),
  5293. { 'Accept': 'application/json, text/plain, */*' }, API_TIMEOUT_MS);
  5294. if (!res.ok || res.status !== 200 || res.body === '') {
  5295. return null;
  5296. }
  5297. var data = null;
  5298. try { data = JSON.parse(res.body); } catch (error) { return null; }
  5299. return findUser(data, 0);
  5300. }
  5301. /** ③ 主页整页 SSR(实测唯一可用的一条) */
  5302. function viaProfileHtml(handle) {
  5303. var res = get('https://www.tiktok.com/@' + encodeURIComponent(handle),
  5304. { 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' },
  5305. PAGE_TIMEOUT_MS);
  5306. if (!res.ok || res.status !== 200 || res.body === '') {
  5307. return null;
  5308. }
  5309. var matched = res.body.match(
  5310. /<script id="__UNIVERSAL_DATA_FOR_REHYDRATION__" type="application\/json">([\s\S]*?)<\/script>/
  5311. );
  5312. if (matched) {
  5313. var data = null;
  5314. try { data = JSON.parse(matched[1]); } catch (error) { data = null; }
  5315. if (data) {
  5316. var user = findUser(data, 0);
  5317. if (user) {
  5318. return user;
  5319. }
  5320. }
  5321. }
  5322. /* 结构改版 / 解析失败时的兜底 */
  5323. return findUserByRegex(res.body, handle);
  5324. }
  5325. /**
  5326. * 一个 username → uid。
  5327. *
  5328. * @returns { ok, uid, nickname, secUid, via, error }
  5329. * `via` 记录这次用的是哪条路径(api / share / html),看日志就知道接口有没有恢复。
  5330. */
  5331. function fetchUserId(username) {
  5332. var name = TikTokAuto.parse.username(username);
  5333. if (name === '') {
  5334. return { ok: false, error: '空用户名' };
  5335. }
  5336. var paths = [
  5337. { key: 'api', dead: deadPaths.api, run: viaApi,
  5338. label: '官方 user/detail' },
  5339. { key: 'share', dead: deadPaths.share, run: viaShare,
  5340. label: 'node/share' },
  5341. { key: 'html', dead: false, run: viaProfileHtml,
  5342. label: '主页 SSR' }
  5343. ];
  5344. for (var i = 0; i < paths.length; i += 1) {
  5345. var path = paths[i];
  5346. if (path.dead) {
  5347. continue;
  5348. }
  5349. var user = path.run(name);
  5350. if (user) {
  5351. var accepted = accept(user, name);
  5352. if (accepted) {
  5353. accepted.via = path.key;
  5354. return accepted;
  5355. }
  5356. }
  5357. /* 前两条失败就标记"本次运行不再试";主页是最后一条,没有可退的地方 */
  5358. if (path.key !== 'html') {
  5359. deadPaths[path.key] = true;
  5360. logger().info('resolve', path.label + ' 不可用,本次运行后续直接走主页 SSR');
  5361. }
  5362. }
  5363. return { ok: false, error: '三条路径都拿不到(账号可能不存在或已注销)' };
  5364. }
  5365. /** 随机间隔,让请求节奏不像机器 */
  5366. function pause() {
  5367. var span = GAP_MAX_MS - GAP_MIN_MS;
  5368. TikTokAuto.wait.tick(GAP_MIN_MS + Math.floor(Math.random() * (span + 1)));
  5369. }
  5370. /**
  5371. * 批量把 username 换成 uid。
  5372. *
  5373. * 三条原则:
  5374. * ① **一个失败不影响其余**(注销的号、网络抖动都只记一行);
  5375. * ② 每处理一个就回报进度(这个阶段比抓取慢,没有进度会让人以为卡死);
  5376. * ③ 可以被停止打断(`wait.tick` 里会检查停止标志);
  5377. * ④ 换不到的**不写进映射文件** —— 文件里全是可用的对应关系,比写一堆空值有用。
  5378. *
  5379. * @param usernames 用户名数组
  5380. * @param options { onProgress(done, total, name), shouldStop() }
  5381. * @returns { ok, map: [{username, uid, nickname}], failed: [{username, error}], stopped }
  5382. */
  5383. function resolveAll(usernames, options) {
  5384. var settings = options || {};
  5385. var log = logger();
  5386. var map = [];
  5387. var failed = [];
  5388. var total = usernames.length;
  5389. for (var i = 0; i < total; i += 1) {
  5390. if (settings.shouldStop && settings.shouldStop()) {
  5391. /* 注意措辞:这里的 shouldStop 只反映**时长护栏**,
  5392. * 不含"提取上限已到"(那个属于抓取阶段,不该阻止收尾)。
  5393. * 早先写成"收到停止",把"跑太久"和"用户点了停止"混为一谈,排查时很误导。 */
  5394. log.warn('resolve', '到达时长上限,已换 ' + map.length + '/' + total + ' 个');
  5395. return { ok: true, map: map, failed: failed, stopped: true };
  5396. }
  5397. var name = usernames[i];
  5398. if (settings.onProgress) {
  5399. settings.onProgress(i + 1, total, name);
  5400. }
  5401. var result = fetchUserId(name);
  5402. if (result.ok) {
  5403. map.push({ username: name, uid: result.uid, nickname: result.nickname });
  5404. /* 带上 via:这样一看日志就知道现在走的是哪条路径,
  5405. * 接口哪天恢复了也能立刻发现(不用等别人报告)。 */
  5406. log.info('resolve', name + ' → ' + result.uid + '(via ' + result.via + ')');
  5407. } else {
  5408. failed.push({ username: name, error: result.error });
  5409. log.warn('resolve', '换不到 ' + name + ':' + result.error);
  5410. }
  5411. if (i < total - 1) {
  5412. pause();
  5413. }
  5414. }
  5415. return { ok: true, map: map, failed: failed, stopped: false };
  5416. }
  5417. /** 把映射写成两列文本(TAB 分隔)。写入失败返回 false,不抛。 */
  5418. function writeMap(path, map) {
  5419. var lines = [];
  5420. for (var i = 0; i < map.length; i += 1) {
  5421. lines.push(map[i].username + '\t' + map[i].uid);
  5422. }
  5423. try {
  5424. files.write(path, lines.join('\n') + (lines.length > 0 ? '\n' : ''));
  5425. return true;
  5426. } catch (error) {
  5427. logger().error('resolve', '写映射文件失败 ' + path + ':' + error);
  5428. return false;
  5429. }
  5430. }
  5431. return {
  5432. fetchUserId: fetchUserId,
  5433. viaApi: viaApi,
  5434. viaShare: viaShare,
  5435. viaProfileHtml: viaProfileHtml,
  5436. resolveAll: resolveAll,
  5437. writeMap: writeMap,
  5438. GAP_MIN_MS: GAP_MIN_MS,
  5439. GAP_MAX_MS: GAP_MAX_MS
  5440. };
  5441. }());
  5442. /* ============================== src/idflow.js ============================== */
  5443. /**
  5444. * idflow.js — ID 提取的编排
  5445. *
  5446. * 与 `flow.js`(视频链路)并列,共用同一套基础设施:应用启动、弹窗守护、
  5447. * 坐标点击、`wait.tick()` 的停止、`bridge.report()` 的状态上报。
  5448. *
  5449. * ## 一轮跑完是什么样
  5450. *
  5451. * 启动应用 → 进信息流 → 对每条取材:
  5452. * 取材(列表型 / 文本型 / 逐个点开型)
  5453. * → 每个候选:去重判定 → 需要时打开用户页 → 过滤判定 → 写盘
  5454. * → 深度展开(第二层,最多一层)
  5455. * → 收尾:关文件、把 summary 上报给界面
  5456. *
  5457. * ## 三个必须处理好的"与视频链路不同"
  5458. *
  5459. * ① **总量未知**。粉丝列表可能几十万人,没有 `total` 可报。
  5460. * 所以状态条上的进度是「已收 N 个」而不是"N/M" —— 界面只读 `phase`,零改动。
  5461. *
  5462. * ② **"不限"是危险的**。界面里 `extractLimit = 0` 的语义是"不限制"。
  5463. * 但真跑起来没有上限就等于跑到没电。所以引擎**另加三道物理护栏**:
  5464. * 最大时长、单列表最大翻屏数(在 idsource 里)、连续无新内容即止。
  5465. * 到护栏时**如实报告**"因达到护栏而结束",绝不装作抓完了。
  5466. *
  5467. * ③ **停止时必须写文件**。用户在任意时刻点停,已经抓到的部分不能丢 ——
  5468. * 所以 `writer.close()` 放在 finally 里,无论怎么结束都会刷盘。
  5469. *
  5470. * 依赖:全部 ID 相关模块 + bridge / wait / ui / popups
  5471. */
  5472. TikTokAuto.idflow = (function () {
  5473. 'use strict';
  5474. /**
  5475. * 最近一次运行的 summary。
  5476. *
  5477. * 为什么挂模块级而不是只靠返回值:用户点停止时 StopSignal 会从
  5478. * `wait.tick()` 里抛出来,穿过整个流程到 main.js 的 catch ——
  5479. * 那时 `runSession` 的返回值根本用不上。main 从这里取已经统计好的数字,
  5480. * 否则界面会显示"提取 0 个"(实际抓到的都在文件里,只是没报上去)。
  5481. */
  5482. var lastSummary = null;
  5483. function logger() { return TikTokAuto.logger; }
  5484. /** 上报进度。只动 phase(界面状态条只读它),total 恒为 0 —— 这里没有总量。 */
  5485. function progress(phase, done) {
  5486. TikTokAuto.bridge.report({
  5487. status: 'running',
  5488. phase: phase + '(已收 ' + done + ' 个)',
  5489. done: done,
  5490. total: 0
  5491. });
  5492. }
  5493. /**
  5494. * 把界面配置里的 `id` 段收拢成引擎侧要用的形状。
  5495. *
  5496. * 界面已经做过一遍归一化(Sources.normalize),但**引擎不能假设界面一定对**:
  5497. * 手写的 engine-config.json、旧版本残留、被改坏的文件都会进来。
  5498. * 这里只做"缺了就补默认",不做语义校验 —— 语义校验在 filters 与 idsource 里。
  5499. */
  5500. function readSettings(config) {
  5501. var id = config.id || {};
  5502. var filter = id.filter || {};
  5503. var output = id.output || {};
  5504. var guard = id.guard || {};
  5505. return {
  5506. sources: (id.sources instanceof Array ? id.sources : []),
  5507. filter: {
  5508. private: filter.private === true,
  5509. noAvatar: filter.noAvatar === true,
  5510. langRegion: filter.langRegion || 'any',
  5511. nicknameCheck: filter.nicknameCheck || 'any',
  5512. follow: filter.follow || {},
  5513. fans: filter.fans || {},
  5514. likes: filter.likes || {},
  5515. videos: filter.videos || {}
  5516. },
  5517. output: {
  5518. dir: output.dir || '/sdcard/Download/tiktok_app',
  5519. historyPath: output.historyPath || (output.dir || '/sdcard/Download/tiktok_app') + '/uid_extracted.txt',
  5520. dedupeHistory: output.dedupeHistory !== false,
  5521. dryRun: output.dryRun === true,
  5522. /* 缺省开:数字 uid 在界面上读不到,不在这里换就得事后另跑一次 */
  5523. resolveUid: output.resolveUid !== false
  5524. },
  5525. limit: Number(output.limit) || 0,
  5526. maxRuntimeMs: Number(guard.maxRuntimeMs) || 30 * 60 * 1000,
  5527. /* 解析 uid 的独立预算:默认 10 分钟 ≈ 240 个(每个约 2.5 秒)。
  5528. * 与抓取预算分开,因为它们是先后发生的两件事(见下)。 */
  5529. resolveMaxMs: Number(guard.resolveMaxMs) || 10 * 60 * 1000
  5530. };
  5531. }
  5532. /**
  5533. * 跑一轮 ID 提取。
  5534. *
  5535. * @returns 结构化 summary(与视频链路同族,供 bridge/main 复用)
  5536. */
  5537. function runSession(config) {
  5538. var log = logger();
  5539. var profileApi = TikTokAuto.profile;
  5540. var settings = readSettings(config);
  5541. var began = Date.now();
  5542. var summary = {
  5543. ok: false,
  5544. task: 'id',
  5545. extracted: 0, /* 本次写进产物文件的个数 */
  5546. skipped: 0, /* 因过滤被排除 */
  5547. duplicates: 0, /* 因去重被排除 */
  5548. openedProfiles: 0, /* 打开过多少次用户页(成本指标) */
  5549. sources: [],
  5550. warnings: [],
  5551. resultFile: '',
  5552. errors: []
  5553. };
  5554. lastSummary = summary;
  5555. var seen = TikTokAuto.uidset.create();
  5556. var expandQueue = [];
  5557. var softReported = {};
  5558. var writer = null;
  5559. /* —— 停止条件:上限 / 时长 / 用户叫停,三处统一问它 —— */
  5560. function limitReached() {
  5561. return settings.limit > 0 && summary.extracted >= settings.limit;
  5562. }
  5563. function timeUp() {
  5564. return Date.now() - began >= settings.maxRuntimeMs;
  5565. }
  5566. function shouldStop() {
  5567. if (limitReached()) {
  5568. return true;
  5569. }
  5570. if (timeUp()) {
  5571. if (!summary.warnings.length || summary.warnings[summary.warnings.length - 1] !== 'TIME_LIMIT') {
  5572. log.warn('idflow', '已达单次运行时长上限(' +
  5573. Math.round(settings.maxRuntimeMs / 60000) + ' 分钟),准备收尾');
  5574. summary.warnings.push('TIME_LIMIT');
  5575. }
  5576. return true;
  5577. }
  5578. return false;
  5579. }
  5580. /**
  5581. * 给一个候选拿到资料。三条路径按代价从低到高:
  5582. * ① 调用方已经读过(评论者那只,`_profile`);
  5583. * ② 列表行自带计数(搜索用户列表有"粉丝 · 获赞");
  5584. * ③ 打开用户页(最贵,约 3~5 秒)。
  5585. */
  5586. function obtainProfile(row) {
  5587. if (row._profile) {
  5588. return row._profile;
  5589. }
  5590. if (!TikTokAuto.filters.needsProfile(settings.filter)) {
  5591. /* 不需要任何用户页才能判的条件 → 绝不为此付一次开页代价 */
  5592. if (row.fans !== undefined || row.likes !== undefined) {
  5593. return {
  5594. uid: row.uid,
  5595. nickname: row.nickname,
  5596. signature: '',
  5597. follow: null,
  5598. fans: row.fans === undefined ? null : row.fans,
  5599. likes: row.likes === undefined ? null : row.likes,
  5600. isPrivate: false,
  5601. hasAvatar: null
  5602. };
  5603. }
  5604. return null;
  5605. }
  5606. /* 从列表行点进去最快(且实测返回后列表位置保持)。
  5607. * ★ 判据是"当前在**任一**种列表页上",不是只有粉丝列表:
  5608. * 搜索结果的用户列表也一样能直接点行进去。
  5609. * 早先写死成 `where() === 'user-list'`,于是搜索取材时
  5610. * 每个候选都要**重新搜一次**(约 15 秒/个)—— 慢十倍且没有必要。
  5611. * ★ 点开失败时不再退回搜索:在列表遍历中途跳去搜索,
  5612. * 会把"当前在哪个列表的第几行"整个丢掉,之后再也接不回原来的列表。 */
  5613. if (row.node && row.x !== undefined && profileApi.isListPage()) {
  5614. if (profileApi.openRow(row)) {
  5615. summary.openedProfiles += 1;
  5616. var data = profileApi.readProfile();
  5617. profileApi.backToList();
  5618. return data;
  5619. }
  5620. log.warn('idflow', '点不开 @' + row.uid + ' 的主页,跳过(不回搜索,避免丢失列表位置)');
  5621. return null;
  5622. }
  5623. var profile = profileApi.openBySearch(row.uid, config);
  5624. if (profile) {
  5625. summary.openedProfiles += 1;
  5626. }
  5627. return profile;
  5628. }
  5629. /**
  5630. * 收尾阶段:把本轮抓到的 username 换成数字 uid,另存一份映射文件。
  5631. *
  5632. * 产物分工:
  5633. * `uid_*.txt` 一行一个 username —— 给"搜人/私信"这类下游用
  5634. * `uid_map_*.txt` `username<TAB>uid` 两列 —— 给"要平台主键"的下游用
  5635. * 两份都留着,因为它们的用途真的不同,而且映射只有这一轮的数据。
  5636. */
  5637. function resolvePhase(target, settings) {
  5638. var names = writer.list();
  5639. if (names.length === 0) {
  5640. return;
  5641. }
  5642. var mapFile = settings.output.dir + '/' +
  5643. TikTokAuto.parse.timestampName('uid_map', new Date());
  5644. log.step('idflow', '开始解析数字 uid:' + names.length + ' 个(每个约 2~3 秒)');
  5645. var resolveStarted = Date.now();
  5646. var result = TikTokAuto.resolve.resolveAll(names, {
  5647. /**
  5648. * ★ 这里**只用自己的预算**,不继承抓取阶段的任何停止条件。
  5649. *
  5650. * 两次踩同一个坑,两次都是"收尾继承了抓取的条件":
  5651. * ① 先是 `shouldStop()` 里的 `limitReached()` —— 抓满 N 个后解析 0 个;
  5652. * ② 再是 `shouldStop()` 里的 `timeUp()` —— 抓取把 30 分钟用满后解析 0 个
  5653. * (实测:83 个候选 × 每人约 8 秒 = 30 分钟,收尾一点时间都不剩)。
  5654. *
  5655. * 抓取阶段的"够了 / 到点了"和收尾阶段的"必须做完"是相反的要求。
  5656. * 所以这里从**进入解析的那一刻**重新计时,用一个独立预算(默认 10 分钟)。
  5657. * 用户主动停止仍然有效 —— 那是 StopSignal 从 `wait.tick` 抛出来的。
  5658. */
  5659. shouldStop: function () {
  5660. return Date.now() - resolveStarted >= settings.resolveMaxMs;
  5661. },
  5662. onProgress: function (done, total) {
  5663. progress('解析 uid ' + done + '/' + total, target.extracted);
  5664. }
  5665. });
  5666. if (result.stopped) {
  5667. target.warnings.push('解析 uid 到达自身时长上限(' +
  5668. Math.round(settings.resolveMaxMs / 60000) + ' 分钟),只换到 ' +
  5669. result.map.length + '/' + names.length + ' 个');
  5670. }
  5671. target.resolved = result.map.length;
  5672. target.unresolved = result.failed.length;
  5673. if (result.map.length > 0 && TikTokAuto.resolve.writeMap(mapFile, result.map)) {
  5674. target.mapFile = mapFile;
  5675. }
  5676. if (result.failed.length > 0) {
  5677. target.warnings.push(result.failed.length +
  5678. ' 个账号换不到数字 uid(账号可能已注销,详见日志)');
  5679. }
  5680. log.step('idflow', 'uid 解析完成:成功 ' + result.map.length +
  5681. ' / 失败 ' + result.failed.length);
  5682. }
  5683. /** 处理一个候选:去重 → 过滤 → 写盘 → 记下要不要展开 */
  5684. function handleRow(row) {
  5685. var parse = TikTokAuto.parse;
  5686. var uid = parse.username(row.uid);
  5687. if (uid === '' || !parse.looksLikeUsername(uid)) {
  5688. return;
  5689. }
  5690. if (seen.has(uid)) {
  5691. summary.duplicates += 1;
  5692. return;
  5693. }
  5694. if (shouldStop()) {
  5695. return;
  5696. }
  5697. progress('正在读取 @' + uid, summary.extracted);
  5698. var profile = obtainProfile(row);
  5699. var verdict = TikTokAuto.filters.judge(profile, settings.filter);
  5700. if (!verdict.ok) {
  5701. summary.skipped += 1;
  5702. log.info('idflow', '跳过 @' + uid + ':' + verdict.reason);
  5703. seen.add(uid);
  5704. return;
  5705. }
  5706. if (verdict.soft && !softReported[verdict.soft]) {
  5707. softReported[verdict.soft] = true;
  5708. summary.warnings.push(verdict.soft);
  5709. log.warn('idflow', verdict.soft);
  5710. }
  5711. seen.add(uid);
  5712. writer.write(uid);
  5713. summary.extracted += 1;
  5714. /* 收录时记下依据。这不只是"好看":用户事后想核对"这个人为啥被收进来"时,
  5715. * 日志是唯一的答案;没有它就只能重新跑一遍。 */
  5716. log.info('idflow', '收录 @' + uid + (profile
  5717. ? '(粉丝 ' + profile.fans + ' / 关注 ' + profile.follow +
  5718. ' / 获赞 ' + profile.likes + ')'
  5719. : '(未打开用户页,按列表现有资料通过)'));
  5720. if (row.expandTo) {
  5721. expandQueue.push({ uid: uid, which: row.expandTo });
  5722. }
  5723. }
  5724. try {
  5725. log.step('idflow', '=== TikTok ID 提取开始 ===');
  5726. log.info('idflow', '取材 ' + settings.sources.length + ' 条,过滤:' +
  5727. TikTokAuto.filters.describe(settings.filter));
  5728. TikTokAuto.bridge.report({ status: 'running', phase: '准备', done: 0, total: 0 });
  5729. if (!TikTokAuto.uidset.ensureDir(settings.output.dir)) {
  5730. summary.errors.push('无法创建结果目录:' + settings.output.dir);
  5731. return summary;
  5732. }
  5733. /* 文件名只生成一次:分两次调 timestampName 有可能跨过整分钟,
  5734. * 于是"写进去的文件"和"报给界面的文件名"不是同一个 —— 这种错几乎无法复现。 */
  5735. var runFile = settings.output.dir + '/' +
  5736. TikTokAuto.parse.timestampName('uid', new Date());
  5737. writer = TikTokAuto.uidset.createWriter({
  5738. runPath: runFile,
  5739. historyPath: settings.output.historyPath,
  5740. dryRun: settings.output.dryRun,
  5741. historyEnabled: settings.output.dedupeHistory
  5742. });
  5743. summary.resultFile = settings.output.dryRun ? '' : runFile;
  5744. if (settings.output.dedupeHistory) {
  5745. TikTokAuto.uidset.loadHistory(seen, settings.output.historyPath);
  5746. }
  5747. /* —— 启动与就位(复用视频链路的成熟实现) —— */
  5748. progress('启动应用', 0);
  5749. if (!TikTokAuto.flow.launchApp(config)) {
  5750. summary.errors.push('启动应用失败');
  5751. return summary;
  5752. }
  5753. progress('进入信息流', 0);
  5754. if (!TikTokAuto.profile.ensureFeed(config)) {
  5755. summary.errors.push('未能进入信息流首页');
  5756. return summary;
  5757. }
  5758. if (settings.sources.length === 0) {
  5759. summary.errors.push('没有配置任何取材(界面里至少需要一条)');
  5760. return summary;
  5761. }
  5762. /* —— 第一层:逐条取材 —— */
  5763. for (var i = 0; i < settings.sources.length; i += 1) {
  5764. if (shouldStop()) {
  5765. break;
  5766. }
  5767. var source = settings.sources[i];
  5768. var before = summary.extracted;
  5769. progress('取材 ' + (i + 1) + '/' + settings.sources.length, summary.extracted);
  5770. var ctx = {
  5771. config: config,
  5772. seen: seen,
  5773. shouldStop: shouldStop,
  5774. onSeenProfile: function (prof) {
  5775. log.info('idflow', '取材账号 @' + prof.uid + ':粉丝 ' + prof.fans +
  5776. ' / 关注 ' + prof.follow + ' / 获赞 ' + prof.likes);
  5777. },
  5778. onRow: function (row) {
  5779. /* 深度只在第一层记:第二层不再展开(上限两层) */
  5780. if (source.expand && !row.expandTo) {
  5781. row.expandTo = source.expand;
  5782. }
  5783. handleRow(row);
  5784. }
  5785. };
  5786. var handled = 0;
  5787. try {
  5788. handled = TikTokAuto.idsource.run(source, ctx);
  5789. } catch (error) {
  5790. if (TikTokAuto.bridge.isStopSignal(error)) {
  5791. throw error;
  5792. }
  5793. log.error('idflow', '取材失败(' + TikTokAuto.idsource.describe(source) + '):' + error);
  5794. summary.errors.push('取材失败:' + TikTokAuto.idsource.describe(source));
  5795. }
  5796. summary.sources.push({
  5797. label: TikTokAuto.idsource.describe(source),
  5798. handled: handled,
  5799. added: summary.extracted - before
  5800. });
  5801. log.info('idflow', '取材完成:' + TikTokAuto.idsource.describe(source) +
  5802. ',处理 ' + handled + ' 行,新增 ' + (summary.extracted - before) + ' 个');
  5803. }
  5804. /* —— 第二层:深度展开 ——
  5805. * 与第一层分开跑,是为了避免嵌套列表上下文(见 idsource.js 文件头)。
  5806. * 展开进来的人**同样要过一遍过滤** —— 否则"粉丝的粉丝"会绕过所有条件。 */
  5807. if (expandQueue.length > 0 && shouldStop() && limitReached()) {
  5808. /* 上限在主层就满了 → 展开根本没跑。这必须说出来:
  5809. * 用户明明勾了「再取粉丝列表」,看到"提取满 3 个"很容易以为展开做了。 */
  5810. summary.warnings.push('提取上限已到,深度展开(再取粉丝列表)本次没有执行');
  5811. log.warn('idflow', '提取上限已到,' + expandQueue.length + ' 个待展开的账号本次没有展开');
  5812. }
  5813. if (expandQueue.length > 0 && !shouldStop()) {
  5814. log.step('idflow', '开始深度展开:' + expandQueue.length + ' 个账号');
  5815. for (var e = 0; e < expandQueue.length; e += 1) {
  5816. if (shouldStop()) {
  5817. break;
  5818. }
  5819. var target = expandQueue[e];
  5820. progress('展开 @' + target.uid + ' 的' +
  5821. (target.which === 'following' ? '关注' : '粉丝'), summary.extracted);
  5822. var opened = profileApi.openBySearch(target.uid, config);
  5823. if (!opened) {
  5824. log.warn('idflow', '展开时打不开 @' + target.uid);
  5825. continue;
  5826. }
  5827. summary.openedProfiles += 1;
  5828. if (!profileApi.openList(target.which, config)) {
  5829. continue;
  5830. }
  5831. profileApi.switchListTab(target.which);
  5832. var subCtx = {
  5833. config: config,
  5834. seen: seen,
  5835. shouldStop: shouldStop,
  5836. onSeenProfile: function () { },
  5837. onRow: handleRow /* 注意:这里**不再**设置 expandTo,所以不会递归 */
  5838. };
  5839. try {
  5840. TikTokAuto.idsource.walkList(subCtx);
  5841. } catch (error2) {
  5842. if (TikTokAuto.bridge.isStopSignal(error2)) {
  5843. throw error2;
  5844. }
  5845. log.warn('idflow', '展开 @' + target.uid + ' 失败:' + error2);
  5846. }
  5847. }
  5848. }
  5849. summary.ok = summary.extracted > 0;
  5850. if (!summary.ok && summary.errors.length === 0) {
  5851. summary.errors.push('没有提取到任何 ID(取材范围内都被过滤或去重掉了)');
  5852. }
  5853. /* —— 收尾:username → 数字 uid ——
  5854. * 数字 uid 在 App 界面上任何地方都读不到,只能取网页版 SSR(见 resolve.js)。
  5855. * 它比抓取慢(每个约 2.5 秒)且依赖网络,所以三条边界:
  5856. * ① 只在**主产物已经落盘之后**做(先 close 一次,保证文件完整);
  5857. * ② 失败**只记 warning**,绝不把已经抓到的 username 算成失败;
  5858. * ③ 换不到的**不写进映射文件** —— 文件里全是可用的对应关系。
  5859. */
  5860. if (settings.output.resolveUid && summary.extracted > 0 && !settings.output.dryRun) {
  5861. try {
  5862. writer.close();
  5863. resolvePhase(summary, settings);
  5864. } catch (error) {
  5865. if (TikTokAuto.bridge.isStopSignal(error)) {
  5866. summary.warnings.push('解析 uid 阶段被停止,映射文件只含已换到的部分');
  5867. throw error;
  5868. }
  5869. log.warn('idflow', '解析 uid 失败(不影响已抓到的 username):' + error);
  5870. summary.warnings.push('解析 uid 失败:' + error);
  5871. }
  5872. }
  5873. } finally {
  5874. /* 无论是正常结束、出错、还是用户叫停,都必须把已经抓到的刷进文件 ——
  5875. * "点了停止但文件是空的"是最不能接受的一种失败。 */
  5876. if (writer) {
  5877. var written = writer.close();
  5878. if (written !== summary.extracted) {
  5879. log.warn('idflow', '写盘数量与统计不符:文件 ' + written + ' / 统计 ' + summary.extracted);
  5880. }
  5881. if (writer.hadFailure()) {
  5882. summary.warnings.push('结果文件写入失败(部分结果可能没有落盘)');
  5883. }
  5884. }
  5885. /* 一个都没收时**不报文件名**:那个文件根本没被创建(writer 只在有内容时 append),
  5886. * 报一个不存在的路径,用户去文件管理器找会以为"抓到了但丢了"。 */
  5887. if (summary.extracted === 0) {
  5888. summary.resultFile = '';
  5889. }
  5890. if (limitReached()) {
  5891. summary.warnings.push('已提取满 ' + settings.limit + ' 个,按设置停止');
  5892. }
  5893. log.step('idflow', '=== 提取结束:新增 ' + summary.extracted +
  5894. ' 个,跳过 ' + summary.skipped + ' 个,重复 ' + summary.duplicates +
  5895. ' 个,耗时 ' + Math.round((Date.now() - began) / 1000) + ' 秒 ===');
  5896. }
  5897. return summary;
  5898. }
  5899. return {
  5900. runSession: runSession,
  5901. readSettings: readSettings,
  5902. lastSummary: function () {
  5903. return lastSummary;
  5904. }
  5905. };
  5906. }());
  5907. /* ============================== src/main.js ============================== */
  5908. /**
  5909. * main.js — 入口与收尾
  5910. *
  5911. * 入口只负责三件事:读配置、开守护线程、跑流程。
  5912. * 收尾负责四件**必须保证**的事:
  5913. * 1. 无论成功失败都打印 `__END__`(否则 PC 侧执行器会一直等到超时);
  5914. * 2. 关掉守护线程(`threads.shutDownAll()`,否则进程不退出);
  5915. * 3. 打印一行机器可读的 RESULT,让 PC 侧能直接断言而不是解析自然语言日志;
  5916. * 4. 向界面宿主上报终态(被界面唤起时才有这一步,见 bridge.js)。
  5917. *
  5918. * 依赖:全部模块
  5919. */
  5920. TikTokAuto.main = (function () {
  5921. 'use strict';
  5922. var RESULT_MARKER = '__END__';
  5923. function emptySummary(reason) {
  5924. return {
  5925. ok: false,
  5926. attemptedVideos: 0,
  5927. postedComments: 0,
  5928. details: [],
  5929. errors: [reason]
  5930. };
  5931. }
  5932. /**
  5933. * 去掉占位原因。
  5934. * `emptySummary('未开始')` 的那条只在"一次都没跑起来"时才有意义;
  5935. * 流程已经跑过一段后再追加真实原因,留着它会让界面把"未开始"显示成第一条错误。
  5936. */
  5937. function dropPlaceholder(errors) {
  5938. var kept = [];
  5939. for (var i = 0; i < errors.length; i += 1) {
  5940. if (errors[i] !== '未开始') {
  5941. kept.push(errors[i]);
  5942. }
  5943. }
  5944. return kept;
  5945. }
  5946. /**
  5947. * 按 `config.task` 分派。
  5948. *
  5949. * 入口只有这一个分岔点:两条链路都走同一套"配置 → 守护线程 → 流程 → 终态上报",
  5950. * 差别只在中间调哪个 runSession。所以 `__END__`、RESULT、终态上报这些收尾逻辑
  5951. * **一份就够** —— 各写一份的话,两边的收尾行为迟早会不一致。
  5952. */
  5953. function runByTask(config) {
  5954. if (config.task === 'id') {
  5955. return TikTokAuto.idflow.runSession(config);
  5956. }
  5957. return TikTokAuto.flow.runSession(config);
  5958. }
  5959. /**
  5960. * @param injected PC 侧注入的配置覆盖项
  5961. * @returns 结构化结果
  5962. */
  5963. function run(injected) {
  5964. var log = TikTokAuto.logger;
  5965. var bridge = TikTokAuto.bridge;
  5966. var summary = emptySummary('未开始');
  5967. var guardState = null;
  5968. var config = null;
  5969. /* 是"用户叫停"还是"出错了",收尾时的上报状态不同 */
  5970. var stoppedByUser = false;
  5971. try {
  5972. log.step('main', '=== TikTok 自动互动开始 ===');
  5973. bridge.report({ status: 'running', phase: '准备', done: 0, total: 0 });
  5974. try {
  5975. config = TikTokAuto.config.resolve(injected);
  5976. } catch (configError) {
  5977. log.error('main', '配置无效:' + configError.message + ' / detail=' + JSON.stringify(configError.detail));
  5978. summary = emptySummary('配置无效:' + configError.message);
  5979. return summary;
  5980. }
  5981. bridge.report({ phase: '启动应用', total: config.task === 'id' ? 0 : config.target.maxVideos });
  5982. guardState = TikTokAuto.popups.startGuard(config, log);
  5983. var guardUp = TikTokAuto.wait.waitFor('guard', function () {
  5984. return guardState.passes >= 1 ? true : null;
  5985. }, config.guard.firstScanTimeoutMs, 200);
  5986. if (!guardUp) {
  5987. log.warn('guard', '守护线程首次巡检未确认,继续执行(不阻塞主流程)');
  5988. }
  5989. summary = runByTask(config);
  5990. log.step('main', '=== 流程结束,耗时 ' + log.elapsedMs() + 'ms ===');
  5991. } catch (error) {
  5992. if (bridge.isStopSignal(error)) {
  5993. /* 停止是**正常收尾**,不是崩溃:不记 ERROR、不把堆栈当异常信息 */
  5994. stoppedByUser = true;
  5995. log.warn('main', '收到停止请求,提前结束本次运行');
  5996. /* ID 提取的统计在 idflow 里已经算好了(含刚刷盘的那个数),
  5997. * 这里取它 —— 否则界面会显示"提取 0 个",而文件里明明有内容。 */
  5998. var partial = TikTokAuto.idflow ? TikTokAuto.idflow.lastSummary() : null;
  5999. if (partial) {
  6000. summary = partial;
  6001. }
  6002. summary.ok = false;
  6003. summary.errors = dropPlaceholder(summary.errors || []);
  6004. summary.errors.push('用户停止');
  6005. } else {
  6006. var detail = error && error.stack ? String(error.stack) : String(error);
  6007. log.error('main', '未捕获异常:' + detail);
  6008. summary.ok = false;
  6009. summary.errors.push(String(error));
  6010. }
  6011. } finally {
  6012. TikTokAuto.popups.stopGuard(guardState, log);
  6013. log.result(summary);
  6014. /* 先把终态交给宿主,再收线程:顺序反了的话,界面可能永远等不到"结束",
  6015. * 状态条会一直显示"运行中"。 */
  6016. bridge.finish(
  6017. stoppedByUser ? 'stopped' : (summary.ok ? 'finished' : 'failed'),
  6018. summary
  6019. );
  6020. // 顺序很重要:先让日志刷出去,再收线程,最后打结束标记
  6021. try {
  6022. threads.shutDownAll();
  6023. } catch (ignored) {
  6024. // 没有线程在跑时该调用可能抛错,忽略即可
  6025. }
  6026. console.log(RESULT_MARKER);
  6027. }
  6028. return summary;
  6029. }
  6030. return {
  6031. RESULT_MARKER: RESULT_MARKER,
  6032. run: run
  6033. };
  6034. }());
  6035. /* PC 侧下发时可替换下面这一行的 null 来注入配置覆盖项 */
  6036. var INJECTED_CONFIG = /*TTA_INJECTED_CONFIG*/null;
  6037. /* 被界面宿主唤起时(engines.execScriptFile 带 --config/--run-id 参数),
  6038. * fromExecArgv 会把参数翻译成「配置路径 + 桥的开关」;独立运行时是空操作。 */
  6039. TikTokAuto.main.run(TikTokAuto.bridge.fromExecArgv(INJECTED_CONFIG));
  6040. }());