From f2dcc4c0cf5ee9df4cb3818b5dfcbb38da7aee01 Mon Sep 17 00:00:00 2001 From: mzl Date: Mon, 12 Jan 2026 18:58:23 +0800 Subject: [PATCH] ts --- app/__pycache__/__init__.cpython-311.pyc | Bin 0 -> 163 bytes app/__pycache__/auth_manager.cpython-311.pyc | Bin 0 -> 11849 bytes app/__pycache__/config.cpython-311.pyc | Bin 0 -> 4148 bytes app/__pycache__/log_manager.cpython-311.pyc | Bin 0 -> 5495 bytes app/__pycache__/scraper.cpython-311.pyc | Bin 0 -> 38282 bytes app/__pycache__/user_resolver.cpython-311.pyc | Bin 0 -> 8976 bytes app/__pycache__/web.cpython-311.pyc | Bin 0 -> 23255 bytes app/auth_manager.py | 171 ++ app/log_manager.py | 186 +-- app/scraper.py | 56 +- app/user_resolver.py | 153 ++ app/web.py | 1458 +++++------------ 小红书数据采集器使用指南.md | 353 ++++ 13 files changed, 1183 insertions(+), 1194 deletions(-) create mode 100644 app/__pycache__/__init__.cpython-311.pyc create mode 100644 app/__pycache__/auth_manager.cpython-311.pyc create mode 100644 app/__pycache__/config.cpython-311.pyc create mode 100644 app/__pycache__/log_manager.cpython-311.pyc create mode 100644 app/__pycache__/scraper.cpython-311.pyc create mode 100644 app/__pycache__/user_resolver.cpython-311.pyc create mode 100644 app/__pycache__/web.cpython-311.pyc create mode 100644 app/auth_manager.py create mode 100644 app/user_resolver.py create mode 100644 小红书数据采集器使用指南.md diff --git a/app/__pycache__/__init__.cpython-311.pyc b/app/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4468a8dca658b9654c78d2f4bb7eb41df36214a4 GIT binary patch literal 163 zcmZ3^%ge<81oO|QWSRi!#~=<2FhUuhK}x1Gq%cG=q%a0EXfjo?8tNJ78Te^3-r|mr zFH0>d&dkq?k6+2~8KmQvl74PpiGE6ANuqv5Msa*WQGS+wVnKm^e0*kJW=VX!UP0wA n4x8Nkl+v73yCOEA7LZNFd_dv@Gb1D813u9PZV)VD0SW;CgdZm? literal 0 HcmV?d00001 diff --git a/app/__pycache__/auth_manager.cpython-311.pyc b/app/__pycache__/auth_manager.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..132e7dfcf1b7429bb37f4460b1dd810aaad92925 GIT binary patch literal 11849 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 I09h#h52FnRSO5S3 literal 0 HcmV?d00001 diff --git a/app/__pycache__/config.cpython-311.pyc b/app/__pycache__/config.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f8337564cf9816f918d3c6637633e1d7bcb07dbb GIT binary patch literal 4148 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 literal 0 HcmV?d00001 diff --git a/app/__pycache__/log_manager.cpython-311.pyc b/app/__pycache__/log_manager.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..86e880c87cbd1c71f2c60c829700743728f0cc2d GIT binary patch literal 5495 zcmb7IeQXrR6`#G`y^p;;+lOszz=kU(kOK_)upxnn##fR+>s+KHO&U{rIo+VuC{m@qH|ty9 z8WS~h>o+rRX5P-e_kM5Q_;Zg(AW$B>Dn)BugnW;ka^Z{yb4!E9D3M4Pkr>GqW8$_h z8-r~&#&)r^=DIjq^IbgDT+AM~ciHJ2KIVu!yPR=Xmn$xG36y7#x#MD&$PgQOhDeUL ziR3(CBjhyv8Kui3u`dw6tM4xKW67RSJe&yk$jZ9ZUO|}ra-jJ84<|of9Db+x(a9SZKNebjQJBMLDqWh zHAN0fp{R7@;z5`@G4$s3zaA(K9Vrf;^GRVXydK`GuIp8k2^>8%eEsjAOr8B(@z9Zp zv4N>ehkZfc#2-KVe&EfC3kN1oeIp1{XU|U@{R6ys`rUV@Kfz5*96ejS_}0W1$EPk0 zPklCYG6zgAm<`2J4Q-#1Y!c})$alw|Mva4LmfUV$#%845)cQ7sheukADqyROV^1Yv3r$N(uc`m&9| z1>az>8_{tL}VJ1{H(GVCoU#;d(U zyASPuZ~swgES{@go3CD5A~siTp>grx)}ieMalzozB^&QrGVZM(mPX>47tU?WdN=31 zoAchyFw?W~!t3J=OGfr)yob{x>1=~PqYQI}Cf}#qKHipj>E!ddrib!P4`rJ+oC^$d zBd!t;OXxaEZlZ+SNM?EWZQZ$Z=e}cf;LZ*a+woUoVGQJ`?eO(y z8v=FYvb`4PywWCCyZ7 zVy>3x>EH>zi03=SS17rwpIfKJQ#!L-Sq>7E79hGqmEmZYRD>wz0BTB(_-z!Vx)=+q zTBt9D`GNHGG+NPb(?vO<_9=2GtVW_y-5yOyazgWaD4_9B-X%*>MKz%9fMy~bmkn&M z#J8@(w{lWnJVjrkpjLD?k=(7jqH3}`sl>yY?oi}ZEF6K1#K1WV`&QP#ObgI;F{Fmy zkmW z&?rXb5&Q+}#WXC<;;g(@F^K#bHn)9^gJ*_2TcU7J9j*B%AC>Ax3E&6)f`OdF` z=Y!YUIpSd$RqT~jTwsmk$~p$9 z&eKFzXCbN59W&`{IX!@UR>9*kV)`z0%=7cekKlg}X3Pix>&-&Z4Kij=(KIuEgs@5j zRIy|1J}%8Fe40&j688kitVBaQoLbN~^Ro=@`JR&ItcYbuW#(47-Z~FP%#(K))r_*Z za(3mo8<2j$h9{CyELkJTm^om19Bb>>{?urZ<{&TRgUUl7NqHCurf~>u<>$!hKoUT< zK(VK6LdIqwI^UN{$%@i|40;aRA*GWOaQepN1Wg;2Ex3@UpqwL4=OGLzdsWmqWQabW zaTY%f-F|NcG7XU%fUUA%`vFp{YZ~c192^N=t6O`uZtb~fwr*{%?#X=JlZE=lqdPv> zad_v*&O*b|l7sUKB?4rSF9}4f$J1_2;b*G{pBi416PJGH@eXytxXVXL0RehVY`Q8o z9sPAqY{`o)S+Qljdcknp$m0k15ADxz$2?c7TeH=zaBz=p&uGW?WfIwy8{o+|ZNy_6 zL|hY_uZqn%aamqmmKB$kTxId}9XIi9x6N11zI;xL!qu|Um>`gv`a#h^rr5DEu!X&{ zh5Log@lT!sYB_Y#f$5=}hQ=s5H`GQ!XQ!zcsM9ELg_sv`|DR(S>E8Vk!Bb2SqO$2D zhDR{eBUIdgVRxO6#N@DI$-*cH{Xu;f2;}^R!E9(SAZGG)kJ9<_6gm&GCsewHM+fF3 zM)oxGhWRF_c%BY6JI;mBFe{PCzJhc^)pH*RvgtPRj)z^@%rm`;YAtqq)Wg7p4253r3&)HR zr3uC2NkTEFAd(bRwDJOwQ$%UTHoX9$dn!oKw(2K92FxGaYj_Bq^N=bR4>t%aSt#(U zO2kSUt5Co^#K-agWh)VEJecaoh_P?nt-nflghEm>VyL0J4=(Va1_jYX^G4&+SdU!} zh;G;RrtnIMXAaF%6a<*g!Wl#V^Fei>yo{r!a_%~xF!Z}c`7I0}2-L5EfVVMBiE}bs zi6E&XOiPw5uYA5E?g9EM5Z3_x6-Z6C{1ixY*7_94s_gxr@5sWe^(ommFrch^$xY}doBK4vz!!!X&l=yN3kSvAt zD6u`JY$c*JO2pW5OnV)h+4#X^;)J&4vFz9xWp-w(!RZvS*sCg4U6GP@Yb*46Ql*=F z>)!kQryETG1WDP+)Ydi*{yyD(>_$|Lgqyw#Aad;dpJ_+p(sNJ zn!@huI#avMt>!LEtEJ1@YVAsEP3y9?rn6`IPJ35IYX(@W!*M^yUpiW|*|QWpOMOd?C-}=xa=hwh&TS6v#C6L4j<%j{J>7?!j|5%K?R;CW zk9RF~O%0Dte){a47hbsY%HQ5O{qgO8IG-|g@%^c@-<|ya4{v?+>8+8MCNG?weDh_= zKQ;3Dx@{c{U^KI z8v;JQEzrZebrD+|-SG5www*k|cN{(vh#0o;JtzD=K4O(UTYI_#zNeAauC~Lzh`9yn z_XGpAJdXra5q*14_o0r%@TKhRIs9;2Hv;kPQi&xPt(vbZas2`s&c~s_IBG03l-j2~ zq#oc7p`o5Lx9SGCr&X?YxDPXw=4CG09yR%wO>CD@I%!lu20p+dEl3hKr~CZd<*}d%lA2MI5rjKu2^U*{p|smbGM?`-q?Jsv;h$mKfy=c46Z-GSw3$F}89AMty7`JSW8+j@JK`=u_j ztoJ0Jg`lSvC99le(bw1!#J>{Y?rARU$RE(2O}%ckoH3j=Oc-;0T zHlg6^>vd?{gxPw*)Nyj0*Hx;4HUNgd3ysNxcgh|8S=CjTUYxJfgY#LHakwe*MrIvvZHgBdc-J9jJ zBQK8Uw5=K5Y+UE~(s7-O>rAiHmxbpJpWT;oNF9?Ktv3(Pv+)KBk(gJlHy^3zB!7zZ za(&L2wDjIZ2%R@)=+uPJ1qhw*Ekwy&NGYa{kg^r^RD_%@V)av;@FuOd1kVb*t690J z2C051meE^=R13Z3@hKisdn*vb#lly{wIE{RU2Z%r@~%mY=dD7TTJI91S?pbk7Ax_t zofT#|-c8~3-W3Q}$|mh~5qr#pEzj3M8fKHD=Wc!U#?24kk|yoRACELfQhmpL-2n_0 zU(gpx?d&=1>5}I~nwX*oO*L$iqV*tgit1Bi!cwN1J`JvvsR`2&=TW9GcBP~%Pf2xd z9bb%O`4WJL;YoQ)PwDV`_+WQ;NB7}Kn#UjL>GkkFWYZt;dEHvxK{1qQd>JnNl(Gwl zG<@0IlQ+96l$pm#ah3sK;>jx?(2B+!<(yrBiO3vw7UGl^E-a;|`2urn=(a26MSObi zv-p3#7kw^n;MK{u zUz$Aoqp1(h-F|xjz4DJ&1}5M7-qcSnPhI|xH?Is&KL1nY(d2tSx%m$-{qf3~Mz=a* zJmPEfcKZDOh_;PC?2j0l_iWu-+uR(a>3b$hH2ul<&Q1*sx>HzZ;i<}JIM_BGe;_Ehh#Z;lv!U05tJZhod> zeUdF8QV)!n*+MFJ=u?GBx?*gABD}``IlyiFPIEWXGS2o*b9z&~>U!y#Q0Y3cblpVh zrt#8Eq0-G_>1LrI(GAp`Z3-8ay;&$0ZI~!}XuRm5>$!!O3$JJ8iCLA?I=qE5xy{X zzzoL@E`*nkxd(-chMO|wu(Xt`VP%L!)1mYHX$>obDK?H*?bR&Qt=-yS8sbbo|Mzz; zy{q)V#)!6|v95{D!OG3a-~3VvhUt%HuZXU_vj@2BQu1qQzT;QH%dZBA?Nk;P%oFWZ zt|YxGE<_c@|2qJ=TcxI*-89XqOci0ffBDzkjL7fpp7e+Fn?WVvH z;K^Q3hqpbpA!uG>v+RrT%!Y0QP1 z=v<7MOheeew6+4FKKBRii#}D%q+QorhR|If+nGdr#}R ztXwg(bRx5AJhN(~c69w%LnyOO%&Z$Q;aOUSXf2qq7LQwt!)E(n?X*TynsURMJ*~qT zvqf4eW{b2`%oe752FXYNOUTmKy8-UC>EtF*eZwZW{pl-{AH6m8%CDw=G|F$r`yxhA zCWm=+uEw9KC6u8Wx7})1^AY{A6SR2vX~vK%Ibvaz8I5)-*%*rpL9g*Y3LuX$t39q( zx68Gf`4`tJPq~a3f|w^TJR*85UxA=MlG5E1@Oj#Mg581k*yW$rI5uSV!gKHSE)AMK ztyg_n_l>&$t&1a51HZZT;;*Mpe>alKaw+8y-JI%(-$tN;0FhIyuQ&3w@J7c$k^l)K zAYx-xACvg0k|c9rVjy~n|4D!(O_W&_&MpA$8FrS2bBm`_^`$9T$Eg47ss97evUblv zn$^thL7pennsOR9krDdETBQY^Z`vwo6YV!^6UK^>+r%PIt#YeqrB%~NG_HAY;pCcMZ`T#jVTQG!;W=E$#{HtDD>0KVdM)D{4^q_(&> zAhm@7j334q0s|%xcgEX#F7+wJ|`Q~%C2LJZfk3XLLcYKiD-ckgc6%?Ls%_U&IkKXvKXf4p)Q zIllA4_iw-d!#cM)V(j*v@bnV$sXg2SZZjkKOgo$E8yY=(cJE}Ec*K074W!*YOH8s{ZZ`XuqhUBk{=em#m7aqLn!+HnkPjf5H!&O`-0?2|dKQR?zYTQ{g*qCGphlTw$l0rn#m zzaBH3j25PFjztq$W#d_8p{xoqt3pUibOTz#(drhAN!$$?$-rXxM-745C zLiQTbUL%;3IM79mO4bXN4~FayiS~yCa}tLlU0Qr$@%beemk8#9*)E)sJ-FuXWLg>F z+PO0zn!G`9Z(Sf4^CzD(xOQ;uv;m3Vy?fW6iT?1}{acpTEaARbQq9$_(0sXE1sJh{ zBXZam05&NtnO1*KkZ&jKl3llBa4HA++0q$+M_xMB!^-1W&)gGazot)>eCY(?22Q|? z6eE@qC9#p*>Yxj6nSAs6lLNnYZSCnf*5UKJCI?1uz4G4F@C&%ZGEK}P-~jAtB(=@! zMQPYYl#zG{iSUOBbO0n^Ip+4B-jmT7Ne5bNfTt;eJpeLhlT$vCvur$PStw_Pn6pB# zC%OSc*qT3KtsJ*jj%0_dD@E%{!8oU5_zdqw@^L~WTANuz{AYNihQO-+EwGpX5g76@ zLnGI42h-3@nm+Y$jyDk_q)$UarbFs>wF-izvo^1mt#OnO`M?@i&=1VRs}Cq^GRO5L zAg|F>us*5!wa*|G>3l+^tW}2KV@Oh2mZtv0>8TH1y7T%6cV2x%TAX6VSxPWz^>S6l z23g{gf=>S6waHh8-AdTTmLLrWDP?pX;>%oo@AmuOhj2_?7vbuHBsG&Czxj*XzxpX2 z=%`1bcY#P}tA%BEL-P*Lj@kqJns(PTM@)(skS{>wD1AdM$9Drn499#YPxSEKh{~5p z;n>@c_}Y(oc*qplu!B%BDOx={SU*zse*h4jqjY zS0v^whw#d>1>8G=gNAU1bLg;;Q3e;zD-?>V#|!}E@~VYQ*-dM>%<>x<`GXtoUQf#* z77;{XQ4r3^5uB^vT|M&D&(^)O4qkE@s|9nS!(4QCT7zgbDHS5;&#l?SeW=>RT}i1f z)qJMUs4moema8Ivp$;%^{!!@LO_+(Il+-X1nSBs9p-&CeRr4yk@jKdhmJntZT?|`C z->>oN!6woq9}dS@@`Eb=0Sqyk17{sx!>pN0Nxxs$r|Z)mV*`yZ0Wv%*Hr-J2Yg{g9G@ zj&`GAKkMk5%`jBT^%&Z`-_)PdXX4WWi)NzsrNrmrd#Z(c-sl4h_8Tir(cZLIbqn@( z9hjK5An^^A84=nVQ!l-C^P`V${o><0mwq+%?h8|=-=6&N@25UKKlSm;lP``aAtSn) z+AVwPBdSM(B}n0*Yx2rz66eH(oqTcl_9vg*dgtQp_eO!_Lr_A*MXhCpx;lJtySQ2TpBLvJ<6f z;sUhvRDNex2nq=%`$pHPh9yZKKIuEscD$p9-{`9FclGoHj#SJF*6+SQ?%o{|T-+g2 zI+({QL(Vu8Jb~tzeEt{G9aoUJ0PsNN4gVRr|}m9B_Q4!Wy)59kyM zn{bB$Gwqs=9>~NxeZF4aj|?&u2zRPPhp?rKS}tOiS_x|t7>*DcxA_B}Bdp0H)+pCY zzD|{cLTcL^Ns}*nd;A@wsDZX56|aWL>l$Cc7+^=M8_e)mc)f^SvQ=O13Uht=kkg!FMHP^x*z<~ zAy)4TIrods{R2C~rIl|#{Kmt=sx6_?tzzj`p&-!>G!b#b#Cl+%71?e$-6K0f&h^te zT(CO~fZLXl4sKgUIxZ~>fIrzdr|WXTyLz#bWoLgV_kfstU|{=od+x=h!zm$qm1wUL z%t_o0bIwq8$h>G^C)H2>P+KUiaCqf-TB(p$8m?S2QTgC_<%3tXp~_mZvQ{Wgbm!9s z4MPrKD?mKb*I#!o8#x|wt`nW>gv@o*TUA_IUQ8=Z*WrfJ?rqfWZE)B6q%*;;8XwrDn!58(Y86?cF;yC;gl_vw)9 z5BiOL#yOCsc^2V*6Bf%Kc>zIrT<9~+!5K|hKzUn0K?FGNsDk#q!u6Sm;^;S_293iC z;uFm~ouLYuNOUR-IF{}d-r1LuoDcaHV*gx8Dz@gkRbF#{YPWq>O_8c6o*dx1wMuPi zl517;nfg*eOY$tb`(rmm2ZCh14$@#bL?Z# zj8o?ZC(r#zLPaLO|N5;nFDM~pg7MMsRDiozfkhvvO2raaMW7?l>5E?XA5_BgLA-}Q z4iF^N@}O&W+QjfYfoF|w&8f_`MY6DYRIC!-{^S>mhOq1DBo^g!1Xl>zQ*TW2TVi?|UB@uAuR0lwqOATXd5 zFj4t{|3n8QNrW!(BtME+<6}osI=Z_d9VKxO{}2Vxbo#m_8r+hI$e5Vi6ovCgOwrf- zp^wnfeas&*M(+R#>+}(d3Oyrw#9yFfQ&@F)=n?;SxWV%Pe#+Mj+5(@1x!5T;ZS4V% zB&coUz5b*W{p?^LQmX%R26st`S;KbF8n#1r`<|e+t4*}F3C1MuMy3M-f9+C}V_K`t z1*bF$1{$W#Tvql#Z8#sSN26sC1B1qCwIO{`xXe9KwrRX<)7bt{S)Eu`Cln^S!ENE} zLa-~d3c{|+Aziqj__eN=yGH7TwQXa$0Oa<0WKY-ep{`KD39;aWkdwrPa~2Khz@p45 zxces~ms3Q`I`R_*;mrKOZD1CpFCwOak;^TZ=F%j*0f1Th+ zbc333hGUvbOV1vVab zR0_&bBmnTjv$DYp&q9R&+?`H|>TiKFeQ=}!0Pf4xS5JPn`HRic6~PXEaB0Zy7wvw* zoWy~{Bbf8SmHtBL@WcV{m_H*(dBj5Nc@^TUw<%!jY#ad0w>hf;ou` zXJidD%=}Q)yVE+n2WLMqWJgkJ*K&Vft*PFi{%oBF{?9g~Y|-mJ*QnwDT&E(xzS@F| z-)3&8v8unYsNny?YOYzN`@%_qzQ|XRe^GTdF1}owQnyL-<%6s0)@y#ZQU(9-*6RS{ z*h&gMcNTa}l$#oe!MBbo{s0ks@-ZXb4Ja%NU>{0|A8_%jT|7G*E2n~|L{p@55{7~z zD`pG@4Kb>~P$0!K`AFoleoxf_?2%!OF*%EdhIVMo-J=R)W$xU9HKYd~r2nR}P~bu8 zKAl%XRAhJ7ESabg*pTu(7ALyLoGWadd2&uTu(QwbEpo0!&UN$TTzUR2a;~tF=E*tm zXXM=YEpx74Am{9PY|g>PBd+(XV-}XH^hV(7+;B`UoQc|J@*2Fx!wGuDs818+YVKH| zeNx^~#k2AdD|NwG<|MH!ESBmh?dD(yQnIppV1S`7#*`K`4CET$OO)Af0{+hp(`K1j zhd0e@W9;ShK-tX0M8=+s-`Zy}pOrc!$_WyXlxI>TEot(_V7D2;6-ZsdK?%EMY_;3( zzcuyj^9q7H^~2Gr@4u%6YvczJOTtpR;ER}hgHLvL_>V}O7h`8ndmCY)L7ISWzyI9r z_dX)wMi=2c5sU1RxIJzS3v}yyAKw1pCqW{DaVE6r81OU3m|;vIIoQ<2U*EoTjKac??at~0GmI>Lh@NLP*Y2#{+S0Tek6Wr6c6wSGS|Ex4F(uGT0rC?A?Ao(s zXG8Ng1bnzQl9Tv!XVcc|mWHNANvdP@L{T_a4-8e57<^0$l*mfsi8Uo{u+q{-D*koK zfr%Jm46}?-+ekPj35_HiGbwK@Bl=!en_gd_t)uf)c@pkel5k%kX@4~uQHXuS_m9Jdvl zGpV>rFF%bdXULp?BO`lY2T;@!6u@Mk0bz4G6HTg340)A64Bonz@YcO>|I#k(ZvOqD ze?BB#3Qry*q_tDDcM9etj_GO)u91GI6^63r;Orm3PIlA)TKqY4sbtx;_J#dTRqQY`9ptACNQ!b&gGqR0`rzg7}mr6~y>^lA40oh%}QT z+3`pXa#cT1uI?d)IdX1XAm=7ho=leW*xd9b$d+XljQ@iW26qY*JOM9|6eK49_W8;8 zK9o39x8Hjmcmb2TH3kWZdGs;=#y?(p`QYNpM<4SpJ6PrBNq2=U>Nm$;;0aUcuvK6^ zQXS*>;sRT3)jR}IeqS5kek7th%=ZL)BN~!L=?-=Fv|&Fs@54^2b{|+MJtus;+bA(y z*wXbIq`-ef;3n60MuTHdhRUkNvg%Ob z7O`;4)jgrY?V-FKV%`oRQ+DAj#C@&l<)%>nQZaw2kVQ^T193v25K$+2--G z&13$nTS8^^Vp)BtaGO}T?b@19VN)n?mzcLp$duh=Ucu1GA8)?AS;%xtZaN(~p0;zg z^ns@&jb{@nG!iNhKkTV$33K{-7Y4sX*^=s3^q8S*lMmuROxK^;aY!4^y((-u$H1R4 zA<(aZ#6mkq>PZK7A}5+ZYvKY5Wdu3_JMg`lMAeP8ploAl-=hjG+n2C;fhMTrymhbc zFqe>ftyfRK_`8sIq?T|ip&)ibEossczOKTrdmShUQX-NMP!Mj&Tex9D)@B_<4fPpF z5)q$1I0E_^H55<+`hLSRhHgw7be^FDyr$=%NmY}ujjiA4O#ux?Q+PE=D!i#PJEZ%J z$5s5=KJBnVBgbYckxqf?gK-Y^hPYJYrz*rU$qEyuvs)5c&TYX3eHCSQAykbFt?SduzO zgc$8-ed8y0UO0z+tEY6!j=}^)M8%)d1p|kc-Ulsaqp!QY2QvM`5gi+{jXdcO2Au%6 z-~X5d7C#uBx_m}TEJ_H?+JQ_A|F0~!xV&E zr3sC{hUay6C?<+v_I3M%ywB6-Z|~@c=%E(^qSc)dQTO=y1C)^VXirDCL>d}vs#~f( zH4VEXdf(G9IRbuu54PhW2(^e7OPDvJfy{x?ffCjB5GA33#%M(A4R-bNgfm4n-90BF zrVf7(DOa=w_?K}rswF|0=f9v(rb8Xj&L#!*h?Tt#4J?%)qB_L?J%uBY0XCaaUg#kc zM38!_j}%m)TfzS`0wmXku*Nq-($z6O$YyGmPE?yjI9d8x_6JNDe za-qx`F|&qk4s_-ZSg)sA&aOYV`Nhpc?oeu}mrSTCKA@qPm`yJLDd29$hbF z-5<)jU(C86?BVqL$DX>LS9p2D@FPFoba~TY4TK19?|)z>@)xVfU&8zq zDO;VI&sP?1&C>ifO9%g#s%mRpp5}LX+PXr+?}}A`5$(fm9o=zT>Ih3c@zjaSa zgE@hD`XI2sm>pZ_RWTYZGucU$^u*RBlMVrMgsf&@&R!Xf)J zKiEh}=}(2-2ZcuC%NDG!tcB}Kh4i~WnKPy6yy*ZnmVFAD1f`r4sEXzm?V2zL<9ROh zykcxyVZJpk)N1q0x2fK|nXbOj-21IZ1<7mJvEZnPQNxeMS+IAT`^gY1~Ujt5m| zPtB67m(Eiw(G{R*;oc63=(=bw*vh=A-!f<4kIp*9fekZp`YiE0gL|oKtc03=%QF^~ z?3U!4;e?+F<~vnG z;@}qKLs34EYzx39?9H+NC^5y9N3w;1AB}=LCh0MN#icyO?hwAG9cFvL8j;j!Bibi} z{*z1tHC9bP-f<+^FklvFusu`7*hoR@A_rZ!-~05=rT1bLfj7E3RzQK5--qY1+N!fd zN(KXzCkv_YBaZP1>ebMN1N z_7h25bL->3oBGLfljnwD4UZ%uUA>(?u)pMuP|&8JEl}URJoWAyH$Q!S^4xEjRfK~s z$(*B_PT5QaYhoAEREFgWc{^?a|1cu+j}TZzfXp#TY`Cr25P{4gYgObstNmsar=g=6@q<3$i7{)Zy(qi^X}dB z?%i2$i6~|>)tN)F`X^u;;R^JQ!Cbj$M)uL-BS+6=EIt~c7Bo4_H4XlFi5o&YO7u`Ss+%LO_Yeur(+%c8~v5fOU(fOc|nZ#iV z%PYRT^j-Zs)=%`}eT_n8Q^>hXbk67!C`twJpdKr#CdxODmv0^mTs;sfZxYL!1XrRP zY`Br_LS1Inslz!Hl5rDs(3}lo&ITc8Lv#a3gRs75Yy$wfy&mC-!{Xi}LPw`?v=dO= z+a*0hmGr4<)kD(}^66yj|c5}$t zB05`y%p@+9*>ZO>yNY$084&F}24Q9E=m7xP9qOUZ(=lMe9}!C*5egFBU~0^?@eEBH&%j+hA)ST8yzE*2 z3>t5Tx<&5fdsrvm!#erBK{oj1_28A;1iO$sS(3y4iF`>H`OHCn*?(b#{$dm-8be9*|&=Jt%5m;i>g0r zO<5$Flt2`Bfjy}4T9}YYw9DF=AUn}*v^RLTvxbv^8w?2rAiPhCG3ci%oAhPhh{);{OZ?1FR{RM3o?YpkpT>a`kwK5T6!)xnxi0!enJp)tmJ zl1i!)cZe~W$6+S((2`7dMv_IQ(TAgz7kBn1v%MM9CzzaLKBF_K(55oW5)MnCXeRN* z+<`A@Wm4n>-MZMCQgKXN6Zw96jh5LjJxT96hFO7$821d_W4nao+c(Qk#0S z18bu(fY&8RU5VE}U%f>006u?PG!KeZgl{uya$-l!ob8tDb=NEl};KuTrWG{(s^@oy3LAf;XYI{F4OA z0Ekt20t#|4zK2hJS@$;7?%UP0yT!Awp{8Y9Bu%;AR@+d&ttFDe*nij**4V=PC=JHq zD<|Kd2q;WD{{N**Wjk3UokeWk*U++co6Mb~wO-nFW+NX>P<$@|;@Cyi-Tyzju^S*_ zVYXR4{$M-tvHAa-9uOBVqVxJ-^Hti;MJrU4`zG5t!gp^x#P){%pky^Wshy))p+e{Z z{t4)mNDR2BT~>UGp*m#UB3icy#v~558_ZTo$M4m0(YYd&x>8JCInCKj*-=~(JhuHI z`vK8@Krkn9*m-|>=S2RR@%%N{3rfX;HP=hrV#$49)`>N(Lhhq*%*f!9X#=-t?N^+3 z5lprM04F8Iv^l^){Xjh|6D%rHlAa?`9ZOV0i2?wrrbOoeNHT@P7PI2jqEtx z#Ntgtexe(AIGjNwt|dF{SVWeZ&i7pG5o~3%UD)Or)Pp3a&jd|6ONO_FoJ&RL(rGTs zl=~HdGusEM2TnjEP^8^SYKtR=jM^G<)QFB6!Is3q*3)?%*^H$Ruu%Pvsm6*v)W8Gx zN&D5x54)~(NmtiSNauN4Xj*G!e4hzN#kivaR7UpII>Av9a_kTtI|N%22l_BG>xcfK z`_A`Y>>qwAlwCQVQ7L3pUe7PS-1u&txTHqNuN|z7Qn7krg?FqBKye|bPjvc(%p~r* zv*_~D5naf+Omr?2GLyKwcPAOY#^PXg7FTiLdhQ!*yK)34$%P#IMaO=@mc*eJ2X)aO z(GhG5pqiMeQ4nnpmaLrOzxajbmfs)$=i~SAf}BAk{mvU1nW96o@IYHM%|Rx{&h(t^ z5sZswyXdE-W^5;K4p|=*tq%&uIh~(ozu#4CF>G6|zP3DNdzJ3mhO&lI-S0~+@c(}0 z^6llie=66(59=gtFdoXj>J_IbNoY1BSo(BPf@NXD2G|3JNoDroO|W(7jWJCWj|Rt~ zQ+Ts<%--!3j|5`LR#5$D96FO4=KLaALLX;7bH!uZNHb8%xRNS_0T6P9EQx(fJVNf( zzcU{}fKSTInO7r}^iY3uDVrwb3v;-1#2RTjN`xjm;%ZVMB$D6tng}6}M{eTVWmbJF z1cv-NcEa~SNNsJNGIakd(9`^xI6xPlW$L7g65Y%*nApN55~faS?$ga|?g08|Vaf>B z`Q9{7Jujdx#?VXMGq8twFB?agGQqd(eT-bw^y6J8RDsf&lA~7czlU0}#VNEUY+{lX zX6CJBCBmOo zOBwp!$p3ew({fm zo!f{)VzP6>za_+*Mz@4`OK7*A)E|8OsoW%+o}@`vKUP|41GpH6!U~Erjukbo#b{sBJSER z`&V{ObKFVQ7WL3lbmY9PB#8jT&8&Nac*BHq<+yX@sC#VRRd2|-Lv+sU>Q%P)!UF-% zAdq2vKF!Ew6;5PUj%QYeG8c=Pi-pX^vB~Vc+7fbZ7oFRM%sCz5kx*`tte^GJex~#& zj4AVeDl>|@p9vlJgm&!}ckPw#2;MG|8Fh=!ZXt7F$M9AdRqJ8foM30{FDd|bO#P)P z+`Wt10RiA-GFufP>roK4<(xOlIDB%_U=OJV?1B52X5o?Dzu)`Md!D)f+d?mC`N zOrt-OlH7P$B9mLi&C^c}VDuYhk%DP{_Vw8__p)L9#ne6(>TXfL;Y@0B8X%En9oq*~ z+}Q$e${Ef>+*NiAqzrHasfRT9th#aj_KL@^`;B&)vw7 z%s!7kj%A+{6PbO$5SqaoU5&ho@Hj{*7riSDeU6${n(Q&aa-hR z%u}6zO+D&|noHO^giZ46`&DsV0i-M)XEe!8V4P9+sb+S5oY8urzT#C$y%ck59E}`L zYoAx2y${>()qFl>pG>W|H4I^nq_!i!_<$#2vV1O{;%hPg3C|hBIAW9w<(JS|!er$S z#+*KVnqjVgPT2%&Zt|A>^+~W-;uLoUI|Ci^8WOWTfmD2nAXH4 z!*=|~N&w;;T$TW2-Gc;xhowk-h=@Q6fm8xMsxjj2M|Ai^!eJl(5Z$1~F`|2fK5x%g z(~WWhf1tbi_O|YJU#HtFRV-uMJCPEs*>vzQL}Q^;iDw_tv}OA$d*;PwUOfc1d?;b0TbjbiaeAwSW@^40S$9lLPseAmS;!B#TcF}`}(o_(q6Le=@D z7ncg=!r2bxvu6)}CzM_yrh~a|VBa4nI8YtV$-iu#$XPL-vtl8UMD9w6Byve4!OSAl zr{uKB!N68X4=h>dnqF)g+8Q#uM6*jUyJCbSwWCF&)xRha@T~&o=Kp9qvhP@Q@!)yS zMbAJAvs0uZJ4FDnOk~f$wCuvN;p~vzE!y3JIk}T157YX%ffh4zX&}RtvLkN@1{&x) zf3OCSK_64iNTJU=qzo9KiJO*tuII&`khM^>77E5B4#I$J*jiSZ$bOzFf1nQEiaz)7 ziw~b^I@>gW9|;TCBNnKD6r{5*xi7fSS6#$M9~LEbH=KneG>Lu{5FZa*m;o+*dI5t@ zIWSZtJ7&f3kR3CKe3geVCd+x0&}=1=KGrSCLnWSvwA!@P;Tv(1D3DDMtC2{|SJ(Ob zaD6H}$<#_8YVV=}OES&V+>OGL0j+4nr-WJQ2GR!7B!f(PNuC$ccJ+9DaV&WSg%^-j z33-n%23Vd-A(XPgL)MUD?GGo>w9qC&o0P8^Fg72inc2rIG$bYtSnF!#Gf4T!8wiit zM`B)p6vU*_P4N_jG&VL^&9ikUVq(t4{+%z!-GyLD&cU9aFJ6>ghq%e>A6A@Zp*}Hr z_0orxOu@HNK80&{`_sYOub-9roN{wxZyVp%<%(*UZH#W1X{f=c3@_b&edcRRa!Be! z8(mV5axHhUr9CmV7>aQt*orU9Xw=B-lUL3`c26n@QN&7#S|{OTgeFOws-%n(fAINV zxPl}+aw!u3M&6BTkp%jrX)<-@G>jBUF(d}2yrLVVam846FC!qohCmuct{_0$L!%#J zWQ*`(x=Sl@!iN|&SSe3Ak_`1+9DW}IL4PN9JdyRtVN==NH8XG(^|c$f~>tfJ)Tx5q!lu@+LCd{l9Bx($2t-A z=WWRyG*#tI7TZRTg&bQ%#}>hs#4#Py(s6rf$X+hm%LQ|CcXzOK!hR3I?%s{o2BzEj zbOIzr0Y70ipV>EWsp3Ab%HL|xxK%Vu(wy6f4R%x1ZYsv0z+KiEh*{w-V4P&*wfP~p?b&YF=6%i zGDQ#@<)mF(XX!3YNj%LjY=;)l_FnyxE++{vE;8$WglkKEkmh%u><9AyL_Wgs84AZ2 zk&i$&{{D-YE>A1Jur^=N=1*&NI(#_+V7UIxhneRpAGpeh+0BbBFJB&dm42}aHK{$kL|zOe&wL#PXZrP zt}iYhzc5pjWj)2b-|~BVKkId-|H@N?#o)SJ`yw!)ODXMq9>8eszF(ucA3{ClQnu>+(j>L^z@FnOC+mZz9;H>Qd>T z!%dK?oX(UWorUJTvd4Q^@+5(eYu4x+CzcpbQxKbSn1G3G7=>}T-d@@AIDE^X@#Vos>Y$m&OA{RvO4%qP zn@ZxRmp>afVJ#1K`Zn{cRU8M$`Dr4B*)bJttZ7tQR26`cQ>C4@(l5-Vo@T!=XFAP( zVa{@z{leTLA>M_#b;3d}%snmKn+tOeA>M_#av|Qqh_`AD2&h?LXalG$)f!M)(MQ%^ UlZu*t?$cM&vwxc>2y6KNA1Ftw`Tzg` literal 0 HcmV?d00001 diff --git a/app/__pycache__/user_resolver.cpython-311.pyc b/app/__pycache__/user_resolver.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d3f764a6ecbf164a6afce5e9d962f1769df60e4e GIT binary patch literal 8976 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 rnBY str: + # 使用本地时间 ISO 字符串,方便 UI 展示 + return datetime.now().isoformat(timespec="seconds") + + +def load_auth_meta() -> dict: + if not os.path.exists(AUTH_META_FILE): + return {} + try: + with open(AUTH_META_FILE, "r", encoding="utf-8") as f: + return json.load(f) or {} + except Exception: + return {} + + +def save_auth_meta(meta: dict) -> None: + tmp = AUTH_META_FILE + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(meta, f, ensure_ascii=False, indent=2) + os.replace(tmp, AUTH_META_FILE) + + +async def _new_context(browser: Browser) -> BrowserContext: + """基于已保存的 storage_state 创建 context。""" + if os.path.exists(config.AUTH_STATE_FILE): + return await browser.new_context(storage_state=config.AUTH_STATE_FILE) + return await browser.new_context() + + +async def open_login_page(browser: Browser) -> None: + """打开登录页面(通常需要用户在 VNC/有界面环境里扫码)。""" + context = await _new_context(browser) + page = await context.new_page() + await page.goto(config.LOGIN_URL, wait_until="domcontentloaded") + # 不关闭页面:让用户扫码 + # 注意:context/page 生命周期由浏览器进程维护;任务结束时统一关闭 browser 即可 + + +async def verify_login(browser: Browser, timeout_sec: int = 20) -> Tuple[bool, str]: + """验证当前 storage_state 是否仍然有效。 + + 策略(尽量稳,避免依赖某个易变 selector): + 1) 打开首页; + 2) 若跳转到 /login 或出现明显登录按钮/文案,则判定未登录; + 3) 若未跳登录页,并且存在一定数量 cookie,则判定登录有效(启发式)。 + """ + if not os.path.exists(config.AUTH_STATE_FILE): + return False, "未找到登录态文件,请先登录" + + context = await _new_context(browser) + page = await context.new_page() + + try: + await page.goto(config.XIAOHONGSHU_BASE_URL + "/", wait_until="domcontentloaded", timeout=timeout_sec * 1000) + await page.wait_for_timeout(800) + + url = page.url or "" + if "/login" in url: + return False, "已跳转到登录页(登录态可能已失效)" + + # 启发式:页面上是否出现明显“登录”入口 + login_text_hits = await page.locator("text=登录").count() + if login_text_hits > 0: + # 有些已登录页面也会出现“登录”字样(比如弹窗),再做一次 cookie 辅助判断 + cookies = await context.cookies() + if len(cookies) < 3: + return False, "检测到登录入口且 cookie 数量过少,疑似未登录" + + cookies = await context.cookies() + if len(cookies) < 3: + return False, "cookie 数量过少,疑似未登录" + + # 通过:写入 meta + meta = load_auth_meta() + if not meta.get("login_time"): + meta["login_time"] = _utc_iso() + meta["last_verified_time"] = _utc_iso() + save_auth_meta(meta) + + # 刷新保存一次 state(让 cookie 续期信息尽可能落盘) + try: + await context.storage_state(path=config.AUTH_STATE_FILE) + except Exception: + # 不要因为保存失败影响验证结果 + pass + + return True, "ok" + except PlaywrightTimeoutError: + return False, "验证登录超时(网络或风控),请稍后重试" + except Exception as e: + return False, f"验证登录异常: {e}" + finally: + try: + await page.close() + except Exception: + pass + try: + await context.close() + except Exception: + pass + + +async def get_login_status(browser: Optional[Browser]) -> LoginStatus: + """获取登录状态(如果 browser 可用则做轻量验证,否则仅返回本地 meta 状态)。""" + meta = load_auth_meta() + if not os.path.exists(config.AUTH_STATE_FILE): + return LoginStatus(logged_in=False, message="未登录") + + if browser is None: + return LoginStatus( + logged_in=True if meta.get("login_time") else True, + login_time=meta.get("login_time"), + last_verified_time=meta.get("last_verified_time"), + message="仅本地状态(未进行在线验证)" + ) + + ok, msg = await verify_login(browser) + meta = load_auth_meta() + return LoginStatus( + logged_in=ok, + login_time=meta.get("login_time"), + last_verified_time=meta.get("last_verified_time"), + message=None if ok else msg + ) + + +def clear_login_state() -> None: + """清理登录态。""" + for p in [config.AUTH_STATE_FILE, AUTH_META_FILE]: + try: + if os.path.exists(p): + os.remove(p) + except Exception: + pass diff --git a/app/log_manager.py b/app/log_manager.py index dadd79e..f754bd7 100644 --- a/app/log_manager.py +++ b/app/log_manager.py @@ -1,153 +1,77 @@ + +"""log_manager.py + +简单的线程安全日志管理器: +- 追加日志(level/message/thread_id) +- 持久化到 data/logs.json +- 提供读取最近 N 条、清空 + +说明:采集器是异步运行,但日志写入使用 threading.Lock 足够。 """ -Log Manager - 持久化日志管理器 -支持日志加载、保存、追加、清空 -""" + +from __future__ import annotations + import json import os import threading from datetime import datetime from typing import List, Dict, Optional + from app import config class LogManager: - """日志管理器 - 线程安全""" - - def __init__(self, log_file: str = None, max_logs: int = None): - self.log_file = log_file or config.LOG_FILE - self.max_logs = max_logs or config.MAX_LOGS + def __init__(self, log_file: Optional[str] = None, max_logs: int = 2000): + self.log_file = log_file or os.path.join(config.DATA_DIR, "logs.json") + self.max_logs = max_logs self._lock = threading.Lock() self.logs: List[Dict] = [] - self._load_logs() + self._load() - def _load_logs(self): - """启动时加载历史日志""" - with self._lock: - if os.path.exists(self.log_file): - try: - with open(self.log_file, 'r', encoding='utf-8') as f: - data = json.load(f) - self.logs = data.get('logs', []) - except (json.JSONDecodeError, IOError) as e: - print(f"[LogManager] 加载日志文件失败: {e}") - self.logs = [] - else: - self.logs = [] + def _load(self) -> None: + if not os.path.exists(self.log_file): + self.logs = [] + return + try: + with open(self.log_file, "r", encoding="utf-8") as f: + data = json.load(f) or {} + self.logs = data.get("logs", []) or [] + except Exception: + self.logs = [] + + def _save_locked(self) -> None: + os.makedirs(os.path.dirname(self.log_file), exist_ok=True) + tmp = self.log_file + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump( + {"logs": self.logs, "last_updated": datetime.now().isoformat(timespec="seconds")}, + f, + ensure_ascii=False, + indent=2, + ) + os.replace(tmp, self.log_file) def append(self, level: str, message: str, thread_id: Optional[str] = None) -> Dict: - """ - 追加新日志 - 返回日志条目 - """ - with self._lock: - log_entry = { - "timestamp": datetime.now().isoformat(), - "level": level, - "message": message, - "thread_id": thread_id - } - self.logs.append(log_entry) + entry = { + "time": datetime.now().isoformat(timespec="seconds"), + "level": level.upper(), + "message": message, + } + if thread_id: + entry["thread_id"] = thread_id - # 限制日志数量(保留最新的 max_logs 条) + with self._lock: + self.logs.append(entry) if len(self.logs) > self.max_logs: - self.logs = self.logs[-self.max_logs:] + self.logs = self.logs[-self.max_logs :] + self._save_locked() + return entry - # 异步保存到文件 - self._save() - - return log_entry - - def _save(self): - """保存到文件(需在锁内调用)""" - try: - with open(self.log_file, 'w', encoding='utf-8') as f: - json.dump({ - "logs": self.logs, - "last_updated": datetime.now().isoformat() - }, f, ensure_ascii=False, indent=2) - except IOError as e: - print(f"[LogManager] 保存日志文件失败: {e}") - - def get_logs(self, level: Optional[str] = None, limit: Optional[int] = None) -> List[Dict]: - """ - 获取日志 - :param level: 过滤级别 (None/INFO/SUCCESS/WARNING/ERROR/DEBUG) - :param limit: 限制返回数量 - """ - with self._lock: - if level: - filtered = [log for log in self.logs if log['level'] == level] - else: - filtered = self.logs - - if limit: - return filtered[-limit:] - return filtered - - def search_logs(self, keyword: str, limit: Optional[int] = None) -> List[Dict]: - """ - 搜索日志 - :param keyword: 搜索关键词 - :param limit: 限制返回数量 - """ - with self._lock: - filtered = [ - log for log in self.logs - if keyword.lower() in log['message'].lower() - ] - - if limit: - return filtered[-limit:] - return filtered - - def clear(self): - """清空日志""" + def clear(self) -> None: with self._lock: self.logs = [] - self._save() + self._save_locked() - def get_stats(self) -> Dict: - """获取日志统计信息""" + def get_latest(self, limit: int = 200) -> List[Dict]: with self._lock: - stats = { - "total": len(self.logs), - "by_level": {} - } - - for log in self.logs: - level = log['level'] - stats["by_level"][level] = stats["by_level"].get(level, 0) + 1 - - return stats - - def export_to_text(self) -> str: - """导出为文本格式""" - with self._lock: - lines = [] - lines.append("=" * 80) - lines.append("小红书采集器日志") - lines.append(f"导出时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") - lines.append(f"总日志数: {len(self.logs)}") - lines.append("=" * 80) - lines.append("") - - for log in self.logs: - timestamp = log['timestamp'][:19] # 去掉微秒 - level = log['level'].ljust(8) - thread = f"[{log['thread_id']}]" if log.get('thread_id') else "" - message = log['message'] - lines.append(f"{timestamp} | {level} | {thread} {message}") - - return "\n".join(lines) - - -# 全局日志管理器实例 -_log_manager_instance = None - - -def get_log_manager() -> LogManager: - """获取全局日志管理器实例""" - global _log_manager_instance - if _log_manager_instance is None: - _log_manager_instance = LogManager() - return _log_manager_instance + return list(self.logs[-max(1, int(limit)) :]) diff --git a/app/scraper.py b/app/scraper.py index 81e698c..d7dddd0 100644 --- a/app/scraper.py +++ b/app/scraper.py @@ -467,16 +467,20 @@ class XiaoHongShuScraper: await self.emit_log("INFO", "启动单线程采集模式") # 创建 Context 并登录 - context = await self.browser.new_context( - viewport={'width': config.VIEWPORT_WIDTH, 'height': config.VIEWPORT_HEIGHT}, - user_agent=config.USER_AGENT - ) - - # 检查是否有保存的登录状态 + + # 创建 Context(优先加载已保存的 storage_state) if os.path.exists(config.AUTH_STATE_FILE): - await self.emit_log("INFO", "加载已保存的登录状态") - await context.add_cookies([json.load(open(config.AUTH_STATE_FILE))]) + await self.emit_log("INFO", "加载已保存的登录状态 (storage_state)") + context = await self.browser.new_context( + storage_state=config.AUTH_STATE_FILE, + viewport={'width': config.VIEWPORT_WIDTH, 'height': config.VIEWPORT_HEIGHT}, + user_agent=config.USER_AGENT + ) else: + context = await self.browser.new_context( + viewport={'width': config.VIEWPORT_WIDTH, 'height': config.VIEWPORT_HEIGHT}, + user_agent=config.USER_AGENT + ) # 需要登录 login_success = await self.handle_login(context) if not login_success: @@ -559,29 +563,31 @@ class XiaoHongShuScraper: """多线程模式""" await self.emit_log("INFO", f"启动多线程采集模式,线程数: {max_threads}") - # 创建多个 Context + + # 创建多个 Context(同一账号并发会增加风控风险,请合理控制线程数) contexts = [] - for i in range(max_threads): - ctx = await self.browser.new_context( + + # 确保已有登录态;若没有,先用第一个 context 登录并保存 + if not os.path.exists(config.AUTH_STATE_FILE): + first = await self.browser.new_context( viewport={'width': config.VIEWPORT_WIDTH, 'height': config.VIEWPORT_HEIGHT}, user_agent=config.USER_AGENT ) + login_success = await self.handle_login(first) + if not login_success: + await first.close() + return + await first.close() - # 第一个 Context 登录,其他复制 Cookies - if i == 0: - if os.path.exists(config.AUTH_STATE_FILE): - await self.emit_log("INFO", "加载已保存的登录状态") - await ctx.add_cookies([json.load(open(config.AUTH_STATE_FILE))]) - else: - login_success = await self.handle_login(ctx) - if not login_success: - await ctx.close() - return - else: - await self.copy_cookies(ctx, contexts[0]) - await self.emit_log("INFO", f"Thread-{i+1} 已复制登录状态") - + # 基于 storage_state 为每个线程创建独立 context + for i in range(max_threads): + ctx = await self.browser.new_context( + storage_state=config.AUTH_STATE_FILE, + viewport={'width': config.VIEWPORT_WIDTH, 'height': config.VIEWPORT_HEIGHT}, + user_agent=config.USER_AGENT + ) contexts.append(ctx) + await self.emit_log("INFO", f"Thread-{i+1} context ready") # 创建任务队列 task_queue = asyncio.Queue() diff --git a/app/user_resolver.py b/app/user_resolver.py new file mode 100644 index 0000000..e51027c --- /dev/null +++ b/app/user_resolver.py @@ -0,0 +1,153 @@ + +"""user_resolver.py + +将“数字小红书号(例如 11432840954)”解析成 profile_id(/user/profile/{profile_id} 里的那串)。 +- 使用 Playwright 打开搜索结果页(更接近真实浏览器,风险更低) +- 解析 HTML:根据 '小红书号:' 定位,再提取附近的 profile href +- 强制缓存:data/profile_map.json,避免重复搜索造成风控压力 +""" + +from __future__ import annotations + +import json +import os +import random +import re +from html import unescape +from typing import Dict, Optional, Tuple + +from playwright.async_api import Browser + +from app import config + +PROFILE_MAP_FILE = os.path.join(config.DATA_DIR, "profile_map.json") + + +def _load_map() -> Dict[str, str]: + if not os.path.exists(PROFILE_MAP_FILE): + return {} + try: + with open(PROFILE_MAP_FILE, "r", encoding="utf-8") as f: + return json.load(f) or {} + except Exception: + return {} + + +def _save_map(m: Dict[str, str]) -> None: + tmp = PROFILE_MAP_FILE + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(m, f, ensure_ascii=False, indent=2) + os.replace(tmp, PROFILE_MAP_FILE) + + +def extract_profile_id_from_search_html(html: str, keyword: str) -> Optional[str]: + """从 search_result HTML 中解析出对应 keyword 的 profile_id。""" + if not html: + return None + + html = unescape(html) + marker = f"小红书号:{keyword}" + idx = html.find(marker) + if idx == -1: + return None + + start = max(0, idx - 5000) + end = min(len(html), idx + 5000) + chunk = html[start:end] + + m = re.search(r'href="\/user\/profile\/([0-9a-fA-F]+)', chunk) + if not m: + return None + return m.group(1) + + +def parse_line_to_keyword_or_profile(line: str) -> Tuple[Optional[str], Optional[str], Optional[str]]: + """解析一行 txt,返回 (keyword数字, profile_id, label备注) 三者之一。 + + 允许格式: + - 11432840954 + - 11432840954 张三 + - https://www.xiaohongshu.com/user/profile/ ... + - /user/profile/ ... + - profile_id(十六进制串) + - 支持行尾 # 备注 + """ + raw = line.strip() + if not raw: + return None, None, None + + # 整行注释 + if raw.startswith("#") or raw.startswith("//"): + return None, None, None + + # 拆备注:支持 # 以及连续空格 + label = None + if "#" in raw: + raw, label = raw.split("#", 1) + raw = raw.strip() + label = label.strip() or None + + parts = raw.split() + core = parts[0].strip() if parts else "" + if len(parts) > 1 and not label: + label = " ".join(parts[1:]).strip() or None + + # URL/相对路径中直接提取 profile_id + m = re.search(r"/user/profile/([0-9a-fA-F]+)", core) + if m: + return None, m.group(1), label + + # 可能直接就是 profile_id(十六进制串,长度>=8) + if re.fullmatch(r"[0-9a-fA-F]{8,}", core): + return None, core, label + + # 纯数字:小红书号 + if re.fullmatch(r"\d{5,20}", core): + return core, None, label + + return None, None, label + + +async def resolve_keyword_to_profile_id(browser: Browser, keyword: str, *, slow_mode: bool = True) -> Tuple[Optional[str], str]: + """将数字 keyword 解析成 profile_id,并写入缓存。返回 (profile_id, message)。""" + m = _load_map() + if keyword in m: + return m[keyword], "cache" + + # 使用新的 context(不强制登录) + context = await browser.new_context() + page = await context.new_page() + + try: + url = f"{config.XIAOHONGSHU_BASE_URL}/search_result?keyword={keyword}&source=unknown" + await page.goto(url, wait_until="domcontentloaded", timeout=20000) + + # 轻微等待/滚动(更像真人浏览) + if slow_mode: + await page.wait_for_timeout(int(random.uniform(800, 1600))) + # 小幅滚动一下 + try: + await page.mouse.wheel(0, random.randint(200, 600)) + except Exception: + pass + await page.wait_for_timeout(int(random.uniform(400, 1000))) + + html = await page.content() + pid = extract_profile_id_from_search_html(html, keyword) + if not pid: + return None, "not_found" + + m[keyword] = pid + _save_map(m) + return pid, "ok" + except Exception as e: + return None, f"error:{e}" + finally: + try: + await page.close() + except Exception: + pass + try: + await context.close() + except Exception: + pass diff --git a/app/web.py b/app/web.py index 58af4bd..b5ff2c6 100644 --- a/app/web.py +++ b/app/web.py @@ -1,1096 +1,478 @@ + +"""web.py - FastAPI 后端 + +目标: +- 提供基本的 Web UI + API(启动/停止采集、导入用户、登录/验证、日志、SSE) +- 单账号登录态长期复用(storage_state 保存) +- txt 导入:支持“数字小红书号”自动解析为 profile_id(并缓存,低频,减少风控) +- 移除无用/冗余的调试/自更新接口,代码保持清晰可维护 """ -Web Backend - FastAPI 应用 -提供 API 和 SSE 事件推送 -""" + +from __future__ import annotations + import asyncio import json import os -import subprocess -from datetime import datetime, timedelta -from typing import Dict, List, Optional +from dataclasses import dataclass +from typing import List, Optional, Dict, Any + from fastapi import FastAPI, HTTPException, BackgroundTasks -from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse +from fastapi.responses import HTMLResponse, StreamingResponse, PlainTextResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from fastapi.requests import Request -from pydantic import BaseModel -from typing import List as ListType from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel + +from playwright.async_api import async_playwright, Browser from app import config -from app.log_manager import get_log_manager +from app.log_manager import LogManager from app.scraper import XiaoHongShuScraper +from app import auth_manager +from app import user_resolver -# ========== Pydantic 模型 ========== +# ========================= +# App State +# ========================= + +@dataclass +class AppState: + is_running: bool = False + scraper: Optional[XiaoHongShuScraper] = None + event_queue: Optional[asyncio.Queue] = None + playwright: Any = None + browser: Optional[Browser] = None + + +app_state = AppState(event_queue=asyncio.Queue()) +log_manager = LogManager() + + +# ========================= +# FastAPI init +# ========================= + +app = FastAPI(title="小红书数据采集器", version="1.1.0") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + expose_headers=["*"], +) + +# 静态资源 + 模板 +app.mount("/static", StaticFiles(directory=os.path.join(config.BASE_DIR, "static")), name="static") +templates = Jinja2Templates(directory=os.path.join(config.BASE_DIR, "templates")) + + +@app.on_event("startup") +async def on_startup(): + # 启动 Playwright + Browser(长期复用,减少反复启动带来的抖动) + app_state.playwright = await async_playwright().start() + app_state.browser = await app_state.playwright.chromium.launch( + headless=config.HEADLESS, + args=config.BROWSER_ARGS, + ) + log_manager.append("INFO", f"Browser launched (headless={config.HEADLESS})") + + +@app.on_event("shutdown") +async def on_shutdown(): + # 安全关闭 + try: + if app_state.browser: + await app_state.browser.close() + finally: + app_state.browser = None + + try: + if app_state.playwright: + await app_state.playwright.stop() + finally: + app_state.playwright = None + + +# ========================= +# Models +# ========================= + class StartRequest(BaseModel): mode: str = "single" # single / multi max_threads: int = 3 class StatusResponse(BaseModel): - is_running: bool - mode: str - max_threads: int - total_users: int - completed_users: int - failed_users: int - active_threads: int - users_status: Dict[str, Dict] + running: bool + mode: Optional[str] = None + user_count: int = 0 + + +class UsersResponse(BaseModel): + users: List[str] + count: int + + +class ImportResponse(BaseModel): + users: List[str] + count: int + total_lines: int + imported: int + skipped: int + resolved: int + failed_resolve: int + duplicates: int + invalid: int + message: str + # 可选返回一些失败明细(避免过大,只给前 20 条) + failed_examples: List[str] = [] -# ========== 全局状态 ========== -class AppState: - def __init__(self): - self.is_running = False - self.mode = "single" - self.max_threads = 1 - self.total_users = 0 - self.completed_users = 0 - self.failed_users = 0 - self.active_threads = 0 - self.users_status: Dict[str, Dict] = {} - self.thread_status: Dict[str, Dict] = {} - self.event_queue: asyncio.Queue = None - self.scraper: Optional[XiaoHongShuScraper] = None - self.scraper_task: Optional[asyncio.Task] = None - self.browser = None - self.playwright = None - - -app_state = AppState() -log_manager = get_log_manager() - -# ========== FastAPI 应用 ========== -app = FastAPI(title="小红书数据采集器", version="1.0.0") - -# 添加 CORS 中间件 -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], # 生产环境应该指定具体域名 - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], - expose_headers=["*"] -) - -# 挂载静态文件和模板 -app.mount("/static", StaticFiles(directory="static"), name="static") -templates = Jinja2Templates(directory="templates") - - -# ========== 路由 ========== - -@app.get("/", response_class=HTMLResponse) -async def index(request: Request): - """首页""" - return templates.TemplateResponse("index.html", {"request": request}) - - - - -@app.get("/api/status", response_model=StatusResponse) -async def get_status(): - """获取当前状态""" - return StatusResponse( - is_running=app_state.is_running, - mode=app_state.mode, - max_threads=app_state.max_threads, - total_users=app_state.total_users, - completed_users=app_state.completed_users, - failed_users=app_state.failed_users, - active_threads=app_state.active_threads, - users_status=app_state.users_status - ) - - -@app.get("/api/debug/browser") -async def debug_browser(): - """调试浏览器状态""" - return { - "browser_exists": app_state.browser is not None, - "contexts_count": len(app_state.browser.contexts) if app_state.browser else 0, - "contexts": [str(type(ctx)) for ctx in app_state.browser.contexts] if app_state.browser else [], - "data_dir": config.DATA_DIR, - "auth_file_exists": os.path.exists(os.path.join(config.DATA_DIR, "auth_state.json")), - "storage_file_exists": os.path.exists(os.path.join(config.DATA_DIR, "storage_state.json")) - } - - -@app.post("/api/start") -async def start_scraping(request: StartRequest): - """启动采集""" - if app_state.is_running: - raise HTTPException(status_code=400, detail="采集任务正在运行中") - - # 读取用户 ID 列表 - users_file = os.path.join(config.DATA_DIR, "users.txt") - if not os.path.exists(users_file): - raise HTTPException(status_code=400, detail="用户文件不存在,请先创建 data/users.txt") - - with open(users_file, 'r', encoding='utf-8') as f: - user_ids = [line.strip() for line in f if line.strip()] - - if not user_ids: - raise HTTPException(status_code=400, detail="用户 ID 列表为空") - - # 验证模式 - if request.mode not in ["single", "multi"]: - raise HTTPException(status_code=400, detail="无效的模式,必须是 single 或 multi") - - # 验证线程数 - if request.mode == "multi": - if request.max_threads < 1 or request.max_threads > config.MAX_CONCURRENT_THREADS: - raise HTTPException(status_code=400, detail=f"线程数必须在 1-{config.MAX_CONCURRENT_THREADS} 之间") - - # 重置状态 - app_state.is_running = True - app_state.mode = request.mode - app_state.max_threads = request.max_threads if request.mode == "multi" else 1 - app_state.total_users = len(user_ids) - app_state.completed_users = 0 - app_state.failed_users = 0 - app_state.active_threads = 0 - app_state.users_status = {} - app_state.thread_status = {} - - # 初始化用户状态 - for user_id in user_ids: - app_state.users_status[user_id] = { - "status": "pending", - "note_count": 0, - "thread_id": None - } - - # 创建事件队列 - app_state.event_queue = asyncio.Queue() - - # 创建采集器(传入已初始化的浏览器实例) - app_state.scraper = XiaoHongShuScraper(app_state.event_queue, log_manager, app_state.browser) - - # 启动采集任务 - async def run_scraper(): - try: - await app_state.scraper.run( - user_ids=user_ids, - mode=request.mode, - max_threads=request.max_threads - ) - finally: - app_state.is_running = False - await app_state.event_queue.put({ - "type": "finished" - }) - - app_state.scraper_task = asyncio.create_task(run_scraper()) - - return { - "success": True, - "message": f"采集任务已启动,模式: {request.mode}, 用户数: {len(user_ids)}", - "total_users": len(user_ids), - "mode": request.mode, - "max_threads": app_state.max_threads - } - - -@app.post("/api/stop") -async def stop_scraping(): - """停止采集""" - if not app_state.is_running: - raise HTTPException(status_code=400, detail="没有正在运行的采集任务") - - if app_state.scraper: - app_state.scraper.stop() - - # 取消任务 - if app_state.scraper_task: - app_state.scraper_task.cancel() - try: - await app_state.scraper_task - except asyncio.CancelledError: - pass - - app_state.is_running = False - - return { - "success": True, - "message": "采集任务已停止" - } - - -@app.post("/api/logs/clear") -async def clear_logs(): - """清空日志""" - log_manager.clear() - return { - "success": True, - "message": "日志已清空" - } - - -@app.get("/api/logs") -async def get_logs(level: Optional[str] = None, limit: Optional[int] = None): - """获取日志""" - logs = log_manager.get_logs(level=level, limit=limit) - return { - "logs": logs, - "stats": log_manager.get_stats() - } - - -@app.get("/api/logs/search") -async def search_logs(keyword: str, limit: Optional[int] = None): - """搜索日志""" - logs = log_manager.search_logs(keyword, limit) - return { - "logs": logs, - "count": len(logs) - } - - -@app.get("/api/logs/download") -async def download_logs(): - """下载日志文件""" - text = log_manager.export_to_text() - - return StreamingResponse( - iter([text]), - media_type="text/plain", - headers={ - "Content-Disposition": f"attachment; filename=xiaohongshu_logs_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt" - } - ) - - -@app.get("/api/users") -async def get_users(): - """获取用户列表""" - users_file = os.path.join(config.DATA_DIR, "users.txt") - - if not os.path.exists(users_file): - return {"users": []} - - with open(users_file, 'r', encoding='utf-8') as f: - users = [{"id": line.strip()} for line in f if line.strip()] - - # 添加采集状态 - for user in users: - user_id = user["id"] - if user_id in app_state.users_status: - user.update(app_state.users_status[user_id]) - else: - user["status"] = "pending" - user["note_count"] = 0 - - return {"users": users} - - -@app.post("/api/users") -async def add_user(request: dict): - """添加用户""" - user_id = request.get("user_id", "").strip() - - if not user_id: - raise HTTPException(status_code=400, detail="用户ID不能为空") - - # 验证用户ID格式 - if not user_id or len(user_id) < 10: - raise HTTPException(status_code=400, detail="用户ID格式不正确") - - users_file = os.path.join(config.DATA_DIR, "users.txt") - - # 检查是否已存在 - if os.path.exists(users_file): - with open(users_file, 'r', encoding='utf-8') as f: - existing_users = [line.strip() for line in f if line.strip()] - if user_id in existing_users: - raise HTTPException(status_code=400, detail="用户ID已存在") - - # 添加到文件 - with open(users_file, 'a', encoding='utf-8') as f: - f.write(f"{user_id}\n") - - return { - "success": True, - "message": f"用户 {user_id} 已添加", - "user_id": user_id - } - - -@app.delete("/api/users/{user_id}") -async def remove_user(user_id: str): - """删除用户""" - users_file = os.path.join(config.DATA_DIR, "users.txt") - - if not os.path.exists(users_file): - raise HTTPException(status_code=404, detail="用户文件不存在") - - # 读取所有用户 - with open(users_file, 'r', encoding='utf-8') as f: - users = [line.strip() for line in f if line.strip()] - - # 检查用户是否存在 - if user_id not in users: - raise HTTPException(status_code=404, detail="用户不存在") - - # 删除用户 - users.remove(user_id) - - # 写回文件 - with open(users_file, 'w', encoding='utf-8') as f: - for user in users: - f.write(f"{user}\n") - - # 删除采集数据文件 - data_file = os.path.join(config.DATA_DIR, f"{user_id}.json") - if os.path.exists(data_file): - os.remove(data_file) - - return { - "success": True, - "message": f"用户 {user_id} 已删除" - } - - -@app.delete("/api/users") -async def clear_all_users(): - """清空所有用户""" - users_file = os.path.join(config.DATA_DIR, "users.txt") - - if not os.path.exists(users_file): - raise HTTPException(status_code=404, detail="用户文件不存在") - - # 清空文件 - with open(users_file, 'w', encoding='utf-8') as f: - f.write("") - - # 删除所有采集数据 - data_dir = config.DATA_DIR - for filename in os.listdir(data_dir): - if filename.endswith('.json') and filename != 'auth_state.json': - file_path = os.path.join(data_dir, filename) - os.remove(file_path) - - # 重置状态 - app_state.users_status = {} - - return { - "success": True, - "message": "所有用户已清空" - } - - -@app.post("/api/users/import/txt") -async def import_users_from_txt(): - """从users.txt导入用户(模拟)""" - users_file = os.path.join(config.DATA_DIR, "users.txt") - - if not os.path.exists(users_file): - raise HTTPException(status_code=404, detail="users.txt文件不存在") - - with open(users_file, 'r', encoding='utf-8') as f: - user_ids = [line.strip() for line in f if line.strip()] - - return { - "success": True, - "count": len(user_ids), - "users": user_ids - } - - -@app.get("/api/users/export/txt") -async def export_users_to_txt(): - """导出用户列表到txt文件""" - users_file = os.path.join(config.DATA_DIR, "users.txt") - - if not os.path.exists(users_file): - raise HTTPException(status_code=404, detail="用户文件不存在") - - with open(users_file, 'r', encoding='utf-8') as f: - content = f.read() - - return StreamingResponse( - iter([content]), - media_type="text/plain", - headers={ - "Content-Disposition": f"attachment; filename=users_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt" - } - ) - - -# ========== 登录相关 API ========== - class LoginStatusResponse(BaseModel): logged_in: bool login_time: Optional[str] = None - expire_time: Optional[str] = None + last_verified_time: Optional[str] = None + message: Optional[str] = None +# ========================= +# Pages +# ========================= + +@app.get("/", response_class=HTMLResponse) +async def index(request: Request): + return templates.TemplateResponse("index.html", {"request": request}) + + +# ========================= +# Helpers +# ========================= + +def _users_file() -> str: + return os.path.join(config.DATA_DIR, "users.txt") + + +def read_users_txt() -> List[str]: + path = _users_file() + if not os.path.exists(path): + return [] + users: List[str] = [] + with open(path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + users.append(line) + # 去重但保持顺序 + seen = set() + out = [] + for u in users: + if u in seen: + continue + seen.add(u) + out.append(u) + return out + + +def write_users_txt(users: List[str]) -> None: + path = _users_file() + tmp = path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + for u in users: + f.write(u + "\n") + os.replace(tmp, path) + + +def require_browser() -> Browser: + if app_state.browser is None: + raise HTTPException(status_code=500, detail="Browser not ready") + return app_state.browser + + +# ========================= +# Status / Logs +# ========================= + +@app.get("/api/status", response_model=StatusResponse) +async def api_status(): + return StatusResponse( + running=app_state.is_running, + mode=getattr(app_state.scraper, "mode", None) if app_state.scraper else None, + user_count=len(read_users_txt()), + ) + + +@app.post("/api/logs/clear") +async def clear_logs(): + log_manager.clear() + return {"success": True} + + +@app.get("/api/logs") +async def get_logs(limit: int = 200): + return {"logs": log_manager.get_latest(limit=limit)} + + +# ========================= +# Users +# ========================= + +@app.get("/api/users", response_model=UsersResponse) +async def get_users(): + users = read_users_txt() + return UsersResponse(users=users, count=len(users)) + + +@app.post("/api/users") +async def add_user(user_id: str): + user_id = (user_id or "").strip() + if not user_id: + raise HTTPException(status_code=400, detail="user_id 不能为空") + + users = read_users_txt() + if user_id in users: + return {"success": True, "message": "已存在"} + + users.append(user_id) + write_users_txt(users) + return {"success": True} + + +@app.delete("/api/users/{user_id}") +async def delete_user(user_id: str): + users = read_users_txt() + users = [u for u in users if u != user_id] + write_users_txt(users) + return {"success": True} + + +@app.delete("/api/users") +async def delete_all_users(): + write_users_txt([]) + return {"success": True} + + +@app.get("/api/users/export/txt") +async def export_users_txt(): + return PlainTextResponse("\\n".join(read_users_txt())) + + +@app.get("/api/users/import/txt", response_model=ImportResponse) +async def import_users_from_txt(): + """从 data/users.txt 读取并规范化: + - 若是 profile_id / profile URL:直接收录 + - 若是纯数字小红书号:通过搜索页解析成 profile_id(Playwright,低频、缓存) + - 输出:users.txt 最终仅保存 profile_id(稳定可采集) + """ + path = _users_file() + if not os.path.exists(path): + raise HTTPException(status_code=400, detail="data/users.txt 不存在") + + with open(path, "r", encoding="utf-8") as f: + raw_lines = [l.rstrip("\n") for l in f] + + total_lines = len(raw_lines) + + browser = require_browser() + + imported_ids: List[str] = [] + duplicates = 0 + invalid = 0 + skipped = 0 + resolved = 0 + failed_resolve = 0 + failed_examples: List[str] = [] + + seen = set() + + # 第一轮:先把能直接解析的收集,数字 keyword 单独列表 + keywords: List[tuple[str, str]] = [] # (keyword, raw_line) + for line in raw_lines: + kw, pid, _label = user_resolver.parse_line_to_keyword_or_profile(line) + if pid: + pid = pid.lower() + if pid in seen: + duplicates += 1 + continue + seen.add(pid) + imported_ids.append(pid) + continue + if kw: + keywords.append((kw, line)) + else: + # 空行/注释等视为 skipped;其它无法识别为 invalid + if not line.strip() or line.strip().startswith("#") or line.strip().startswith("//"): + skipped += 1 + else: + invalid += 1 + if len(failed_examples) < 20: + failed_examples.append(f"invalid: {line}") + continue + + # 第二轮:逐个解析 keyword(单线程+慢一点,减少风控) + for kw, raw in keywords: + pid, msg = await user_resolver.resolve_keyword_to_profile_id(browser, kw, slow_mode=True) + if not pid: + failed_resolve += 1 + if len(failed_examples) < 20: + failed_examples.append(f"resolve_failed({kw}): {raw} ({msg})") + continue + pid = pid.lower() + if pid in seen: + duplicates += 1 + continue + seen.add(pid) + imported_ids.append(pid) + resolved += 1 + + # 每解析一个稍微间隔一下(更像真人) + await asyncio.sleep(0.8) + + # 写回:只保存 profile_id + write_users_txt(imported_ids) + + imported = len(imported_ids) + message = f"导入完成:{imported} 个 profile_id(解析成功 {resolved})" + + log_manager.append("INFO", message) + + return ImportResponse( + users=imported_ids, + count=imported, + total_lines=total_lines, + imported=imported, + skipped=skipped, + resolved=resolved, + failed_resolve=failed_resolve, + duplicates=duplicates, + invalid=invalid, + message=message, + failed_examples=failed_examples, + ) + + +# ========================= +# Login +# ========================= + @app.get("/api/login/status", response_model=LoginStatusResponse) async def get_login_status(): - """获取登录状态""" - auth_file = os.path.join(config.DATA_DIR, "auth_state.json") - - if not os.path.exists(auth_file): - return LoginStatusResponse(logged_in=False) - - try: - with open(auth_file, 'r', encoding='utf-8') as f: - auth_data = json.load(f) - - # 检查是否过期 - login_time = auth_data.get("login_time") - expire_time = auth_data.get("expire_time") - current_time = datetime.now().isoformat() - - if expire_time and current_time > expire_time: - # 登录已过期,清除状态 - os.remove(auth_file) - return LoginStatusResponse(logged_in=False) - - return LoginStatusResponse( - logged_in=auth_data.get("logged_in", False), - login_time=login_time, - expire_time=expire_time - ) - except Exception: - return LoginStatusResponse(logged_in=False) + status = await auth_manager.get_login_status(app_state.browser) + return LoginStatusResponse( + logged_in=status.logged_in, + login_time=status.login_time, + last_verified_time=status.last_verified_time, + message=status.message, + ) @app.post("/api/login/qrcode") -async def generate_qrcode(): - """重定向到小红书官方登录页面""" - # 返回小红书登录页面 URL - login_url = "https://www.xiaohongshu.com/login" +async def open_login(): + """兼容旧前端:返回 login_url,同时在服务端打开 Playwright 登录页。 + 说明:前端 window.open 打开的页面不会影响服务端 Playwright 的登录态。 + 正确使用方式:在部署环境(Docker + VNC)里打开 VNC 浏览器扫码登录。 + """ + browser = require_browser() + await auth_manager.open_login_page(browser) return { - "login_url": login_url, - "message": "请在打开的小红书页面完成扫码登录", - "instruction": "登录成功后,返回此页面点击'验证登录'按钮" + "login_url": config.LOGIN_URL, + "hint": "已在服务端打开登录页。请在 VNC/有界面环境里扫码登录,然后点击“验证登录”。" } -@app.get("/api/check-login-status") -async def check_login_status(): - """检查小红书登录状态(直接验证)""" - try: - print("[DEBUG] === 检查浏览器状态 ===") - print(f"[DEBUG] 浏览器实例存在: {app_state.browser is not None}") - - # 如果浏览器可用,直接使用浏览器验证 - if app_state.browser: - print("[DEBUG] 浏览器可用,尝试获取context...") - if app_state.browser.contexts: - print(f"[DEBUG] 已有 {len(app_state.browser.contexts)} 个contexts") - context = app_state.browser.contexts[0] - print("[DEBUG] 使用现有context") - else: - print("[DEBUG] 创建新context") - context = await app_state.browser.new_context() - - try: - page = await context.new_page() - await page.goto("https://www.xiaohongshu.com/", timeout=10000) - await asyncio.sleep(2) - - # 获取页面cookies - cookies = await context.cookies() - print(f"[DEBUG] 找到 {len(cookies)} 个cookies") - - # 打印所有cookies - for cookie in cookies: - cookie_name = cookie.get('name', '') - cookie_value = cookie.get('value', '')[:20] if cookie.get('value') else '' - print(f"[DEBUG] Cookie: {cookie_name} = {cookie_value}...") - - # 检查是否有小红书相关的session cookie - session_cookies = [] - for cookie in cookies: - cookie_name = cookie.get('name', '').lower() - if any(keyword in cookie_name for keyword in ['session', 'token', 'login', 'auth']): - session_cookies.append(cookie) - print(f"[DEBUG] ✓ 找到登录cookie: {cookie_name}") - - if session_cookies: - return { - "success": True, - "message": "已登录小红书", - "logged_in": True, - "cookies_count": len(session_cookies) - } - else: - print("[DEBUG] ✗ 未找到登录相关的 cookies") - return { - "success": False, - "message": "未找到登录状态,请先登录" - } - - except Exception as e: - print(f"[DEBUG] 浏览器页面操作失败: {str(e)}") - pass - finally: - # 确保页面关闭 - if 'page' in locals(): - await page.close() - else: - print("[DEBUG] ✗ 浏览器实例不存在") - - # 检查本地是否有登录状态文件 - print("[DEBUG] 检查本地登录状态文件...") - auth_file = os.path.join(config.DATA_DIR, "auth_state.json") - if os.path.exists(auth_file): - try: - with open(auth_file, 'r', encoding='utf-8') as f: - auth_data = json.load(f) - print(f"[DEBUG] ✓ 找到本地auth_state: {auth_data}") - return { - "success": True, - "message": "本地登录状态有效", - "logged_in": True, - "login_time": auth_data.get("login_time"), - "expire_time": auth_data.get("expire_time") - } - except Exception as e: - print(f"[DEBUG] ✗ 读取auth_state失败: {str(e)}") - - print("[DEBUG] ✗ 未找到任何登录状态") - return { - "success": False, - "message": "未找到登录状态,请先登录" - } - - except Exception as e: - print(f"[DEBUG] ✗ 检查登录状态异常: {str(e)}") - return { - "success": False, - "message": f"检查登录状态失败: {str(e)}" - } - - @app.post("/api/login/verify") async def verify_login(): - """验证登录状态""" - try: - print("[DEBUG] 开始验证登录状态...") - - # 首先尝试保存 storage_state - if app_state.browser and app_state.browser.context: - try: - # 创建 storage_state 文件 - storage_file = os.path.join(config.DATA_DIR, "storage_state.json") - print(f"[DEBUG] 尝试保存 storage_state 到: {storage_file}") - await app_state.browser.context.storage_state(path=storage_file) - print(f"[DEBUG] ✓ 成功保存 storage_state: {storage_file}") - - # 检查文件是否存在和大小 - if os.path.exists(storage_file): - size = os.path.getsize(storage_file) - print(f"[DEBUG] storage_state 文件大小: {size} bytes") - if size > 0: - # 使用 storage_state 验证登录 - print("[DEBUG] 使用 storage_state 验证登录...") - return await verify_login_with_storage(storage_file) - else: - print("[DEBUG] storage_state 文件为空") - else: - print("[DEBUG] storage_state 文件未创建") - - except Exception as e: - print(f"[DEBUG] ✗ 保存 storage_state 失败: {str(e)}") - pass - - # 如果保存失败,尝试直接检查 cookies - print("[DEBUG] 尝试直接检查 cookies...") - if app_state.browser: - try: - cookies = await app_state.browser.context.cookies() - print(f"[DEBUG] 找到 {len(cookies)} 个cookies") - - # 打印所有 cookies 用于调试 - for cookie in cookies: - cookie_name = cookie.get('name', '') - cookie_value = cookie.get('value', '')[:20] if cookie.get('value') else '' - print(f"[DEBUG] Cookie: {cookie_name} = {cookie_value}...") - - # 检查是否有登录相关的 cookie - session_cookies = [] - for cookie in cookies: - cookie_name = cookie.get('name', '').lower() - if any(keyword in cookie_name for keyword in ['session', 'token', 'auth', 'passport', 'sid']): - session_cookies.append(cookie) - print(f"[DEBUG] ✓ 找到登录cookie: {cookie_name}") - - # 创建 auth_state 文件 - auth_file = os.path.join(config.DATA_DIR, "auth_state.json") - auth_data = { - "login_time": datetime.now().isoformat(), - "expire_time": (datetime.now() + timedelta(hours=24)).isoformat() - } - with open(auth_file, 'w', encoding='utf-8') as f: - json.dump(auth_data, f, ensure_ascii=False, indent=2) - - print("[DEBUG] ✓ 创建 auth_state 文件成功") - return { - "success": True, - "message": "登录成功", - "logged_in": True, - "login_time": auth_data["login_time"], - "expire_time": auth_data["expire_time"], - "expire_in_hours": 24 - } - - if not session_cookies: - print("[DEBUG] ✗ 未找到登录相关的 cookies") - - except Exception as e: - print(f"[DEBUG] ✗ 检查cookies失败: {str(e)}") - pass - - # 检查本地登录状态文件 - print("[DEBUG] 检查本地登录状态文件...") - auth_file = os.path.join(config.DATA_DIR, "auth_state.json") - if os.path.exists(auth_file): - try: - with open(auth_file, 'r', encoding='utf-8') as f: - auth_data = json.load(f) - print(f"[DEBUG] ✓ 找到本地 auth_state 文件: {auth_data}") - - # 检查登录是否过期 - login_time = auth_data.get("login_time") - expire_time = auth_data.get("expire_time") - current_time = datetime.now().isoformat() - - if expire_time and current_time > expire_time: - # 登录已过期,清除状态 - print("[DEBUG] ✗ 登录已过期") - os.remove(auth_file) - return { - "success": False, - "message": "登录已过期,请重新登录" - } - - print("[DEBUG] ✓ 本地登录状态有效") - return { - "success": True, - "message": "登录成功", - "logged_in": True, - "login_time": login_time, - "expire_time": expire_time, - "expire_in_hours": 24 - } - except Exception as e: - print(f"[DEBUG] ✗ 读取 auth_state 文件失败: {str(e)}") - - print("[DEBUG] ✗ 未找到任何登录状态") - return { - "success": False, - "message": "未找到登录状态,请先登录" - } - - except Exception as e: - print(f"[DEBUG] ✗ 验证登录异常: {str(e)}") - return { - "success": False, - "message": f"验证失败: {str(e)}" - } - - -async def verify_login_with_storage(storage_file: str): - """使用 storage_state 文件验证登录状态""" - try: - from pathlib import Path - from playwright.async_api import async_playwright - - pth = Path(storage_file) - if not pth.exists(): - return { - "success": False, - "message": "未找到登录状态文件" - } - - # 使用 async_playwright 创建临时浏览器实例验证 - async with async_playwright() as p: - # 使用与主浏览器相同的配置 - browser = await p.chromium.launch( - headless=True, # 无头模式验证 - args=["--no-sandbox", "--disable-setuid-sandbox"] - ) - - # 使用 storage_state 创建新 context - context = await browser.new_context( - storage_state=str(pth), - locale="zh-CN", - viewport={"width": 1200, "height": 800} - ) - - page = await context.new_page() - - # 访问小红书主页 - await page.goto("https://www.xiaohongshu.com", wait_until="domcontentloaded", timeout=60000) - await asyncio.sleep(1.2) # 等待页面加载 - - # 强拦截关键词 - 使用更可靠的元素检查方式 - block_texts = ["扫码登录", "安全验证", "请先登录", "登录后查看"] - for text in block_texts: - try: - if await page.get_by_text(text).first.is_visible(): - await browser.close() - return { - "success": False, - "message": f"登录状态无效或被拦截:页面出现「{text}」" - } - except Exception: - pass - - # 弱判断:若明显出现"登录"且可见,可能未登录 - try: - if await page.get_by_text("登录").first.is_visible(): - await browser.close() - return { - "success": False, - "message": "疑似未登录(页面出现明显「登录」入口),建议重新登录保存状态" - } - except Exception: - pass - - # 检查 URL 是否被重定向 - current_url = page.url - if "login" in current_url.lower() or "404" in current_url: - await browser.close() - return { - "success": False, - "message": "被重定向到登录页面,登录状态无效" - } - - # 检查页面标题 - title = await page.title() - if "登录" in title or "404" in title: - await browser.close() - return { - "success": False, - "message": "页面标题显示登录或错误,登录状态无效" - } - - # 所有检查都通过,认为登录有效 - await browser.close() - - # 创建 auth_state 文件作为备份 - auth_file = os.path.join(config.DATA_DIR, "auth_state.json") - auth_data = { - "login_time": datetime.now().isoformat(), - "expire_time": (datetime.now() + timedelta(hours=24)).isoformat() - } - with open(auth_file, 'w', encoding='utf-8') as f: - json.dump(auth_data, f, ensure_ascii=False, indent=2) - - print(f"[DEBUG] 登录验证成功") - return { - "success": True, - "message": "登录成功", - "logged_in": True, - "login_time": auth_data["login_time"], - "expire_time": auth_data["expire_time"], - "expire_in_hours": 24 - } - - except Exception as e: - print(f"[DEBUG] storage_state 验证失败: {str(e)}") - return { - "success": False, - "message": f"登录验证失败: {str(e)}" - } + browser = require_browser() + ok, msg = await auth_manager.verify_login(browser) + meta = auth_manager.load_auth_meta() + return { + "success": ok, + "message": None if ok else msg, + "login_time": meta.get("login_time"), + "expire_time": None, # 单账号长期复用:不强制本地过期时间 + "last_verified_time": meta.get("last_verified_time"), + } @app.post("/api/logout") async def logout(): - """退出登录""" - auth_file = os.path.join(config.DATA_DIR, "auth_state.json") + auth_manager.clear_login_state() + return {"success": True} - try: - if os.path.exists(auth_file): - os.remove(auth_file) - return { - "success": True, - "message": "已退出登录" - } - except Exception as e: - raise HTTPException(status_code=500, detail=f"退出登录失败: {str(e)}") +# ========================= +# Scrape control +# ========================= +@app.post("/api/start") +async def start_scraping(request: StartRequest, background_tasks: BackgroundTasks): + if app_state.is_running: + raise HTTPException(status_code=400, detail="采集任务正在运行中") + + users = read_users_txt() + if not users: + raise HTTPException(status_code=400, detail="用户列表为空,请先导入 users.txt") + + # 启动前校验登录态(单账号) + browser = require_browser() + ok, msg = await auth_manager.verify_login(browser) + if not ok: + raise HTTPException(status_code=400, detail=f"未登录或登录失效:{msg}") + + app_state.is_running = True + app_state.scraper = XiaoHongShuScraper(app_state.event_queue, log_manager, browser) + + async def run_scraper(): + try: + await app_state.scraper.run(user_ids=users, mode=request.mode, max_threads=request.max_threads) + finally: + app_state.is_running = False + + background_tasks.add_task(run_scraper) + return {"success": True} + + +@app.post("/api/stop") +async def stop_scraping(): + if not app_state.is_running or not app_state.scraper: + return {"success": True, "message": "未在运行"} + + await app_state.scraper.stop() + app_state.is_running = False + return {"success": True} + + +# ========================= +# Data +# ========================= @app.get("/api/data/{user_id}") async def get_user_data(user_id: str): - """获取用户采集数据""" + # 读取该用户数据文件(若存在) file_path = os.path.join(config.DATA_DIR, f"{user_id}.json") - if not os.path.exists(file_path): - raise HTTPException(status_code=404, detail="用户数据不存在") + raise HTTPException(status_code=404, detail="数据不存在") + try: + with open(file_path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) - with open(file_path, 'r', encoding='utf-8') as f: - data = json.load(f) - - return data +# ========================= +# SSE events +# ========================= @app.get("/api/events") -async def event_stream(): - """SSE 事件流""" +async def sse_events(): async def event_generator(): while True: - try: - # 等待事件,超时 1 秒发送心跳 - event = await asyncio.wait_for( - app_state.event_queue.get(), - timeout=1.0 - ) + event = await app_state.event_queue.get() + yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n" - # 更新状态 - if event["type"] == "log": - # 日志不需要更新状态 - pass - elif event["type"] == "progress": - app_state.completed_users = event.get("completed", app_state.completed_users) - app_state.failed_users = event.get("failed", app_state.failed_users) - app_state.active_threads = event.get("active_threads", app_state.active_threads) - elif event["type"] == "user_status": - if event["user_id"] in app_state.users_status: - app_state.users_status[event["user_id"]]["status"] = event["status"] - app_state.users_status[event["user_id"]]["note_count"] = event.get("note_count", 0) - app_state.users_status[event["user_id"]]["thread_id"] = event.get("thread_id") - elif event["type"] == "thread_status": - app_state.thread_status[event["thread_id"]] = { - "status": event["status"], - "current_user": event.get("current_user") - } - # 更新活跃线程数 - app_state.active_threads = sum( - 1 for s in app_state.thread_status.values() if s.get("status") == "busy" - ) - - # 发送事件 - yield f"data: {json.dumps(event)}\n\n" - - except asyncio.TimeoutError: - # 发送心跳 - yield ": heartbeat\n\n" - - except Exception as e: - # 错误事件 - yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n" - - return StreamingResponse( - event_generator(), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "Access-Control-Allow-Origin": "*" - } - ) - - -# ========== Git 相关 API ========== - -@app.get("/api/git/status") -async def git_status(): - """获取 Git 状态""" - if not os.path.exists("/app/.git"): - return { - "enabled": False, - "message": "不是 Git 仓库" - } - - try: - # 获取当前分支 - result = subprocess.run( - ["git", "branch", "--show-current"], - capture_output=True, - text=True, - cwd="/app" - ) - current_branch = result.stdout.strip() - - # 获取最新提交 - result = subprocess.run( - ["git", "log", "-1", "--format=%h %s %cr"], - capture_output=True, - text=True, - cwd="/app" - ) - latest_commit = result.stdout.strip() - - # 检查是否有未提交的更改 - result = subprocess.run( - ["git", "status", "--porcelain"], - capture_output=True, - text=True, - cwd="/app" - ) - has_changes = len(result.stdout.strip()) > 0 - - return { - "enabled": True, - "branch": current_branch, - "latest_commit": latest_commit, - "has_changes": has_changes, - "auto_pull_enabled": config.ENABLE_GIT_PULL, - "auto_pull_interval": config.AUTO_PULL_INTERVAL - } - - except Exception as e: - return { - "enabled": False, - "error": str(e) - } - - -@app.post("/api/git/pull") -async def git_pull(): - """手动拉取最新代码""" - if not os.path.exists("/app/.git"): - raise HTTPException(status_code=400, detail="不是 Git 仓库") - - try: - # 获取当前分支 - result = subprocess.run( - ["git", "branch", "--show-current"], - capture_output=True, - text=True, - cwd="/app" - ) - current_branch = result.stdout.strip() - - # 拉取最新代码 - result = subprocess.run( - ["git", "fetch", "origin", current_branch], - capture_output=True, - text=True, - cwd="/app" - ) - - if result.returncode != 0: - raise HTTPException(status_code=500, detail=f"拉取失败: {result.stderr}") - - # 检查是否有更新 - result = subprocess.run( - ["git", "rev-list", "HEAD..origin/" + current_branch, "--count"], - capture_output=True, - text=True, - cwd="/app" - ) - commits_behind = int(result.stdout.strip()) - - if commits_behind == 0: - return { - "success": True, - "message": "已是最新代码", - "needs_restart": False - } - - # 重置到最新代码 - result = subprocess.run( - ["git", "reset", "--hard", "origin/" + current_branch], - capture_output=True, - text=True, - cwd="/app" - ) - - if result.returncode != 0: - raise HTTPException(status_code=500, detail=f"重置失败: {result.stderr}") - - # 检查 Python 文件是否有变化 - result = subprocess.run( - ["git", "diff", "--name-only", "HEAD@{1}", "HEAD"], - capture_output=True, - text=True, - cwd="/app" - ) - changed_files = result.stdout.strip().split('\n') - python_changed = any(f.endswith('.py') for f in changed_files if f) - - return { - "success": True, - "message": f"已拉取 {commits_behind} 个新提交", - "changed_files": changed_files, - "needs_restart": python_changed, - "restart_hint": "Python 代码有变化,建议重启容器以应用更新" - } - - except HTTPException: - raise - except Exception as e: - raise HTTPException(status_code=500, detail=f"拉取失败: {str(e)}") - - -@app.post("/api/git/restart") -async def restart_container(): - """ - 请求重启容器 - 注意:这需要在容器外配置重启策略 - """ - return { - "success": True, - "message": "重启请求已接收", - "methods": [ - "docker restart xiaohongshu-scraper", - "docker-compose restart" - ] - } - - -# ========== 启动事件 ========== -@app.on_event("startup") -async def startup_event(): - """启动时初始化""" - # 确保数据目录存在 - os.makedirs(config.DATA_DIR, exist_ok=True) - os.makedirs(config.SCREENSHOT_DIR, exist_ok=True) - - # 初始化事件队列 - app_state.event_queue = asyncio.Queue() - - # 初始化浏览器 - try: - from playwright.async_api import async_playwright - app_state.playwright = await async_playwright().start() - app_state.browser = await app_state.playwright.chromium.launch( - headless=config.HEADLESS, - args=config.BROWSER_ARGS - ) - print("[INFO] 浏览器初始化完成") - except Exception as e: - print(f"[ERROR] 浏览器初始化失败: {str(e)}") - - print("=" * 60) - print("小红书数据采集器 Web 服务启动") - print(f"访问地址: http://{config.WEB_HOST}:{config.WEB_PORT}") - print("=" * 60) - - -@app.on_event("shutdown") -async def shutdown_event(): - """关闭时清理""" - if app_state.scraper: - app_state.scraper.stop() - - if app_state.scraper_task: - app_state.scraper_task.cancel() - - # 关闭浏览器 - if app_state.browser: - await app_state.browser.close() - if app_state.playwright: - await app_state.playwright.stop() - print("[INFO] 浏览器已关闭") - - print("Web 服务已关闭") - - -if __name__ == "__main__": - import uvicorn - uvicorn.run( - "app.web:app", - host=config.WEB_HOST, - port=config.WEB_PORT, - reload=True - ) + return StreamingResponse(event_generator(), media_type="text/event-stream") diff --git a/小红书数据采集器使用指南.md b/小红书数据采集器使用指南.md new file mode 100644 index 0000000..25f1166 --- /dev/null +++ b/小红书数据采集器使用指南.md @@ -0,0 +1,353 @@ +# 小红书数据采集器使用指南 + +## 系统概述 + +这是一个基于 FastAPI + Playwright 的小红书数据采集器,具有 Web 可视化监控界面,支持多线程采集、实时进度展示和持久化日志记录。 + +## 系统特点 + +- 🌐 **Web 可视化界面**:实时监控采集进度和状态 +- 🔐 **真实扫码登录**:打开小红书官方登录页面,扫码登录后验证状态 +- 👥 **账号管理**:支持添加、删除用户账号 +- 📊 **实时日志**:分类显示操作日志,支持过滤和搜索 +- 🔄 **多线程采集**:支持单线程/多线程模式切换 +- 🐳 **Docker 部署**:容器化部署,支持自动更新 +- 📁 **数据持久化**:所有数据本地存储,重启不丢失 + +## 快速开始 + +### 1. Docker 部署(推荐) + +```bash +# 克隆或下载项目 +cd /path/to/demo + +# 构建并启动容器(使用18085端口) +docker-compose up -d --build + +# 查看容器状态 +docker-compose ps + +# 查看日志 +docker-compose logs -f +``` + +### 2. 本地运行 + +```bash +# 安装依赖 +pip install -r requirements.txt + +# 安装浏览器 +playwright install chromium + +# 创建数据目录 +mkdir -p data data/screenshots + +# 运行程序 +python main.py +``` + +### 3. 访问系统 + +打开浏览器访问:`http://localhost:18085` + +## 系统界面说明 + +### 1. 账号管理区域 + +![账号管理](./images/account-management.png) + +- **添加账号**:点击添加账号按钮,输入小红书用户 ID +- **刷新账号**:重新加载账号列表和状态 +- **登录状态**: + - 🔴 红色圆点:未登录 + - 🟢 绿色圆点:已登录 + - 显示剩余登录时间(小时:分钟) + +### 2. 控制面板 + +![控制面板](./images/control-panel.png) + +- **启动采集**:开始数据采集任务 +- **停止**:停止正在进行的采集任务 +- **清空日志**:清空所有历史日志 +- **导出数据**:下载采集到的数据文件 +- **模式选择**: + - 单线程:逐个处理账号 + - 多线程:同时处理多个账号(可配置线程数) +- **线程数**:多线程模式下的并发线程数(1-10) + +### 3. 进度监控 + +![进度监控](./images/progress.png) + +- **整体进度**:显示采集进度条和百分比 +- **活跃线程**:多线程模式下显示正在运行的线程数 +- **用户列表**:显示每个用户的采集状态 + - ✅ 已完成:显示采集的笔记数量 + - 🔄 进行中:显示当前处理的笔记数量 + - ⏳ 待处理:尚未开始处理的账号 + - ❌ 失败:采集失败的账号 + +### 4. 日志面板 + +![日志面板](./images/logs.png) + +- **日志级别过滤**:可选择查看 ALL/INFO/SUCCESS/WARNING/ERROR/DEBUG 级别的日志 +- **搜索功能**:搜索特定关键词的日志 +- **自动滚动**:开启后自动滚动到最新日志 +- **时间戳**:显示每条日志的具体时间 +- **线程标识**:多线程模式下显示日志来源线程 + +### 5. 错误截图 + +![错误截图](./images/screenshots.png) + +如果采集过程中遇到错误(如风控检测),系统会自动截图并保存: +- 截图保存在:`data/screenshots/` 目录 +- 在界面中显示错误截图缩略图 +- 可点击查看大图或下载所有截图 + +## 操作流程 + +### 1. 首次使用 + +```bash +# 1. 启动系统 +docker-compose up -d --build + +# 2. 访问 Web 界面 +# 打开 http://localhost:18080 + +# 3. 添加要采集的用户 ID +# 点击"添加账号"按钮,输入小红书用户 ID +# 格式:长字符串(如 5ef74296000000000100003a) +# 可从浏览器地址栏获取:https://www.xiaohongshu.com/user/profile/用户ID + +# 4. 真实扫码登录 +# 点击"打开登录页"按钮,会在新窗口打开小红书官方登录页面 +# 在打开的页面中使用小红书App扫描二维码完成登录 +# 登录成功后,返回本页面点击"验证登录"按钮 +# 登录状态有效期:24小时 +``` + +### 2. 配置采集参数 + +1. 选择采集模式: + - **单线程**:稳定但速度慢,适合少量账号 + - **多线程**:速度快,适合大量账号(推荐 3-5 个线程) + +2. 设置线程数(多线程模式): + - 建议:3-5 个线程 + - 过多线程可能触发风控 + +### 3. 开始采集 + +1. 确保已添加至少一个用户账号 +2. 确保已登录(显示绿色状态) +3. 点击"启动采集" +4. 系统会: + - 检查登录状态 + - 读取用户列表 + - 开始采集并实时显示进度 + +### 4. 监控采集 + +采集过程中可以: +- 查看实时进度和状态 +- 监控日志输出 +- 观察各线程工作状态 +- 随时停止采集(点击"停止") + +### 5. 数据导出 + +采集完成后可以: +- 查看每个用户的数据文件:`data/{用户ID}.json` +- 下载所有日志:点击"导出数据" +- 下载错误截图(如有) + +## 用户 ID 获取方法 + +### 方法1:浏览器地址栏 + +1. 打开小红书网页版 +2. 访问目标用户主页:https://www.xiaohongshu.com/user/profile/用户名 +3. 从浏览器地址栏复制用户 ID: + ``` + https://www.xiaohongshu.com/user/profile/5ef74296000000000100003a + ``` + 复制 `5ef74296000000000100003a` 部分 + +### 方法2:开发者工具 + +1. 在用户页面按 F12 打开开发者工具 +2. 切换到 Network(网络)标签 +3. 刷新页面 +4. 查找包含 "user" 或 "profile" 的请求 +5. 从请求 URL 中提取用户 ID + +## 数据文件说明 + +### 输入文件 + +``` +data/users.txt # 用户 ID 列表,每行一个 +``` + +### 输出文件 + +``` +data/ +├── auth_state.json # 登录状态缓存 +├── logs.json # 操作日志历史 +├── users.txt # 用户列表 +├── 5ef74296000000000100003a.json # 用户采集数据 +├── 5b6c1f9e0000000001000388.json # 用户采集数据 +└── screenshots/ # 错误截图目录 + ├── error_xxx1.png + └── error_xxx2.png +``` + +### 数据格式示例 + +```json +// data/{用户ID}.json +{ + "user_id": "5ef74296000000000100003a", + "last_updated": "2025-01-12T10:30:00", + "notes": [ + { + "title": "笔记标题", + "publish_time": "2025-01-10 14:30", + "location": "上海", + "comment_count": 128, + "like_count": 5234, + "collect_count": 892, + "note_url": "https://www.xiaohongshu.com/explore/xxx" + } + ] +} +``` + +## 常见问题 + +### Q1: 为什么提示"未登录"? + +A: 登录状态 24 小时后会自动过期,请: +1. 点击"模拟登录"重新登录 +2. 检查 `data/auth_state.json` 文件是否存在 + +### Q2: 采集速度很慢怎么办? + +A: 可以尝试: +1. 使用多线程模式(建议 3-5 线程) +2. 检查网络连接 +3. 避免在高峰期采集 + +### Q3: 如何避免被封禁? + +A: 系统已内置防护措施: +1. 拟人化延迟(随机 2-60 秒) +2. 平滑滚动加载 +3. 多线程错开启动 +4. 风控检测自动截图 +5. 建议不要设置过多线程 + +### Q4: 数据采集不全怎么办? + +A: 可能原因: +1. 网络不稳定导致加载失败 +2. 笔记数量过多,滚动加载不完整 +3. 小红书页面结构变化 + +### Q5: Docker 容器无法启动? + +A: 检查: +1. Docker 是否运行 +2. 端口 18080 是否被占用 +3. Docker Compose 配置是否正确 + +## 配置说明 + +### 环境变量 + +```env +# Web 服务器配置 +PORT=18080 +HOST=0.0.0.0 + +# 浏览器配置 +HEADLESS=false # 是否无头模式 +SCREENSHOT_ON_ERROR=true # 错误时截图 + +# 多线程配置 +MAX_CONCURRENT_THREADS=5 # 最大并发线程数 +THREAD_MIN_DELAY=2 # 线程间最小间隔(秒) +``` + +### 配置文件位置 + +```python +# app/config.py +DATA_DIR = "data" # 数据目录 +SCREENSHOT_DIR = "data/screenshots" # 截图目录 +MAX_CONCURRENT_THREADS = 5 # 最大并发线程数 +``` + +## 更新和维护 + +### 1. 自动更新(Docker) + +系统支持 Git 自动更新: +1. 代码提交到远程仓库 +2. 系统自动拉取最新代码 +3. 检测到 Python 代码变化时建议重启容器 + +### 2. 手动更新 + +```bash +# 拉取最新代码 +git pull origin master + +# 重新构建容器 +docker-compose build + +# 重启服务 +docker-compose up -d +``` + +### 3. 数据备份 + +重要数据请定期备份: +```bash +# 备份数据目录 +tar -czf backup-$(date +%Y%m%d).tar.gz data/ + +# 备份特定用户数据 +cp data/*.json backup-$(date +%Y%m%d)/ +``` + +## 技术支持 + +如果遇到问题: +1. 查看 Web 界面的日志面板 +2. 检查 Docker 容器日志:`docker-compose logs` +3. 查看 `data/logs.json` 文件 +4. 确保网络连接正常 + +## 版本更新日志 + +### v1.0.0 (2025-01-12) +- ✅ 实现基础采集功能 +- ✅ Web 可视化界面 +- ✅ 多线程支持 +- ✅ 实时日志系统 +- ✅ 账号管理功能 +- ✅ 模拟登录系统 +- ✅ Docker 部署支持 +- ✅ 数据导出功能 + +--- + +**注意**:本工具仅供学习和研究使用,请遵守小红书的使用条款和相关法律法规。 \ No newline at end of file