// ==UserScript== // @name OpenCD 工具箱 // @namespace opencd-upload-helper // @version 9.76.18 // @description OpenCD 全站工具箱:发种/发布页自动填充 · 批量发种与批量打标 · 静默发布 · 自动标签(27 固定标签 / 别名归类 / MusicBrainz / 简介流派识别)· 发种查重 · 平台换源解析(Qobuz 等)· 悬浮球调度中心 · 17 套主题 // @icon data:image/x-icon;base64,AAABAAEAQEAAAAEAIAAoQgAAFgAAACgAAABAAAAAgAAAAAEAIAAAAAAAAAAAABILAAASCwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOqjAQXpogBK6qUHsuqmDezqpQf/6qUG/+qlCvrqpg3e6aQFo+miAFfpogAQ6qMCA+mjAgEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOmiABvqowO/66wd//HJav/44q7/+uvJ//ruz//56L//9tua//HGZP/triH/66gO/+qkBaXpowEx6qMDBwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOmiAAjpoQSr8L9O//789v////////////7////+//////////////////////////rryv/wwlj/66kU/+qjA4rpogATAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADpnwAd7K8j//zx2v///////v7+//7+/v/+/v7//v7+//7+/v/+/v7//v7+//7+//////////////rqxP/utzr/6aQEu+mhABsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA6J4AI+yxKv/8+e7//v////7+/v/+/v7//v7+//7+/v/+/v7//v7+//7+/v/+/v7//v7+//7+/v///////vrx//DBVP/poQSy6aEADQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOigABftrR30++vK///////+/v7//v7+//7+/v/+/v7//v7+//7+/v/+/v7//v7+//7+/v/+/v7//v7+///////89ef/7rc5/+mfAHEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADpogAI6qIGo/PLbf////////////7+/v/+/v7//v7+//7+/v/+/v7//v7+//7+/v/+/v7//v7+//7+/v/+/v7///////jjsf/rpwro6KAAIwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOifADHrpxHj9tiT/////v///////v////7+/v/+/v7//v7+//7+/v/+/v7//v7+//7+/v/+/v7//v//////////////8cRb/+ihApwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA6Z8ATOqnC93yyGb/+ea5//7///////////////////////////////////////////////7////45rn/8sls//fdov/rqxj26qIAQQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADpoQA56J4Al+yvI/fwwFP/8895//fcof/55rz/+urH//rqxf/45rr/99yh//PPef/xwVT/7K8i7OicAM/wvkz39M97/umhALzpogAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOmiAAzqogBA6J0Ab+mhAKPrqRTM7bEn7O2zLvvtsy767LEm6+ypE8rpoQCi6J0AbuqhADzpowEr6qQG2vbVjP/utTP56aIAaQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADpogAV6aIAJOqkBDDqpAQ26qQGNeqkBC/qogAj6aIAFAAAAAAAAAAAAAAAAOmhAGPuszD/9tmW/+mfAM3pogAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADpogAI6qQEvfLHY/7xxWD76JwAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOmgADfusin/9NKE/+uqGerpoQA3AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADpogAD6J8CifHCV//yzHH/6JwAmemiAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOmhAB/rqRLa88x0/++5P/3onwBIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOmiAAHpogED6qUIBemkBQbpogAX6aIAOemiAFjpogFw6aIBg+mjAo7powKa6qQDp+qkBqzqpAOm6J4C0fC/Tv/yyWr+6qYL4umiApbpogFo6aIAO+mjAw/qpgoF6aMDBOmiAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADpogAF6qQFDemiAB3pogBV6aMFk+uoEc/rqRT/6qcP/+qnDv/rqhn/7bMt/++5P//wvkz/8cNa//LIZv/yyWr/8sdm//HFYP/xwVP++OKu//fZl//wvk3/7bEm/+uoDv/qqBH/66oV5+qlCKXpogBn6aIALOqlBhDqowIK6aIAAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOmjAw3qowIl6aIAcOupE8/srBr/7K0d/++8RP/zzXf/99ud//fgqf/23J//9daO/vPNdPrwwVj07rc57O2wKefsriHk66wa4+uqF+TsriHj7bIs5vDCWPj0znv/77xF/fPMc/733qf/+OGs//XXj//xxV3/7rMs/+ytH//sqxnx6qUHoumhAFXqpAQh6aMCEumiAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA6aIAB+mhADDppAaY664f/+2yK//yx2X/9tiV//bYkv/yxF717K4h4OmfANrnmwDU6Z4AxemhAKzpoQCT6Z4AeOigAF/ooABT6aAATumhAEfpoQBF6aAATuigAFXomwC78stw/e63N/vpngDs6J0AyOebANfsrB3k8stv/PnmvP/66cL/9teU//LGY//ttjb/7K8j/+qmCrfpoABj6aEAKuqiABIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA6qMAF+mhAHbrrR797rg6//POdv/0znj/7LAn4eicAMzongCy6aEAfumgAEzpogAlAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA6aMAM+qlCOL11oz/66oV4emiARzpogAP6qAAT+mgAJjnmgDE6aEA1/DBVfj54rD/+/HX//jktP/00X//77tD/+yvIv7qogCi6KEAUOqjAiYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA6aAAHOqiAKbtsy3/8MNb//DAUfzpoADK6J4AmeihAE/pogALAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADqoQCN8L5O/fLHZv/poQCy6aIAAwAAAAAAAAAAAAAAAOmhADTpoACC55kAuOqmCd3zyWr/+unB//z15P/56cL/88x0/+63Of/qpgvQ6J4Ab+qiADXpowIDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9M95COeZAJ7ttDL/8L9P/+umCdPpnwB/6aIAGwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA6aEAGOqkA9j104f/7LAo8emgAFkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADqogBE6J0AkeidAMbvukH/99yf//358P/++/T/9tiU//C+TP/rqRPg6J0AheqiADsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD36MkE+PDgMfTNc3nrpxn277tE/+mhALzqoQA0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADpogFn7rUz//TUiP/poQLK6aIAGQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADpoQAi6Z4AceidALzuuDv/9tmW///+/f//////9+Cp//HCWf/rqBDg6J0AhumjAS4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+fDdUvfjuZ/utDDA771J/+yqFuPpoAAoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA6aEAB+mkCsLyzHD/8MBS/uibAHsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADpowEd6J8AaemgAMfvvUv/+OS1///////+/vv/9+Gs//HBVP/poQDN6Z4Ab+miAQkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/v38G/37+9Lz0IK66qYJvfDAUv/rqRGlAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADonwBE7rQt/vXVjf/srCLl6J8AMQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADpogAp6J0AgOysGfX0znn//v37///////9+Ov/9tud/+2yLPnonQCw6aEANQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPz580n369Xz8s59q+2xKNjzymz/66oSsemhAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA6aIABemgBZ3zy3D/88xy/+mgALDpogAJAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOmiAAPpoABG6aMBzvDAU//99+v////////////779P/88pr/+meANbpoQBnAAAAAAAAAAAAAAAAAAAAAAAAAAD47dak79Sg4/TShoLsqxPS99ye/+yuH+7onwATAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADpoAAz66oV4/fcn//utzj66J0AWQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOqjASTpnwCw8b9O//347P////////////368P/33aD/66gP7+ifAIgAAAAAAAAAAAAAAAAAAAAA9ufI0OzLjOj11Y5q6JoBt/npw//wvk7+6aAAhwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA6aIABOieAIPyxV//9teS/+qnEOHpoAAXAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA6aEAHOmeArLzyWv///////7////+/////v79//row//tsCj36J0AkemiAAEAAAAAAAAAAPXkw9fnv3H99tWNZOaRAIr21o//+uzJ/+qlCO7qogBbAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADpogAf6aAIy/jgqv/yw1z/6qIBqwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADpoAA07KkT4vjluf///////v7+//7+////////+u7S/+6zL/jonQCEAAAAAAAAAAD148C0469L//v274HzyW1X6qQJ6fz04f/44a3/6qIA7+qjAXMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA6aIAAemhAG/ttDD7+uzL/+yuH/PqogBoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA6aIAA+ibAJL11If///////7+/v/+/v7//v7+/////v/67Mv/7K8k+OieAFkAAAAA+/XpYOa2Wv/szZHh881zNuiaAKHxxFz//v7+//nmuf7sqxf86aAAwumiAC0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADpogAC6aABv/XWjf/547L/6qIA4umiADcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOmiAATomwCV9dSI///////+/v7//v7+//7+/v/+/v7////+//fcn//rqA/p6aEAH////yLx2KX94q1F//39/nXuuUMM6Z4A1fTMcv///v3//fjt//LFYP3qpQb/6qQEv+miAEjpogEH6qMDBOmiAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOmhAEjrqA7p/PHa//TShP/poALI6aIAGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA6aIAAuqjBQXpoABr7LAl//rqx////////v7+//7+/v/+/v7//v7+//7+////////775L/+ieAHz79OcK9ubIltuaF//muWP/////Wu21NB7onQDa8cFS/P346////////PTh//LKb//sqhj/66gO+eqkB6XpogBX6aIAHuqlBg/powIK6aIABumiAAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA6qMCp/HFYf/9+/T/8cNb/+mfA7PpogAT6aIABumiAAfpogAI6aIACemkAwzqpgkQ6aIBIemiAGXqpAfQ660d//jjs/3//////v7+//7+/v/+/v7//v7+//7+/v/+/v7///////jfqf/rqhPXAAAAAP359CTy267h1IUD/+rHhP/26M1P67AmI+icALnsrRv1+OS1//768f////////////jktv/xxWH/7bAl/+urGf/rqxfm6qUHqumiAHnpoQBS6aIALOqkBh/qpQga6qMBFemiABDpogAN6aIACumiAAfpogAE6aIAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOmgAB7qpQfo+enE//368P/wvkz/6qAFwumhAHfpogB+6aMCjuqkBqDqqA7C66wa8eurGv/usiv/9NKC//357////////v7+//7+/v/+/v7//v7+//7+/v/+/v7//v7+//7////779P/7KoV8wAAAAAAAAAA////RPDTmezRgAD/7c2U//fpza3utjge6qkTd+eaANfwvUr7+ejA//z36f/////////////////8893/996k//PLcv/vukL/7bIu/+ywJv/sryP/7K0e6eupEsrqpQiq6aMDlumhAITpoABy6J8AY+igAFbooABL6KAAQemgADnpoAAz6qEAMeqiADLpogAL6aAAeO62N//+/Pb//vvz//PIaf/uuDz/8L1L//HCWP/zym3/9dSM//jisP/89eP////////////+/////v7+//7+/v/+/v7//v7+//7+/v/+/v7//v7+//7+/v/+////+ee+/+upE+YAAAAAAAAAAAAAAAD///9a68Z67NSGAP/dnyj/+O/k6/bitDnvuElC6qQHleeXAM7vvEb799yf//rtz//9+O3////////////////////////////99+r/+uvI//nhrv/22Zj/9M98//LIaP/xwVb/8LxI/+65QP/uuDz/7rc4/+61Nf/ttDL/7bMw/+60MP/srR3/6aMCjemgAA7qpAvV9deT/////////////////////////////////////////////v////7+///+/v7//v7+//7+/v/+/v7//v7+//7+/v/+/v7//v7+//7+/v///////vz4//HFYf/powaaAAAAAAAAAAAAAAAAAAAAAP///2Dty4fl1YwB/9aLAP/u0Jb/+O/ex/XZlkLwvVlB6Z8AeOaUALbqpATg8cNb//fcnv/56sT//PPh//79+//////////////////////////////////////////////////////////////////+/Pj//fnw//336//99+v/+OS1/+yrGPnpoAAr6J0ATu2yKvr89eX//v////7+/v/+/v7//v7+//7+/v/+/v7//v7+//7+/v/+/v7//v7+//7+/v/+/v7//v7+//7+/v/+/v7//v7+//7//////////fnu//XUif/qpQjf6J8AJgAAAAAAAAAAAAAAAAAAAAAAAAAA+/brT+zMidXdoCr/0HoA/9mTCf/szZD/9u3atPXUhU3xxGEv8shmUOyvInzljwCu6qIA1++9SP/zznr/9tyf//nluf/78Nf//fnx/////////////////////////////v////7////+/////v////7////+/////v/////////yx2X/6KAApemhAAbonwCy8895///+/P/+/v7//v7+//7+/v/+/v7//v7+//7+/v/+/v7//v7+//7+/v/+/v7//v7///7////////////////////9+e3/+OW3//HEXv/poQDP6Z8AQwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///9G9+rOqua7af/ZlAv/0HsA/9ucHP/sz5b/89+6wv///3n+9uJQ8st/NvTOdTnpoQBd5ZEAiuaUAK/qpAXT77o/+vHEXv/0z3v/9deS//jgrP/66cT/+/HZ//357////////////////////////////////////////PTi/+ysG/npogE56J4AN+yrHuv77c/////////////////////////////////////////////////////////////++/b/+/DY//jjtP/11Yz/8cJY/+uoEN7onACX6aIALgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///x3///908tmq0eOwU//UhgD/0X8A/9ubGv/jsU//68iD//Piv7v///99+eSwX/XWkin21o4X77xIIOaUAErmlQBv5ZEAj+aVAK/qpw7K7bIp6e66Qf/wwFL/8shn//PPev/11o//99+m//nmu//67c3//PTh//7+/P/21Yz/6qMAuAAAAADonQCU8cNa//nluP/56MH/+ujC//rowf/55r3/+OW5//jisP/33aD/9daR//TQgP/zy3D/8cNa/+65P//sqxfa6J0AquidAHrqogA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///zT78uOI79Oa+t+lNv/TgwD/0oEA/9WIAP/bmhf/47FO/+nCd//w2qzN/v39mf/++Ib22JxS992kDwAAAAD01IsC55cAFuiaADDnmQBL5pQAX+WSAHvmkwCX6aEAsOuoEcjtryTk7rY0/O+6QP/vvk3/8LxJ/+miAf/qowFf6aIAIOqlCMbsryPr7bIp5+2yK+vtsirq7bAm4+yuId7rqxjS6qcMwemiAbHongCe55sAiOidAG7pogBX6qIAOemiABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/vr2BP///0P47dSa79ai/+S0VP/WigD/0oIA/9SHAP/ViwD/2pcR/+CnOf/ltVj/6sR77/PhvMX+//+m////oPnjsoX55LJc+ee7KfnmuQcAAAAAAAAAAOeaAAHnlgAQ6JkAIOibADDnmQA+5pQATeaVAGTnmACE55gAYQAAAADomAAl6aEAOeqjATfpowE66aMBOemjADXqoQAy6aAALOmhACXpoQAd6aIAEumiAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////Df7//yz68eB39OG79ezKiP/bmRf/1IQA/9SHAP/ViQD/1osA/9iRBP/bmhn/36Uz/+GrQP/ktVb+6L9t7+7RmNn15sm//Pv2sf///7P98day+unFm/nnvoH55rpp+OW4Uvrnvz/66sc5++7NPPrqxD/668cmAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+O3Yafz587r8+PFa////Dv///xD+/vs+9+jKt/Xkwv/numL/2pQK/9OEAP/UhwD/1YoA/9aMAP/WjAD/144A/9iRBP/ZlQ7/25oZ/92fJf/eoy7/36c4/+GsQv/kslH55rlg8+nAce3rx4Dn7cyO4+3LjN757trf8+C5bgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////O+3Njtbirkb/6L1o7/js09r////K/fnye/7//zL9/f0N/PfvUvns1sb36c7/68iC+t+mNf/WiwD/1IYA/9WKAP/WjAD/1o0A/9eOAP/XjgD/1o0A/9aNAP/XjwD/15AC/9iRBP/Ykgf/2ZMJ/9mUC//Zkwr/2JEX/+i/bfD9/PhKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD68OCH7s6P/teNAP/alxH/3Z4h/+GtRPjv1J7h/vz45vz477T9/v1b////JP39+jn58N6H+fDe7/fpzPvrx4H44ag6/9mUCf/ViQD/1YoA/9aMAP/WjQD/144A/9ePAP/XjwD/148A/9ePAP/XjwD/148A/9eNAP/XkAf/8dyt4/369B8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///+e57tl/OCoOvv67930+vLh4Pry4Nz8+fH3/fr0+/Tivevuz5L96L5q8vfq0ur8+O/b/PXok/7+/kP+/v8c/v37Pfv16IT68uLW/Pfu+/blxvPsx4D24q9I/9yeIP/XkQP/1owA/9aNAP/WjgD/144A/9eOAP/XjwD/1owA/96fJf735sam/v78CQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///9W57tm7uGrPvL679yX/v7+BQAAAAAAAAAA/PjwOv379dvw1aD536Ux/taOA//ZlAv/5LNR/uSzU//rxn/0+fDc9fv16Ov58N+d/fr2Sv7//hj+/v8b/v//Qvz373r79Oa4/vz66v77+fP358vm79KX6+i+avjirkX83qIq/tuZFf7XjwX/5bdZ6/z472kAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD+//8E/fv1i+GrQf/v1J2bAAAAAAAAAAD79usE/PjxZuvKh/XZkwn92ZQK/+e9aez68uHf////2/Laq8rbmRj1148G/+W4XfTbmxr/3qMt/+jBcf/04Ln7/Pfu//nv3OH369Gj+/XoY/38+TT+/fwe////Jf///z////9P/Pftf/z266v9+vfN////6P78++f+/fzl/v7+FQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+e/dMue9at/syIP//fr0W/r05hv79esZ/vz5YurCdf/Ykwj/7tCT3f///8j///9U/PrzFPHYpZndnyr/1o0S//jt19r78uRW7c+T7N2fJv/mt1r81IQA/+nCc/vsyYXz5rpi/+e8Zv/syIP/8tuu//fr0f/8+PD//vz4FgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP7+/gsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADksk9O47BL+u3Liv/25sj1+O7Z4uzLiP/YkAL49uTEyP///z4AAAAAAAAAAPnv24DhrEr/1YgS//foyeD///8VAAAAAPbnx8DirUT/+vXn5O3LhvTeoyz/9OK+w/379an36c7G4q5H9tWJAP/XjQD/892x/v///2wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA68Z/Dv///4/26MvJ6cJz8+W2Wf/bmhj97s6Qrfz37hMAAAAAAAAAAP///yjx1qL/0oAA//DVnOr///8uAAAAAAAAAAD+/f1C6b9v/+e8Zt3///+z6cN479uaGPz68+WQAAAAAP///4LtzYzi47NS/vv16d3///8uAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///5PtzYz/68iB4/779zEAAAAAAAAAAAAAAAD9+/ZY5LNS9uCoNf/469GLAAAAAAAAAAAAAAAA////NOzLif/luFz/////Q////2zjsk/y7c2M7fjpzjUAAAAA/fnzeOW0U//037e5/v36AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD8+fNU5rhd//Laq8j///8OAAAAAAAAAAD9+vQB+vDdh96iKv/pv2z2/fv2VQAAAAAAAAAAAAAAAP///y7u0Zb+5rhd/////2AAAAAA9+rNtNuaGP/037ba+/XqFf///2rlt1v/9OG73vv26w0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/v37Nu7SmP/nu2Tn////UAAAAAAAAAAAAAAAAPfmyKbeoiv/7tGV0v38+CYAAAAAAAAAAAAAAAD9/Pop7c6O+Oe8aP////9gAAAAAP7891HpwXT73aAn//jr0Xv+/v195LVV//PftsD79OcJAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP369APv05ug6L9t/+/Sl9j///+B/v37dP78+Wr68eDa3aAm//Pes7v///8TAAAAAAAAAAAAAAAA/v79J+/SmProvWj9//78TwAAAAD///8C9ufIyNiSCP/nvGbf7s+R0+3NjP/78+Rl/fr1AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9ubIKfnw3cHtzY7/8dah/+rDdf/ovmv/5rhd/92fJP/z26++////FAAAAAAAAAAAAAAAAP78+DXx2Kf/8tqssv///xMAAAAA/v7+A/blw7fdnyH/6L5s/+7QlP/04r3L/v78KQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP7//wj89usj+/juXPfnyozx2qrM8daj8vLbr//qxHj86sN34/7+/jcAAAAAAAAAAAAAAAD26Mu16cBv/////6AAAAAAAAAAAAAAAAD258rc25sb//fmx+b9+/SO9ufIKfz37wsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//v8N/PnxI/z37jH///9S8NSc8OW6YP3///9gAAAAAAAAAAD///+w6cFw/tiPAP/ltlnh+fHhmP79+m////+34qxC/OKtRf/++/l9////DQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPjs1Y/foy7/7c2N1f79+2z8+O+d36Mu+9qYE/7148D97cyM/+KtRP/js1H136c3/+GrP/z15cSY/fnzDgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///9I7MuK/9qYFf7pwnTz3qAm++GqPv/36s/e+e7aHv3890r469HB9+rP+fXnyeb58Nye9+zWDgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA148UBOrEeXHqxXv/2ZUM//Xlxv/68uOS/v7+DwAAAAD+/f0C/PbsDPv16RH69ecP/PjwCgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADktFYS//78xvr16Mz9+/Ya+/TpAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/8B/////////AA////////4AA////////gAB///////+AAD///////4AAP///////gAAf///////AAA///////+AAD///////8AAH///////+Acf/////////4//////////h//////////H/////////8P/////////4/////////gA///////8AAAH/////+AAAAB/////AAf4AA////4D//xwAf//+B///D4Af//w///+P8AP//H///8f+AP/w////x/+Af+D////j/8Af4P///+H/8A/A////8f/4A8h////w//wByH////h//gDEP////H/+AOQP///8P/4AZgP///4f/gBjAH///g/4ADGAB///BgAAOGAAA/+AAAA8MAAAAIAAAD4OAAAAwAAAfwPAAABAAAD/gPgAAGAAAf/gH8AAIAAP//AA/4AwAP///AAH/7//////gAAH//////3gAAAf////+DwAAA/////4A4AAB/////AAOAAD////8eAD4AP////jwAAfwf///+eGEAD/////8BwwAP/////wPHgQ//////x8eZz//////nj5jP/////+ePnM//////4Y+cH//////wD5wf//////wPHD///////84I////////xAD////////gYf////////D/////////8//////8= // @match https://open.cd/* // @grant GM_getValue // @grant GM_setValue // @grant GM_xmlhttpRequest // @grant GM_cookie // @grant GM_download // @grant GM_notification // @grant GM_setClipboard // @grant unsafeWindow // @connect open.cd // @connect music.163.com // @connect m.music.163.com // @connect y.music.163.com // @connect interface.music.163.com // @connect c.y.qq.com // @connect u.y.qq.com // @connect mobiles.kugou.com // @connect mobilecdn.kugou.com // @connect songsearch.kugou.com // @connect mdata.kugou.com // @connect m.kugou.com // @connect www.kugou.com // @connect search.kuwo.cn // @connect kuwo.cn // @connect m.kuwo.cn // @connect mobi.kuwo.cn // @connect sou.kuwo.cn // @connect itunes.apple.com // @connect music.apple.com // @connect musicbrainz.org // @connect api.deezer.com // @connect www.deezer.com // @connect allmusic.com // @connect www.allmusic.com // @connect api.song.link // @connect api.odesli.co // @connect www.qobuz.com // @connect static.qobuz.com // @connect cdn-images.dzcdn.net // @connect tidal.com // @connect www.tidal.com // @connect openapi.tidal.com // @connect resources.tidal.com // @connect auth.tidal.com // @connect api.tidal.com // @connect music.amazon.com // @run-at document-idle // @license MIT // ==/UserScript== (function () { 'use strict'; if (window.top !== window.self) return; /* ================================================================ * §01 常量与配置 * ================================================================ */ const VERSION = '9.76.18'; const AUTO_FILL_PRESETS = true; const OCD_Z = { PROBE_PANEL: 999998, TOP_BAR: 999999, LAYER_1000001: 1000001, LAYER_1000010: 1000010, MODULE_PANEL: 9999990, BATCH_PANEL: 9999996, HOST: 9999997, FAB: 9999998, SHELL_MENU: 9999999, OPEN_ANIM: 10000010, TASK_DETAIL: 10000030, BATCH_STATS: 10000040, BATCH_MORE: 10000042, SILENT_OBSERVE: 10000045, SILENT_BAR: 10000046, BATCH_INPUT: 10000047, TAGPRESET: 10000048, TIP_TOAST: 10000050, SILENT_SESSION: 10000060, TASK_MENU: 10000068, COVER_FLOAT: 10000069, SH_POP: 10000070, ASK_POP: 10000072, MYREQ_POP: 10000076, DRAFT_POP: 10000077, EDIT_POP: 10000078, PUB_SUCCESS: 10000079, REQ_PREVIEW: 10000082, APPEND_POP: 10000085, CONFIRM: 10000095, APPEND_CONFIRM: 10000086, APPEND_SUCCESS: 10000087, TAG_POP: 10000090, CIRCUIT_POP: 10000100, RESPOND_POP: 10000110, RESP_CONFIRM: 10000120, RESP_SUCCESS: 10000130, TINFO_SUBPOP: 10000140, TINFO_POP: 10000150, SPEC_POP: 10000160, GRAB_POP: 10000170, UTIL_POP: 10000175, CMDK: 10000190, TOAST: 10000200, }; /* V9.73.18:使用手册线上链接(由 html2link 发布;更新手册后用同 slug 覆盖即可,无需改脚本) */ const MANUAL_URL = 'https://html2link.dev/x/F9g27lI8'; function openManual() { try { const u = String(MANUAL_URL || '').trim(); if (!u || u.indexOf('http') !== 0) { try { toastSafe('使用手册链接未配置(MANUAL_URL 为空)', 'err'); } catch (e) {} return; } try { if (typeof GM_openInTab === 'function') { GM_openInTab(u, { active: true, insert: true }); return; } } catch (e) {} try { window.open(u, '_blank'); } catch (e) {} } catch (e) {} } /* V9.73.21:把主题类同步到所有已打开的面板(此前只有壳菜单自己换肤) */ function syncAllPanelThemeClasses() { try { const cls = 'ocd-theme-' + store.get(THEME_KEY, 'dark'); const els = document.querySelectorAll('[data-ocd-panel], [id^="ocd-"][id$="-panel"], .ocd-util-mask, .ocd-pop'); els.forEach(function (el) { try { Array.from(el.classList).filter(function (c) { return c.indexOf('ocd-theme-') === 0; }).forEach(function (c) { el.classList.remove(c); }); el.classList.add(cls); } catch (e) {} }); } catch (e) {} } /* V9.73.21:对**所有**已打开的面板实时预览入场动画(此前硬编码 7 个 id,漏了通知中心/工具箱维护) */ function previewOpenAnimAllPanels(key) { let played = 0; try { const anim = (OPEN_ANIM_KEY[key] || 'ocd-dp-slide') + ' .45s ease-out'; const els = document.querySelectorAll('[data-ocd-panel], [id^="ocd-"][id$="-panel"], #ocd-silent-box'); els.forEach(function (el) { try { if (!el || el.style.display === 'none' || !el.isConnected) return; el.style.animation = 'none'; void el.offsetWidth; el.style.animation = anim; played++; } catch (e) {} }); } catch (e) {} return played; } /* ================================================================ * V9.73.39 §22 自动标签匹配(固定 27 标签 · 合并别名表 · 地区推断) * 用户确认语义:8 汽车=车库摇滚 ✓ · 15 天籁=Soul/R&B+美声/天籁人声 ✓ · 7 发烧=音质向(24-Bit/Hi-Res) ✓ * ================================================================ */ function ocdNormTag(s) { /* V9.74.2:先解 HTML 实体(& / " / ' 等),否则 Alternative & Indie 会归一成 alternativeampindie 而匹配不上别名表 alternativeindie */ const _nt = String(s || '').replace(/&/gi, '&').replace(/</gi, '<').replace(/>/gi, '>').replace(/"/gi, '\x22').replace(/�?39;|'/gi, '\x27').replace(/ /gi, ' ').replace(/r\s*&\s*b/gi, 'rnb'); /* V9.74.6:R&B / R & B → rnb ✓ 让 contemporary r&b 等写法也能命中别名表 ✓ */ return _nt.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]/g, ''); } /* 多对一合并:来源叫法 -> 固定标签 ID(可在设置里编辑;此处为默认表)*/ const TAG_ALIAS_DEFAULT = { "6": ["pop", "poprock", "vocal", "vocalpop", "teenpop", "citypop", "流行", "泡泡糖"], "11": ["rock", "rocknroll", "alternative", "alternativeindie", "altindie", "indie", "indierock", "grunge", "hardrock", "altrock", "progrock", "psychedelic", "摇滚", "独立摇滚"], "12": ["ost", "soundtrack", "score", "originalmotionpicture", "stagescreen", "filmmusic", "tv", "原声", "影视原声", "配乐"], "9": ["classical", "baroque", "romantic", "opera", "chamber", "orchestral", "symphony", "古典", "歌剧", "室内乐"], "18": ["jazz", "bebop", "swing", "fusion", "smoothjazz", "bossa", "bossanova", "jazzvocal", "爵士"], "19": ["metal", "heavymetal", "deathmetal", "blackmetal", "thrashmetal", "powermetal", "doom", "metalcore", "金属"], "21": ["electronic", "edm", "house", "techno", "trance", "dubstep", "downtempo", "idm", "synthpop", "electro", "ambienttechno", "电子", "电音"], "26": ["rap", "hiphop", "hiphoprap", "trap", "gfunk", "raphiphop", "说唱", "嘻哈"], "10": ["world", "worldmusic", "ethnic", "traditional", "national", "民族", "世界音乐"], "13": ["folk", "acoustic", "singersongwriter", "folksinger", "民间", "民谣"], "14": ["country", "bluegrass", "americana", "countryfolk", "乡村", "乡村民谣"], "23": ["gospel", "religious", "christian", "spiritual", "religion", "宗教", "赞美诗", "圣歌"], "15": ["soul", "randb", "rhythmandblues", "rb", "rbsoul", "soulrandb", "neosoul", "funk", "motown", "vocalist", "belcanto", "天籁", "灵魂", "美声", "人声"], "7": ["hires", "audiophile", "发烧"], "8": ["garage", "garagerock", "车库摇滚", "车库"], "17": ["blues", "rhythmandbluesblues", "bluesrock", "delta", "chicago", "蓝调", "布鲁斯"], "22": ["children", "childrens", "kids", "kidssongs", "nursery", "儿童", "儿歌"], "27": ["musical", "musicals", "showtunes", "castrecording", "broadway", "音乐剧", "歌舞剧"], "16": ["newage", "ambient", "relaxation", "easylistening", "meditation", "新世纪", "轻音乐", "冥想"], "24": ["reggae", "ska", "dancehall", "dub", "rocksteady", "雷鬼"], "25": ["drumbass", "dnb", "drumandbass", "jungle", "bass", "breakbeat", "贝斯", "鼓打贝斯"], "20": ["punk", "punkrock", "hardcore", "postpunk", "oi", "朋克"], }; /* V9.73.61:MusicBrainz 常见词补充表(与内置表合并使用 ✓ 不改动原有行 ✓)*/ const TAG_ALIAS_EXTRA = { "6": ["dancepop", "electropop", "synthpop", "artpop", "baroquepop", "chamberpop", "dreampop", "britpop", "jpop", "kpop", "cantopop", "mandopop"], "11": ["alternativerock", "noiserock", "artrock", "postrock", "shoegaze", "mathrock", "garagerock", "indiepop", "janglepop", "slackerrock", "stonerrock", "bluesrock", "folkrock", "countryrock", "southernrock", "glamrock", "punkrock", "poprock", "softrock", "classicrock"], "12": ["soundtrack", "original soundtrack", "filmscore", "televisionmusic", "videogamemusic"], "9": ["baroquemusic", "romanticclassical", "contemporaryclassical", "choral", "chambermusic", "symphonic"], "18": ["jazzfusion", "vocaljazz", "bigband", "swingmusic", "latinjazz", "acidjazz", "cooljazz", "hardbop"], "19": ["progressivemetal", "symphonicmetal", "folkmetal", "gothicmetal", "nu metal", "numetal", "groovemetal", "sludgemetal"], "21": ["electropop", "dance", "housedance", "deephouse", "techhouse", "electrohouse", "drumandbass", "breakcore", "triphop", "vaporwave", "synthwave", "chiptune", "glitch", "industrialtechno"], "26": ["hiphop", "eastcoasthiphop", "westcoasthiphop", "conscioushiphop", "gangstarap", "drill", "grime", "boombap", "lofihiphop"], "10": ["worldbeat", "afrobeat", "afropop", "latin", "flamenco", "celtic", "gamelan", "klezmer", "reggaeton"], "13": ["contemporaryfolk", "folktronica", "americana", "bluegrass", "countryfolk", "freakfolk", "singer songwriting"], "14": ["contemporarycountry", "countrypop", "outlawcountry", "altcountry", "honkytonk", "westernswing"], "23": ["contemporarychristian", "christianrock", "worship", "gospelmusic", "spirituals", "gregorianchant"], "15": ["contemporaryrnb", "alternativernb", "rnb", "randbsoul", "rnbsoul", "soulmusic", "northernsoul", "philadelphiasoul", "quietstorm", "doowop"], "8": ["garagerock", "sixtiesgarage", "fratrock", "surfrock"], "17": ["electricblues", "chicagoblues", "countryblues", "piedmontblues", "bluesrock", "jumpblues"], "22": ["childrensmusic", "childrens", "kidmusic", "lullaby", "storytelling"], "27": ["musicaltheatre", "westend", "broadway", "castalbum", "operetta"], "16": ["ambient", "drone", "fieldrecording", "neoclassical", "spacemusic", "newagemusic"], "24": ["rootsreggae", "dubmusic", "ragga", "loversrock", "rocksteadyreggae", "ska", "dancehall"], "25": ["drumandbass", "liquidfunk", "neurofunk", "jungle", "dubstep", "bassmusic", "footwork"], "20": ["posthardcore", "hardcorepunk", "skatepunk", "popunk", "pop punk", "streetpunk", "emocore", "grindcore", "screamo"], "7": ["hires", "audiophile"], }; /* 一对多展开:一个来源串同时命中多个固定标签(站点允许多选 ✓)*/ const TAG_SPLIT_DEFAULT = { "poprock": ["6", "11"], "rockpop": ["6", "11"], "funksoul": ["15", "17"], "soulfunk": ["15", "17"], "folkworld": ["13", "10"], "worldfolk": ["13", "10"], "hiphoprap": ["26"], "randb": ["15"], "alternativeindie": ["11"], "countryrock": ["11", "14"], "folkrock": ["11", "13"], "jazzfunk": ["18", "15"], "electronicpop": ["21", "6"], "synthpop": ["21", "6"], "metalcore": ["19", "20"], }; /* V9.74.1:站点「类别」(#source) 真实选项(真机取证 plugin_upload.php ✓ 17 项 ✓ 顺序按站点 ✓)*/ const GENRE_SITE_OPTS = ["流行(Pop)", "古典(Classical)", "器乐(Instrumental)", "原声(OST)", "摇滚(Rock)", "爵士(Jazz)", "新世纪(NewAge)", "舞曲(Dance)", "电子(Electronic)", "民谣(Folk)", "独立(Indie)", "嘻哈(Hip Hop)", "音乐剧(Musical)", "乡村(Country)", "另类(Alternative)", "世界音乐(World)", "其他(Others)"]; /* V9.74.1:固定标签 → 站点类别(内置 21 条 ✓ 可编辑/可关闭 ✓ 见 prefs.genreMap ✓) 未收录:7 发烧(音质向,站点无对应类别 ✗)· 1~5 地区(站点类别不含地区 ✗)→ 不映射,避免设错 ✓ */ const GENRE_MAP_DEFAULT = { "6": "流行(Pop)", "8": "摇滚(Rock)", "9": "古典(Classical)", "10": "世界音乐(World)", "11": "摇滚(Rock)", "12": "原声(OST)", "13": "民谣(Folk)", "14": "乡村(Country)", "15": "流行(Pop)", "16": "新世纪(NewAge)", "17": "爵士(Jazz)", "18": "爵士(Jazz)", "19": "摇滚(Rock)", "20": "摇滚(Rock)", "21": "电子(Electronic)", "22": "其他(Others)", "23": "其他(Others)", "24": "世界音乐(World)", "25": "电子(Electronic)", "26": "嘻哈(Hip Hop)", "27": "音乐剧(Musical)" }; /* 音质向 -> 7 发烧(HiFi):24-Bit / Hi-Res / DSD / 96k 以上 ✓ */ /* V9.73.45:解析规格串(位深/采样率/DSD/MQA/Hi-Res),档位=中(高于 CD 即算 ✓)*/ function tagSpecFrom(text) { const s2 = String(text || ""); const bit = (s2.match(/(\d{1,2})\s*[- ]?\s*bit/i) || [])[1]; const khz = (s2.match(/(\d{2,3}(?:\.\d)?)\s*k\s*hz/i) || [])[1]; return { bit: bit ? Number(bit) : null, khz: khz ? Number(khz) : null, dsd: /dsd|sacd/i.test(s2), mqa: /\bmqa\b/i.test(s2), hires: /hi[- ]?res|hires/i.test(s2) }; } /* 档位=中:位深≥24 或 采样率≥88.2 → 发烧(HiFi) ✓;DSD/MQA/Hi-Res 字样也放行 ✓ */ function tagHintQuality(quality, title, subtitle, tier) { const _tier = tier || "mid"; const head = tagSpecFrom(String(title || "") + " " + String(subtitle || "")); if (head.dsd || head.mqa || head.hires) return { on: true, from: "title", why: "标识(DSD/MQA/Hi-Res)" }; if (head.bit !== null || head.khz !== null) { const ok = hifiByTier(head, _tier); return { on: !!ok, from: "title", why: (head.bit || "?") + "bit/" + (head.khz || "?") + "kHz" }; } const s2 = (Array.isArray(quality) ? quality : [quality]).map(function (x) { return String(x || ""); }).join(" "); const body = tagSpecFrom(s2); if (body.dsd || body.mqa || body.hires) return { on: true, from: "quality", why: "标识(DSD/MQA/Hi-Res)" }; if (body.bit !== null || body.khz !== null) { const ok = hifiByTier(body, _tier); return { on: !!ok, from: "quality", why: (body.bit || "?") + "bit/" + (body.khz || "?") + "kHz" }; } return { on: false, from: "", why: "未识别规格" }; } /* 地区推断:国别码优先 ✓ 文字体系兜底 ✓ */ const TAG_REGION_CN = ["CN", "CHN", "CHINA", "中国大陆", "大陆"]; const TAG_REGION_HT = ["TW", "TWN", "TAIWAN", "HK", "HKG", "HONGKONG", "MO", "MACAU", "香港", "台湾", "港台"]; const TAG_REGION_JPKR = ["JP", "JPN", "JAPAN", "KR", "KOR", "KOREA", "南韩", "韩国", "日本", "日韩"]; const TAG_REGION_WEST = ["US", "USA", "GB", "UK", "DE", "FR", "IT", "ES", "CA", "AU", "SE", "NO", "NL", "IE", "RU", "欧美"]; function tagRegionOf(country, text) { const c = String(country || "").trim().toUpperCase(); if (c) { if (TAG_REGION_CN.indexOf(c) >= 0) return "1"; if (TAG_REGION_HT.indexOf(c) >= 0) return "3"; if (TAG_REGION_JPKR.indexOf(c) >= 0) return "4"; if (TAG_REGION_WEST.indexOf(c) >= 0) return "2"; } const tx = String(text || ""); if (/[\u3040-\u30ff]/.test(tx)) return "4"; /* 假名 -> 日韩 */ if (/[\uac00-\ud7af]/.test(tx)) return "4"; /* 韩文 -> 日韩 */ if (/[\u4e00-\u9fff]/.test(tx)) return "1"; /* 汉字无假名 -> 大陆(低置信 ✓)*/ return c ? "5" : ""; /* 有国别但不在表内 -> 其它地区 ✓ */ } /* 主函数:meta -> { ids:[...], reasons:[...], low:[...] } */ /* V9.73.46:自动标签设置(8 组 ✓ 默认均为"安全/当前行为" ✓)*/ const AUTOTAG_KEY = "ocd_helper_autotag_v1"; const AUTOTAG_DEFAULT = { autoOnParse: false, sources: ["title", "platform", "musicbrainz"], strictOnly: true, hintQuality: true, hintRegion: true, splitOneToMany: true, alias: null, conflict: "all", hifiTier: "mid", titleOnlyHifi: false, regionMode: "suggest", introMode: "intro+mb", genreMapOn: true, /* V9.74.1:标签→类别映射表开关(默认开 ✓)*/ genreMap: null /* V9.74.1:用户自定义覆盖(id → 类别文字;空串 = 该标签不映射 ✓)*/ }; function getAutoTagPrefs() { let p = {}; try { p = store.get(AUTOTAG_KEY, null) || {}; } catch (e) { p = {}; } const out = Object.assign({}, AUTOTAG_DEFAULT, p); if (!Array.isArray(out.sources) || !out.sources.length) out.sources = AUTOTAG_DEFAULT.sources.slice(); if (["all", "best"].indexOf(out.conflict) < 0) out.conflict = "all"; if (["strict", "mid", "wide"].indexOf(out.hifiTier) < 0) out.hifiTier = "mid"; if (["auto", "suggest", "off"].indexOf(out.regionMode) < 0) out.regionMode = "suggest"; if (["intro", "intro+mb", "mb"].indexOf(out.introMode) < 0) out.introMode = "intro+mb"; return out; } function setAutoTagPrefs(patch) { const cur = getAutoTagPrefs(); const nx = Object.assign({}, cur, patch || {}); try { store.set(AUTOTAG_KEY, nx); } catch (e) {} return nx; } function resetAutoTagPrefs() { try { store.set(AUTOTAG_KEY, Object.assign({}, AUTOTAG_DEFAULT)); } catch (e) {} return getAutoTagPrefs(); } /* ===================== V9.74.1:标签 → 站点「类别」映射(可编辑 ✓ 可关闭 ✓)===================== */ function genreMapUserOf() { try { const u = getAutoTagPrefs().genreMap; return (u && typeof u === "object") ? u : {}; } catch (e) { return {}; } } /* 合并:内置(开关开时)+ 用户覆盖;用户写空串 = 该标签不映射 ✓ */ function genreMapOf() { const out = {}; try { if (getAutoTagPrefs().genreMapOn !== false) { for (const k in GENRE_MAP_DEFAULT) out[k] = GENRE_MAP_DEFAULT[k]; } const u = genreMapUserOf(); for (const k in u) { const v = u[k]; if (v === "" || v === null || v === undefined) delete out[k]; else out[k] = String(v); } } catch (e) {} return out; } function genreMapSet(id, text) { try { const u = JSON.parse(JSON.stringify(genreMapUserOf())); if (text) u[id] = String(text); else u[id] = ""; setAutoTagPrefs({ genreMap: u }); } catch (e) {} return genreMapOf(); } function genreMapDel(id) { try { const u = JSON.parse(JSON.stringify(genreMapUserOf())); delete u[id]; setAutoTagPrefs({ genreMap: u }); } catch (e) {} return genreMapOf(); } /* 档位判定(strict / mid / wide)*/ function hifiByTier(spec, tier) { if (spec.dsd || spec.mqa || spec.hires) return true; if (tier === "wide") return (spec.bit !== null || spec.khz !== null); if (spec.bit === null && spec.khz === null) return false; if (tier === "strict") return (spec.bit !== null && spec.bit >= 24) && (spec.khz !== null && spec.khz >= 88.2); return (spec.bit !== null && spec.bit >= 24) || (spec.khz !== null && spec.khz >= 88.2); /* mid ✓ */ } /* V9.73.49:自动标签设置面板(自带 ✓ 8 组控件 + 别名表 JSON 编辑 + 试算 ✓)*/ function openAutoTagSettings() { const old = document.getElementById('ocd-autotag-cfg'); if (old) { old.remove(); return; } const p = getAutoTagPrefs(); const box = document.createElement('div'); box.id = 'ocd-autotag-cfg'; box.style.cssText = 'position:fixed;right:18px;bottom:18px;z-index:' + ocdTopZ(20) + ';width:430px;max-height:72vh;overflow:auto;border:1px solid var(--border,#444);border-radius:10px;padding:12px;background:var(--bg,#1b1b1b);color:var(--fg,#eee);font-size:12px;line-height:1.7;box-shadow:0 8px 28px rgba(0,0,0,.45);'; const H = (x) => '
' + x + '
'; const row = (a, b) => ''; const cb = (k, label) => ' ' + label; const sel = (k, opts) => { let h = ''; }; box.innerHTML = [ '
🏷️ 自动标签设置
', H('① 总开关') + row(cb('autoOnParse', '解析时自动打标(默认关 ✓ 只在点按钮时生效)'), ''), H('② 置信度阈值') + row(cb('strictOnly', '只自动勾精确命中(关掉后包含命中也勾 ✓)'), ''), H('③ 旁证开关') + row(cb('hintQuality', '音质规格 → 发烧(HiFi)'), '') + row(cb('hintRegion', '地区推断'), '') + row(cb('splitOneToMany', '一对多展开(如 Pop/Rock → 流行+摇滚)'), ''), H('④ 地区策略') + row(sel('regionMode', [['suggest', '仅建议(默认 ✓ 不自动勾)'], ['auto', '自动勾选'], ['off', '关闭']]), ''), H('⑤ 冲突策略') + row(sel('conflict', [['all', '多个流派候选 → 全勾(默认 ✓)'], ['best', '只勾第一个流派']]), ''), H('⑥ 发烧档位') + row(sel('hifiTier', [['strict', '严:≥24bit 且 ≥96kHz'], ['mid', '中:高于 CD(默认 ✓ 24/44.1 算)'], ['wide', '宽:出现规格即算']]), ''), H('⑦ 合并别名表(自定义归类)') + '
留空 = 用内置表 ✓;添加/删除即时生效 ✓
' + '' + '
' + '' + '' + '' + '
', '
' + '' + '' + '
', '
(点试算看结果 ✓)
' ].join(''); document.body.appendChild(box); /* V9.73.57:已删除重复的旧编辑器(V9.73.54 那份 ✗)*/ const save = (k, v) => { const np = setAutoTagPrefs({}); np[k] = v; store.set(AUTOTAG_KEY, np); }; box.querySelectorAll('[data-at]').forEach(function (el) { el.addEventListener('change', function () { const k = el.getAttribute('data-at'); const v = el.type === 'checkbox' ? !!el.checked : el.value; const np = Object.assign({}, getAutoTagPrefs()); np[k] = v; try { store.set(AUTOTAG_KEY, np); } catch (e) {} try { toastSafe('✅ 已保存:' + k + ' = ' + v, 'ok'); } catch (e) {} }); }); const on = (id, fn) => { const el = box.querySelector('#' + id); if (el) el.addEventListener('click', fn); }; let _atWrap = null; /* V9.73.88:别名版块延迟挂载 ✓ 移到面板最下方 ✓ */ /* V9.73.55:傻瓜化别名编辑(DOM 追加 ✓ 唯一锚点 ✓ 不写 JSON ✓)*/ try { const _tx = box.querySelector('#ocd-at-alias'); if (_tx) _tx.remove(); /* 去掉 JSON 文本域 ✓ */ const _sb = box.querySelector('#ocd-at-save'); if (_sb && _sb.parentElement) _sb.parentElement.remove(); const wrap = document.createElement('div'); wrap.style.cssText = 'margin-top:8px;padding:6px;border:1px dashed var(--border,#444);border-radius:6px;'; const tip = document.createElement('div'); tip.style.cssText = 'opacity:.85;'; tip.textContent = '✏️ 别名归类:填别名 → 选固定标签 → 添加(例如 krautrock → 摇滚)'; const line = document.createElement('div'); line.style.cssText = 'display:flex;gap:6px;margin-top:4px;'; const inp2 = document.createElement('input'); inp2.id = 'ocd-at-newalias'; inp2.placeholder = '别名,如 krautrock / 德国摇滚'; inp2.style.cssText = 'flex:1 1 auto;min-width:0;background:var(--bg-soft,#262626);color:var(--fg,#eee);border:1px solid var(--border,#444);border-radius:6px;padding:3px 6px;'; const sel2 = document.createElement('select'); sel2.id = 'ocd-at-newtag'; sel2.style.cssText = 'background:var(--bg-soft,#262626);color:var(--fg,#eee);border:1px solid var(--border,#444);border-radius:6px;padding:3px 6px;'; try { TAG_GROUPS.flatMap(g => g.row).forEach(function (x) { const o = document.createElement('option'); o.value = x[0]; o.textContent = x[1]; sel2.appendChild(o); }); } catch (e) {} const btn2 = document.createElement('button'); btn2.id = 'ocd-at-addalias'; btn2.textContent = '➕ 添加'; btn2.style.cssText = 'cursor:pointer;white-space:nowrap;flex:0 0 auto;'; line.appendChild(inp2); line.appendChild(sel2); line.appendChild(btn2); const mine = document.createElement('div'); mine.id = 'ocd-at-mylist'; mine.style.cssText = 'margin-top:6px;opacity:.9;'; wrap.appendChild(tip); wrap.appendChild(line); wrap.appendChild(mine); const rowB = document.createElement('div'); rowB.style.cssText = 'display:flex;gap:6px;margin-top:6px;flex-wrap:wrap;'; const expBtn = document.createElement('button'); expBtn.textContent = '📋 导出别名表'; expBtn.style.cssText = 'cursor:pointer;white-space:nowrap;'; expBtn.addEventListener('click', function () { _atDiag("点击 📋导出"); const cur = getAutoTagPrefs().alias || TAG_ALIAS_DEFAULT; const tx3 = JSON.stringify(cur, null, 1); try { GM_setClipboard(tx3); try { toastSafe('📋 别名表已复制到剪贴板 ✓', 'ok'); } catch (e) {} } catch (e) { try { window.prompt('复制以下 JSON:', tx3); } catch (e2) {} } }); const clrBtn = document.createElement('button'); clrBtn.textContent = '↺ 用内置表'; clrBtn.style.cssText = 'cursor:pointer;white-space:nowrap;'; clrBtn.addEventListener('click', function () { _atDiag("点击 ↺用内置表"); setAutoTagPrefs({ alias: null }); refreshMine(); try { toastSafe('↺ 已切换为内置别名表 ✓', 'ok'); } catch (e) {} }); const allBtn = document.createElement('button'); allBtn.id = 'ocd-at-showall'; allBtn.textContent = '👁 显示内置全部'; allBtn.style.cssText = 'cursor:pointer;white-space:nowrap;'; allBtn.addEventListener('click', function () { _atDiag("点击 👁显示内置全部"); _atShowAll = !_atShowAll; allBtn.textContent = _atShowAll ? '🙈 只看新增' : '👁 显示内置全部'; refreshMine(); }); rowB.appendChild(expBtn); rowB.appendChild(clrBtn); rowB.appendChild(allBtn); wrap.appendChild(rowB); _atWrap = wrap; /* V9.73.88:先不挂载 ✓ 见下方统一挂到最末 ✓ */ const cnOf = (id) => { try { const f = TAG_GROUPS.flatMap(g => g.row).find(x => x[0] === id); return f ? f[1] : id; } catch (e) { return id; } }; /* V9.73.58:默认只看"用户新增"的别名 ✓ 内置的用开关展开 ✓ */ let _atShowAll = false; let _atNewCount = 0; let _atAllCount = 0; const _atDiag = (what) => { try { console.log("[OCD Helper] 别名编辑诊断:" + what, JSON.stringify({ showAll: _atShowAll, newCnt: _atNewCount, allCnt: _atAllCount, aliasIsNull: !getAutoTagPrefs().alias, aliasKeys: Object.keys(getAutoTagPrefs().alias || {}).length })); } catch (e) {} }; _atDiag("面板初始化"); const refreshMine = () => { const cur = getAutoTagPrefs().alias; mine.innerHTML = ''; _atNewCount = 0; _atAllCount = 0; /* V9.73.58:重绘前清零 ✓ */ if (!cur) { mine.textContent = '(当前使用内置表 ✓ 添加后自动切换为自定义表 ✓)'; return; } let cnt = 0; Object.keys(cur).forEach(function (id) { (cur[id] || []).forEach(function (a) { /* V9.73.58:与内置表比对 ✓ 判断是否为"用户新增" ✓ */ let _isNew = true; try { const _b = (TAG_ALIAS_DEFAULT[id] || []); _isNew = !_b.some(function (bb) { return ocdNormTag(bb) === ocdNormTag(a); }); } catch (e) {} _atAllCount++; if (_isNew) _atNewCount++; if (!_isNew && !_atShowAll) return; cnt++; const chip = document.createElement('span'); chip.style.cssText = 'display:inline-block;margin:2px 4px 2px 0;padding:1px 6px;border:1px solid var(--border,#444);border-radius:10px;'; chip.textContent = a + ' → ' + cnOf(id) + ' '; /* V9.76.5:标出这条别名是不是第三方模块带来的 ✓(含门控 / 否决状态) —— 免得"我删了它怎么又回来了 / 我明明没加过它"找不到出处 ✓ */ try { const _src = (typeof ecomodTagSource === 'function') ? ecomodTagSource(a) : null; if (_src) { const mk = document.createElement('span'); const st = !_src.enabled ? '(模块已停用)' : (_src.excluded ? '(这一条已被你在模块面板里否决)' : (_src.gated ? '(参数条件不满足,暂不生效)' : '(生效中)')); mk.style.cssText = 'font-size:10px;opacity:.9;color:var(--accent,#5b8cff);margin-right:3px;'; mk.title = '来自第三方模块「' + _src.name + '」' + st + ' —— 在设置中心的「🧩 第三方模块生态」里可以停用 / 否决它'; mk.textContent = '🧩' + (!_src.enabled ? '✕' : (_src.excluded ? '🚫' : (_src.gated ? '⏸' : ''))); chip.insertBefore(mk, chip.firstChild); } } catch (e) {} const del = document.createElement('b'); del.textContent = '✕'; del.style.cssText = 'cursor:pointer;color:#ff6b6b;'; try { del.title = '删掉这条别名(若它来自第三方模块,模块还装着时会被重新写回 —— 要永久去掉请到「🧩 第三方模块生态」里否决它)'; } catch (e) {} del.addEventListener('click', function () { const np = JSON.parse(JSON.stringify(getAutoTagPrefs().alias || TAG_ALIAS_DEFAULT)); np[id] = (np[id] || []).filter(function (x) { return x !== a; }); if (!np[id].length) delete np[id]; setAutoTagPrefs({ alias: np }); refreshMine(); try { toastSafe('🗑 已删除别名:' + a, 'ok'); } catch (e) {} }); chip.appendChild(del); mine.appendChild(chip); }); }); try { const info = document.createElement('div'); info.style.cssText = 'margin-top:4px;opacity:.75;'; info.textContent = _atShowAll ? ('当前显示全部 ' + _atAllCount + ' 条(含内置 ✓)其中你新增 ' + _atNewCount + ' 条') : ('你新增 ' + _atNewCount + ' 条;内置 ' + Math.max(0, _atAllCount - _atNewCount) + ' 条未显示 ✓'); mine.appendChild(info); } catch (e) {} if (!cnt) { mine.textContent = _atShowAll ? '(内置表项为空 ✓)' : '(暂无自定义别名 ✓ 当前用内置表 ✓ 可点下面按钮查看内置条目)'; } }; btn2.addEventListener('click', function () { _atDiag("点击 ➕添加"); try { box.style.outline = '2px solid #ffd166'; setTimeout(function () { box.style.outline = ''; }, 400); } catch (e) {} /* 视觉反馈:确认点击确实到达 ✓ */ const name = String(inp2.value || '').trim(); if (!name) { try { toastSafe('请先填写别名 ✓ 例如 krautrock', 'err'); } catch (e) {} return; } const id = sel2.value; const base = JSON.parse(JSON.stringify(getAutoTagPrefs().alias || TAG_ALIAS_DEFAULT)); base[id] = base[id] || []; if (base[id].indexOf(name) < 0) base[id].push(name); setAutoTagPrefs({ alias: base }); inp2.value = ''; refreshMine(); _atDiag("添加后重绘"); try { toastSafe('✅ 已添加:' + name + ' → ' + cnOf(id) + '(立即生效 ✓ 可点试算核对 ✓)', 'ok'); } catch (e) {} }); refreshMine(); } catch (e) { try { console.warn('[OCD Helper] 别名编辑器初始化失败:', e && e.message); } catch (e2) {} } /* V9.73.63:数据源开关(可视化 ✓)*/ try { const ds = document.createElement('div'); ds.style.cssText = 'margin-top:8px;padding:6px;border:1px dashed var(--border,#444);border-radius:6px;'; const dst = document.createElement('div'); dst.style.cssText = 'opacity:.85;'; dst.textContent = '🗂 数据源开关(决定用哪些来源推断标签)'; ds.appendChild(dst); [ ['title', '标题/副标题规格(如 [24Bit-44.1kHz] → 发烧)'], ['platform', '平台流派(Qobuz / QQ 等页面流派)'], ['musicbrainz', 'MusicBrainz 兜底(流派 + 国别,缺流派时才联网)'] ].forEach(function (row) { const k = row[0], label = row[1]; const lb = document.createElement('label'); lb.style.cssText = 'display:flex;align-items:center;gap:6px;margin:3px 0;cursor:pointer;'; const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = (getAutoTagPrefs().sources || []).indexOf(k) >= 0; cb.addEventListener('change', function () { const cur = (getAutoTagPrefs().sources || []).slice(); const i = cur.indexOf(k); if (cb.checked) { if (i < 0) cur.push(k); } else if (i >= 0) cur.splice(i, 1); setAutoTagPrefs({ sources: cur }); try { toastSafe('✅ 数据源「' + label + '」已' + (cb.checked ? '启用' : '关闭'), 'ok'); } catch (e) {} }); const sp = document.createElement('span'); sp.textContent = label; lb.appendChild(cb); lb.appendChild(sp); ds.appendChild(lb); }); box.appendChild(ds); } catch (e) { try { console.warn('[OCD Helper] 数据源开关初始化失败:', e && e.message); } catch (e2) {} } /* V9.73.71:简介/联网模式下拉 ✓ */ try { const rowI = document.createElement('div'); rowI.style.cssText = 'margin-top:8px;padding:6px;border:1px dashed var(--border,#444);border-radius:6px;'; const lb = document.createElement('div'); lb.style.cssText = 'opacity:.85;'; lb.textContent = '📄 标签来源模式(简介 vs 联网)'; const s3 = document.createElement('select'); s3.id = 'ocd-at-intromode'; s3.style.cssText = 'background:var(--bg-soft,#262626);color:var(--fg,#eee);border:1px solid var(--border,#444);border-radius:6px;padding:3px 6px;margin-top:4px;width:100%;'; [ ['intro', '① 只用简介 / 平台流派(不联网,最快)'], ['intro+mb', '② 简介优先,缺流派时才联网补充(默认 ✓)'], ['mb', '② 总是联网获取(MusicBrainz 补齐流派 + 国别)'] ].forEach(function (o) { const op = document.createElement('option'); op.value = o[0]; op.textContent = o[1]; if ((getAutoTagPrefs().introMode || 'intro+mb') === o[0]) op.selected = true; s3.appendChild(op); }); s3.addEventListener('change', function () { setAutoTagPrefs({ introMode: s3.value }); try { toastSafe('✅ 标签来源模式:' + s3.options[s3.selectedIndex].textContent, 'ok'); } catch (e) {} }); rowI.appendChild(lb); rowI.appendChild(s3); box.appendChild(rowI); /* V9.73.88:把「✏️ 别名归类」挂到面板**最下方** ✓ */ try { if (_atWrap) box.appendChild(_atWrap); } catch (e) {} } catch (e) {} /* ===== V9.74.1:⑧ 标签 → 站点「类别」映射表(可编辑 ✓ 可启停 ✓ 挂在 ⑦ 别名版块下方 ✓)===== */ try { const gm = document.createElement("div"); gm.id = "ocd-at-genre-wrap"; gm.style.cssText = "margin-top:8px;padding:6px;border:1px dashed var(--border,#444);border-radius:6px;"; const gt = document.createElement("div"); gt.style.cssText = "opacity:.85;"; gt.textContent = "🏷➡📂 标签 → 站点「类别」映射(供「自动设置类别」使用 ✓ 解析后按标签把类别设为对应项 ✓)"; gm.appendChild(gt); const onLb = document.createElement("label"); onLb.style.cssText = "display:flex;align-items:center;gap:6px;margin:3px 0;cursor:pointer;"; const onCb = document.createElement("input"); onCb.type = "checkbox"; onCb.id = "ocd-at-genre-on"; onCb.checked = getAutoTagPrefs().genreMapOn !== false; onCb.addEventListener("change", function () { setAutoTagPrefs({ genreMapOn: !!onCb.checked }); try { toastSafe("✅ 标签→类别映射表已" + (onCb.checked ? "启用" : "关闭(回退名称模糊匹配)"), "ok"); } catch (e) {} }); const onSp = document.createElement("span"); onSp.textContent = "启用映射表(关掉 = 用旧的名称模糊匹配 ✓)"; onLb.appendChild(onCb); onLb.appendChild(onSp); gm.appendChild(onLb); const addRow = document.createElement("div"); addRow.style.cssText = "display:flex;gap:6px;margin-top:4px;flex-wrap:wrap;"; const tagSel = document.createElement("select"); tagSel.id = "ocd-at-genre-tag"; tagSel.style.cssText = "flex:1 1 110px;min-width:0;background:var(--bg-soft,#262626);color:var(--fg,#eee);border:1px solid var(--border,#444);border-radius:6px;padding:3px 6px;"; try { TAG_GROUPS.flatMap(g => g.row).forEach(function (x) { const o = document.createElement("option"); o.value = x[0]; o.textContent = x[1]; tagSel.appendChild(o); }); } catch (e) {} const tgtSel = document.createElement("select"); tgtSel.id = "ocd-at-genre-target"; tgtSel.style.cssText = "flex:1 1 130px;min-width:0;background:var(--bg-soft,#262626);color:var(--fg,#eee);border:1px solid var(--border,#444);border-radius:6px;padding:3px 6px;"; (function () { const seen = {}; const push = function (x) { const v = String(x || "").trim(); if (!v || seen[v]) return; seen[v] = 1; const o = document.createElement("option"); o.value = v; o.textContent = v; tgtSel.appendChild(o); }; try { const sel = document.querySelector("#source"); if (sel) Array.from(sel.options).forEach(function (o) { if (String(o.value) !== "0") push(o.textContent); }); } catch (e) {} try { GENRE_SITE_OPTS.forEach(push); } catch (e) {} })(); const addBtn = document.createElement("button"); addBtn.id = "ocd-at-genre-add"; addBtn.textContent = "➕ 添加/覆盖"; addBtn.style.cssText = "cursor:pointer;white-space:nowrap;flex:0 0 auto;"; addRow.appendChild(tagSel); addRow.appendChild(tgtSel); addRow.appendChild(addBtn); gm.appendChild(addRow); const gList = document.createElement("div"); gList.id = "ocd-at-genre-list"; gList.style.cssText = "margin-top:6px;"; gm.appendChild(gList); const btnRow = document.createElement("div"); btnRow.style.cssText = "display:flex;gap:6px;margin-top:6px;flex-wrap:wrap;"; const showBtn = document.createElement("button"); showBtn.id = "ocd-at-genre-showall"; showBtn.textContent = "👁 显示内置全部"; showBtn.style.cssText = "cursor:pointer;white-space:nowrap;"; const resetBtn = document.createElement("button"); resetBtn.id = "ocd-at-genre-reset"; resetBtn.textContent = "↺ 恢复内置映射"; resetBtn.style.cssText = "cursor:pointer;white-space:nowrap;"; const expB = document.createElement("button"); expB.id = "ocd-at-genre-export"; expB.textContent = "📋 导出映射表"; expB.style.cssText = "cursor:pointer;white-space:nowrap;"; btnRow.appendChild(showBtn); btnRow.appendChild(resetBtn); btnRow.appendChild(expB); gm.appendChild(btnRow); let _gmAll = false, _gmUserN = 0, _gmAllN = 0; const cn2 = function (id) { try { const f = TAG_GROUPS.flatMap(g => g.row).find(x => x[0] === id); return f ? f[1] : id; } catch (e) { return id; } }; const refreshGm = function () { const user = genreMapUserOf(); const merged = genreMapOf(); gList.textContent = ""; _gmUserN = 0; _gmAllN = 0; const ids = Object.keys(merged).concat(Object.keys(user).filter(function (k) { return !(k in merged); })); let cnt = 0; ids.forEach(function (id) { const isUser = Object.prototype.hasOwnProperty.call(user, id); const off = isUser && !user[id]; _gmAllN++; if (isUser) _gmUserN++; if (!isUser && !_gmAll) return; cnt++; const chip = document.createElement("span"); chip.style.cssText = "display:inline-block;margin:2px 4px 2px 0;padding:1px 6px;border:1px solid var(--border,#444);border-radius:10px;" + (isUser ? "border-color:#ffd166;" : ""); chip.textContent = id + " " + cn2(id) + " → " + (off ? "(已关闭 ✓ 不映射)" : merged[id]) + " " + (isUser ? "✎ " : ""); const del = document.createElement("b"); del.textContent = "✕"; del.style.cssText = "cursor:pointer;color:#ff6b6b;"; del.title = isUser ? "删除自定义(恢复内置 ✓)" : "关闭该标签的映射(不设置类别 ✓)"; del.addEventListener("click", function () { if (isUser) genreMapDel(id); else genreMapSet(id, ""); refreshGm(); try { toastSafe(isUser ? ("🗑 已删除自定义映射:" + cn2(id)) : ("🚫 已关闭映射:" + cn2(id)), "ok"); } catch (e) {} }); chip.appendChild(del); gList.appendChild(chip); }); const info = document.createElement("div"); info.style.cssText = "margin-top:4px;opacity:.75;"; info.textContent = _gmAll ? ("当前显示全部 " + _gmAllN + " 条 · 其中自定义/关闭 " + _gmUserN + " 条") : ("自定义/关闭 " + _gmUserN + " 条 · 内置 " + Math.max(0, _gmAllN - _gmUserN) + " 条未显示 ✓(点上方按钮展开)"); gList.appendChild(info); if (!cnt) { gList.textContent = "(无自定义映射 ✓ 当前用内置表 ✓)"; } }; addBtn.addEventListener("click", function () { const id = tagSel.value, tx = String(tgtSel.value || "").trim(); if (!id || !tx) { try { toastSafe("请选择标签与目标类别 ✓", "err"); } catch (e) {} return; } genreMapSet(id, tx); refreshGm(); try { toastSafe("✅ 已保存:" + cn2(id) + " → " + tx + "(立即生效 ✓)", "ok"); } catch (e) {} }); showBtn.addEventListener("click", function () { _gmAll = !_gmAll; showBtn.textContent = _gmAll ? "🙈 只看自定义" : "👁 显示内置全部"; refreshGm(); }); resetBtn.addEventListener("click", function () { setAutoTagPrefs({ genreMap: null }); refreshGm(); try { toastSafe("↺ 已恢复内置映射表 ✓", "ok"); } catch (e) {} }); expB.addEventListener("click", function () { const tx = JSON.stringify(genreMapOf(), null, 1); try { GM_setClipboard(tx); try { toastSafe("📋 映射表已复制到剪贴板 ✓", "ok"); } catch (e) {} } catch (e) { try { window.prompt("复制以下 JSON:", tx); } catch (e2) {} } }); refreshGm(); box.appendChild(gm); } catch (e) { try { console.warn("[OCD Helper] 标签→类别映射表初始化失败:", e && e.message); } catch (e2) {} } on('ocd-at-close', () => box.remove()); on('ocd-at-save', () => { const _txe = box.querySelector('#ocd-at-alias'); const tx = _txe ? String(_txe.value || '').trim() : ''; if (!tx) { setAutoTagPrefs({ alias: null }); try { toastSafe('✅ 已改为使用内置别名表 ✓', 'ok'); } catch (e) {} return; } try { const o = JSON.parse(tx); setAutoTagPrefs({ alias: o }); try { toastSafe('✅ 别名表已保存并生效 ✓', 'ok'); } catch (e) {} } catch (e) { try { toastSafe('❌ JSON 解析失败:' + (e.message || ''), 'err'); } catch (e2) {} } }); on('ocd-at-clear', () => { const _txc = box.querySelector('#ocd-at-alias'); if (_txc) _txc.value = ''; setAutoTagPrefs({ alias: null }); try { toastSafe('↺ 已切换为内置别名表 ✓', 'ok'); } catch (e) {} }); on('ocd-at-export', () => { const cur = getAutoTagPrefs().alias || TAG_ALIAS_DEFAULT; const tx = JSON.stringify(cur, null, 1); try { GM_setClipboard(tx); try { toastSafe('📋 别名表已复制到剪贴板 ✓', 'ok'); } catch (e) {} } catch (e) { window.prompt('复制以下 JSON:', tx); } }); on('ocd-at-try', () => { const m = (function () { try { return window.__ocdLastParsedMeta || null; } catch (e) { return null; } })(); const r = autoTags(m || {}); const nameOf = (id) => { try { const f = TAG_GROUPS.flatMap(g => g.row).find(x => x[0] === id); return f ? f[1] : id; } catch (e) { return id; } }; const o = box.querySelector('#ocd-at-out'); o.textContent = [ '元数据:' + JSON.stringify(m || {}), '将自动勾选:' + (r.ids.length ? r.ids.map(nameOf).join('、') : '(无)'), '仅建议(不勾):' + (r.low.length ? r.low.map(nameOf).join('、') : '(无)'), '依据:' + r.reasons.join(' | ') ].join('\n'); }); on('ocd-at-reset', () => { resetAutoTagPrefs(); try { toastSafe('↺ 已恢复默认设置 ✓ 请重开面板查看 ✓', 'ok'); } catch (e) {} box.remove(); openAutoTagSettings(); }); return true; } /* V9.73.60:MusicBrainz 兜底(流派 + 国别)· 缓存 + 1 req/s 节流 ✓ */ const MB_CACHE = {}; let _mbLast = 0; async function mbFetchJson(url) { for (let i = 0; i < 3; i++) { const wait = Math.max(0, 1000 - (Date.now() - _mbLast)); if (wait) await new Promise(function (r) { setTimeout(r, wait); }); _mbLast = Date.now(); try { const d = await gmFetchJSON(url, { Accept: "application/json", "User-Agent": "OpenCD-Helper/1.0 (userscript)" }); return d || null; } catch (e) { if (i === 2) { try { console.warn("[OCD Helper] MusicBrainz 失败:", e && e.message); } catch (e2) {} return null; } } } return null; } async function mbLookup(artist, album) { const a = String(artist || "").trim(), b = String(album || "").trim(); if (!a || !b) return null; const key = (a + "|" + b).toLowerCase(); if (MB_CACHE[key]) return MB_CACHE[key]; const q = "https://musicbrainz.org/ws/2/release-group/?query=" + encodeURIComponent('release:"' + b + '" AND artist:"' + a + '"') + "&fmt=json&limit=3"; const d = await mbFetchJson(q); const rgs = (d && d["release-groups"]) || []; if (!rgs.length) { MB_CACHE[key] = null; return null; } const nb = normKey(b); rgs.sort(function (x, y) { const sx = (normKey(x.title).indexOf(nb) === 0 ? 100 : 0) + (Number(x.score) || 0); const sy = (normKey(y.title).indexOf(nb) === 0 ? 100 : 0) + (Number(y.score) || 0); return sy - sx; }); const pick = rgs[0]; const out = { mbid: pick.id, name: pick.title || "", genres: [], country: "" }; const det = await mbFetchJson("https://musicbrainz.org/ws/2/release-group/" + pick.id + "?inc=genres&fmt=json"); if (det && Array.isArray(det.genres)) out.genres = det.genres.map(function (g) { return g && g.name; }).filter(Boolean); let aid = ""; try { const ac = (det && det["artist-credit"]) || pick["artist-credit"] || []; aid = (ac[0] && ac[0].artist && ac[0].artist.id) || ""; } catch (e) {} if (aid) { const ad = await mbFetchJson("https://musicbrainz.org/ws/2/artist/" + aid + "?fmt=json"); try { if (ad && ad.country) out.country = ad.country; else if (ad && ad.area && ad.area["iso-3166-1-codes"] && ad.area["iso-3166-1-codes"][0]) out.country = ad.area["iso-3166-1-codes"][0]; } catch (e) {} } MB_CACHE[key] = out; try { console.log("[OCD Helper] MusicBrainz:", JSON.stringify({ name: out.name, genres: out.genres.slice(0, 5), country: out.country })); } catch (e) {} return out; } /* 异步版:平台 genre 缺失时用 MusicBrainz 补齐(流派 + 国别)*/ /* V9.73.68:从简介文本提取流派与音质规格(用户截图证据:Genre: … / Available in: 24-Bit/48 kHz ✓)*/ function tagIntroExtract(text) { const s2 = String(text || ""); const out = { genre: "", quality: [] }; try { /* V9.73.73:标签名支持多种写法 ✓(英文 Genre/Style(s)/Tags,中文 类型/流派/曲风/风格/标签/类别/分类)*/ const _labelRe = /(?:^|[\s|\[((])(?:genres?|styles?|tags?|类型|流派|曲风|风格|标签|类别|分类)\s*[::]\s*([^\n\r]+)/i; const m1 = s2.match(_labelRe) || s2.match(/Genre\s*[::]\s*([^\n\r]+)/i); if (m1) { /* V9.73.73:值按多值拆分 ✓(Pop/Rock Rock Alternative & Indie → pop / rock / alternative / indie)*/ const _raw = String(m1[1]).replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(); const _parts = _raw.split(/[\/、,,;;|·]+|\s*&\s*|\s+and\s+/i).map(function (x) { return String(x || "").trim(); }).filter(Boolean); const _flat = []; _parts.forEach(function (p) { if (/\s/.test(p) && p.split(/\s+/).length > 1) { p.split(/\s+/).forEach(function (w) { if (w) _flat.push(w); }); } else _flat.push(p); }); out.genre = (_flat.length ? _flat : [_raw]).join(" / "); } const m2 = s2.match(/(?:available in|quality|音质|规格|格式)\s*[::]\s*([^\n\r]+)/i) || s2.match(/Available in\s*[::]\s*([^\n\r]+)/i); if (m2) out.quality = String(m2[1]).replace(/<[^>]*>/g, " ").split(/[,,、;;]|\s{2,}/).map(function (x) { return x.trim(); }).filter(Boolean); if (!out.quality.length) { const m3 = s2.match(/(\d{1,2}\s*[- ]?\s*Bit\s*\/\s*[\d.]+\s*k?Hz)/i); if (m3) out.quality = [m3[1]]; } } catch (e) {} return out; } async function autoTagsAsync(meta) { const m = Object.assign({}, meta || {}); const p = getAutoTagPrefs(); let _im = p.introMode || "intro+mb"; /* V9.73.72:提升到函数作用域 ✓ */ let _mbAllowed = (p.sources || []).indexOf("musicbrainz") >= 0 && _im !== "intro"; /* V9.73.68:**优先**用简介里的流派/音质(用户的待发布简介通常带 Genre / Available in ✓)*/ try { /* V9.73.71:简介是对象 { st, content, introFrom } ✓ 需兼容对象/字符串 ✓ */ const _introRaw = m.intro || m.introText || m.desc || ""; const _intro = (typeof _introRaw === "string") ? _introRaw : ((_introRaw && (_introRaw.content || _introRaw.text)) || ""); if (_intro) { const ie = tagIntroExtract(_intro); if (ie.genre && !String(m.genre || "").trim()) { m.genre = ie.genre; m._fromIntro = 1; } if (ie.quality && ie.quality.length) { m.quality = (Array.isArray(m.quality) && m.quality.length) ? m.quality : ie.quality; m._fromIntro = 1; } } } catch (e) {} const hasGenre = !!(m.genre && String(m.genre).trim()); try { _im = p.introMode || "intro+mb"; if (_im === "mb") { try { m._forceMb = 1; } catch (e) {} } /* mb 模式:总是联网补齐 ✓(已在 try 内 ✓)*/ _mbAllowed = (p.sources || []).indexOf("musicbrainz") >= 0 && _im !== "intro"; /* intro 模式不联网 ✓ */ if ((!hasGenre || m._forceMb) && _mbAllowed) { const mb = await mbLookup(m.artist, m.title || m.album); if (mb) { if (mb.genres && mb.genres.length) { m.genre = mb.genres.join(" / "); m._mb = 1; } if (!m.country && mb.country) { m.country = mb.country; m._mb = 1; } } } } catch (e) {} const r = autoTags(m); if (m._mb) r.reasons = ["MusicBrainz 补齐:genre=" + (m.genre || "-") + " country=" + (m.country || "-")].concat(r.reasons || []); if (m._fromIntro && !m._mb) r.reasons = ["来自简介:genre=" + (m.genre || "-") + " quality=" + [].concat(m.quality || []).slice(0, 1).join("")].concat(r.reasons || []); return r; } /* V9.73.66:兜底 —— 「更多设置」里点 🏷️ 自动标签设置 时打开设置面板 ✓ */ try { document.addEventListener("click", function (ev) { try { const el = ev.target && ev.target.closest ? ev.target.closest("[data-more=\"ocd-batch-autotag\"], #ocd-batch-autotag, [data-key=\"ocd-batch-autotag\"]") : null; if (!el) return; ev.preventDefault(); ev.stopPropagation(); openAutoTagSettings(); /* 批量更多设置·自动标签 ✓ */ } catch (e) {} }, true); } catch (e) {} function autoTags(meta) { const m = meta || {}; const p = getAutoTagPrefs(); /* V9.73.46:按设置生效 ✓ */ const hit = {}; const low = {}; const why = []; let alias = TAG_ALIAS_DEFAULT, split = TAG_SPLIT_DEFAULT; /* V9.73.56:优先使用设置面板里的自定义表(prefs.alias ✓)—— 此前只读旧键 ✗ 导致不生效 ✗ */ try { if (p && p.alias && typeof p.alias === "object" && Object.keys(p.alias).length) alias = p.alias; } catch (e) {} /* V9.73.61:把 MB 补充词合并进别名表(用户自定义优先 ✓ 其后补充 ✓ 最后内置 ✓)*/ try { const _merged2 = {}; for (const k in TAG_ALIAS_DEFAULT) _merged2[k] = (TAG_ALIAS_DEFAULT[k] || []).slice(); for (const k in TAG_ALIAS_EXTRA) { _merged2[k] = (_merged2[k] || []).concat(TAG_ALIAS_EXTRA[k] || []); } if (alias === TAG_ALIAS_DEFAULT || (p && p.alias && Object.keys(p.alias).length)) alias = Object.assign({}, _merged2, alias === TAG_ALIAS_DEFAULT ? {} : alias); else alias = _merged2; if (alias === TAG_ALIAS_DEFAULT) alias = _merged2; } catch (e) {} /* 兜底:无论如何都保证补充词可用 ✓ */ try { const _has = (id, w) => (alias[id] || []).some(function (x) { return normKey(x) === normKey(w); }); for (const k in TAG_ALIAS_EXTRA) { (TAG_ALIAS_EXTRA[k] || []).forEach(function (w) { if (!_has(k, w)) { alias[k] = (alias[k] || []).concat([w]); } }); } } catch (e) {} try { const ov = store.get("ocd_helper_tagalias_v1", null); if (ov && typeof ov === "object") { if (ov.alias && typeof ov.alias === "object") alias = ov.alias; if (ov.split && typeof ov.split === "object") split = ov.split; } } catch (e) {} /* V9.73.63:平台流派受 sources.platform 控制(关掉则只用标题规格 + 地区 ✓)*/ const _usePlatform = !p.sources || p.sources.indexOf("platform") >= 0; const genres = _usePlatform ? (Array.isArray(m.genre) ? m.genre : String(m.genre || "").split(/[,\/;|]+/)) : []; genres.map(function (g) { return ocdNormTag(g); }).filter(Boolean).forEach(function (g) { let done = false; if (p.splitOneToMany && split[g]) { split[g].forEach(function (id) { hit[id] = 1; }); why.push("genre:" + g + " -> split " + split[g].join("+")); done = true; } if (!done) { for (const id in alias) { const arr = alias[id] || []; if (arr.some(function (a) { return ocdNormTag(a) === g; })) { hit[id] = 1; why.push("genre:" + g + " -> " + id); done = true; } } } if (!done) { for (const id in alias) { const arr = alias[id] || []; if (arr.some(function (a) { const na = ocdNormTag(a); return na.length >= 4 && g.length >= 4 && (g.indexOf(na) >= 0 || na.indexOf(g) >= 0); })) { if (p.strictOnly) { low[id] = 1; why.push("genre~" + g + " ≈ " + id + "(低置信 ✓ 仅建议)"); } else { hit[id] = 1; why.push("genre~" + g + " ≈ " + id + "(阈值放宽 → 勾选 ✓)"); } done = true; } } } if (!done) why.push("genre:" + g + " 未识别 ✓ 仅提示"); }); if (p.hintQuality) { var _q = tagHintQuality(m.quality, m.title, m.subtitle, p.hifiTier); if (_q.on) { hit["7"] = 1; why.push("发烧(HiFi)<-" + (_q.from || "?") + ":" + _q.why + "(档位=" + p.hifiTier + ")"); } else if (_q.why && _q.why !== "未识别规格") { why.push("发烧(HiFi):不满足档位(" + p.hifiTier + "):" + _q.why); } } const reg = p.hintRegion && p.regionMode !== "off" ? tagRegionOf(m.country, (m.artist || "") + " " + (m.title || "") + " " + (m.subtitle || "")) : ""; if (reg && p.regionMode === "suggest") { low[reg] = 1; why.push("region:" + (m.country || "(文字体系)") + " -> " + reg + "(低置信建议 ✓ 未自动勾选 ✓)"); } else if (reg) { hit[reg] = 1; why.push("region:" + (m.country || "(文字体系)") + " -> " + reg + "(regionMode=auto ✓ 自动勾选 ✓)"); } /* V9.73.48:补回 auto 路径 ✓ */ /* V9.73.47:删除遗留的重复赋值(suggest 模式不得自动勾地区 ✗)*/ /* V9.73.47:删除遗留的重复赋值(suggest 模式不得自动勾地区 ✗)*/ let _hitIds = Object.keys(hit).sort(function (a, b) { return Number(a) - Number(b); }); if (p.conflict === "best" && _hitIds.length > 1) { const _genre = ["6","11","12","9","18","19","21","26","10","13","14","23","15","8","17","22","27","16","24","25","20"]; const _g = _hitIds.filter(function (id) { return _genre.indexOf(id) >= 0; }); if (_g.length > 1) { _hitIds = _hitIds.filter(function (id) { return _genre.indexOf(id) < 0 || id === _g[0]; }); why.push("conflict=best → 流派只保留 " + _g[0]); } } const ids = _hitIds; const lowIds = Object.keys(low).filter(function (id) { return !hit[id]; }).sort(function (a, b) { return Number(a) - Number(b); }); return { ids: ids, low: lowIds, reasons: why }; } /* V9.73.41:把自动匹配结果应用到「🏷️分类标签」面板(只补勾 ✓ 绝不取消已勾 ✓ 宁缺勿错 ✓)*/ /* V9.73.43:文档级事件委托 —— 按钮何时渲染都能生效 ✓(首次失败原因:绑定点早于渲染 ✗)*/ try { document.addEventListener("click", function (ev) { try { const el = ev.target && ev.target.closest ? ev.target.closest("#ocd-autotag-btn") : null; if (!el) return; ev.preventDefault(); ev.stopPropagation(); console.log("[OCD Helper] 自动标签:按钮被点击 ✓ meta=", JSON.stringify(window.__ocdLastParsedMeta || null)); Promise.resolve(applyAutoTags(null)).catch(function (e) { console.warn("[OCD Helper] 自动标签异常:", e && e.message); }); } catch (e) {} }, true); } catch (e) {} let _autoTagLast = null; /* V9.73.44:上一次本按钮自动勾选的 id(仅会话内 ✓ 不落盘 ✓)*/ async function applyAutoTags(meta) { const pop = document.getElementById("ocd-tag-pop"); let m = meta || null; if (!m) { try { m = window.__ocdLastParsedMeta || null; } catch (e) {} } if (!m) { try { toastSafe("✨ 自动匹配需要平台元数据:请先完成解析(或用 __ocd.applyAutoTags(meta) 传入)", "err"); } catch (e) {} return { ok: false, added: 0, msg: "no-meta" }; } if (!pop) { /* V9.73.43:自动打开 🏷️分类标签 面板后再应用(真正一键 ✓)*/ try { const openBtn = document.getElementById("ocd-tag-btn"); if (openBtn) openBtn.click(); } catch (e) {} let waited = 0; while (waited < 2000 && !document.getElementById("ocd-tag-pop")) { await new Promise(function (res) { setTimeout(res, 100); }); waited += 100; } const pop2 = document.getElementById("ocd-tag-pop"); if (!pop2) { try { toastSafe("✨ 未能打开 🏷️分类标签 面板(请手动点一下再试)", "err"); } catch (e) {} return { ok: false, added: 0, msg: "no-panel" }; } return applyAutoTags(m); } const r = await autoTagsAsync(m); /* V9.73.44:若上次自动勾选的那批标签**现在仍然都勾着** → 视为"再点一次撤销" ✓ */ const _isOn = (el) => /var\(--ok/.test(String(el.getAttribute("style") || "")); try { if (_autoTagLast && _autoTagLast.length) { const still = _autoTagLast.filter(function (id) { const el = pop.querySelector("[data-tag=\"" + id + "\"]"); return el && _isOn(el); }); if (still.length === _autoTagLast.length) { let off = 0; still.forEach(function (id) { const el = pop.querySelector("[data-tag=\"" + id + "\"]"); if (el) { try { el.click(); off++; } catch (e) {} } }); const nameOf2 = (id) => { try { const f = TAG_GROUPS.flatMap(g => g.row).find(x => x[0] === id); return f ? f[1] : id; } catch (e) { return id; } }; _autoTagLast = null; try { toastSafe("↩️ 已撤销上一次自动勾选(" + off + " 个):" + still.map(nameOf2).join("、") + " · 你的手动选择未受影响", "ok"); } catch (e) {} try { console.log("[OCD Helper] 自动标签:已撤销", JSON.stringify(still)); } catch (e) {} return { ok: true, toggledOff: off, ids: still }; } } } catch (e) {} const isOn = (el) => /var\(--ok/.test(String(el.getAttribute("style") || "")); let added = 0; let already = 0; const missing = []; r.ids.forEach(function (id) { const el = pop.querySelector("[data-tag=\"" + id + "\"]"); if (!el) { missing.push(id); return; } if (isOn(el)) { already++; return; } try { el.click(); added++; } catch (e) {} }); const nameOf = (id) => { try { const f = TAG_GROUPS.flatMap(g => g.row).find(x => x[0] === id); return f ? f[1] : id; } catch (e) { return id; } }; const addNames = r.ids.map(nameOf); const lowNames = r.low.map(nameOf); let msg = "✨ 已自动勾选 " + added + " 个" + (already ? "(另有 " + already + " 个已勾选)" : "") + ":" + addNames.join("、"); if (lowNames.length) msg += " | 建议(低置信度,未勾选):" + lowNames.join("、"); if (missing.length) msg += " | 面板未找到:" + missing.join("、"); _batchTagProgressShow(msg + "\n(每条任务的打标结果已写入其任务卡备注 ✓ · 同一批再点一次可一次性撤销 ✓)", undo.length ? "ok" : "warn"); try { toastSafe(msg, "ok"); } catch (e) {} try { console.log("[OCD Helper] 自动标签:", JSON.stringify({ ids: r.ids, low: r.low, reasons: r.reasons })); } catch (e) {} try { _autoTagLast = added ? r.ids.slice() : null; } catch (e) {} /* V9.73.44:记住本次自动勾选的 id ✓ 便于再点撤销 ✓ */ if (added) { try { console.log("[OCD Helper] 自动标签:已勾选 " + added + " 个(再点一次可撤销 ✓)"); } catch (e) {} } return { ok: true, added: added, already: already, ids: r.ids, low: r.low, missing: missing, reasons: r.reasons }; } const CHANGELOG = [ { v: '9.76.18', d: '2026-10-06', items: [ '起因:用户 m14220–m14222 真机报障(截图 + 原话)—— 搜索求助助手(专辑「满江红」)点「🔍 开始搜索」后,① 那行显示「🟡 ① 站内查重 · 命中 7 条 ▸」却**点不动**、面板上**没有继续下一环节的搜索选项**、点「🧹 清空」只弹出「正在搜索中…请稍候再清空」✗ 三项现象同时出现 ✓', '取证(两条路径混用 ✓):CDP 探针挂在**主世界**给 Element.prototype.innerHTML setter 打钩(TM 5.x 下脚本与页面同域 ✓)→ 抓到唯一一条「innerHTML 清空含结果块的父节点」:tgt=`sh_result` / len=0 / 栈 `at clearSearchResults (:28369:40) < at HTMLButtonElement. (:28406:7)` ✓;同时 Runtime.exceptionThrown 收到 `TypeError: Cannot set properties of null (setting \'innerHTML\')` · 栈 `shRenderDupResult (:23024:25) ← HTMLButtonElement. (:28420:9)`(同轮后续还有 shRunRequestSearch ← 平台解析段 ✓);真机读数链:新建面板 `blocks:4` → 点「开始搜索」**同步立刻** `blocks:0` ✓(① 行文字本身照常推进到 ⏳ → ✅/🟡 ✓ 站内查重逻辑没坏 ✓ 控制台 `[搜索求助] 站内搜索:kw=满江红 … area=0` ✓)', '根因(`src\\40-种子列表页:详情弹窗.js`):面板 HTML 里 `
` **正是** 4 个 `
` 的父节点(:2283-2288 ✓)—— V9.76.17 新增的「开始搜索先清空上一次结果」清的是 `#sh_result.innerHTML` ✗ 于是 4 个结果块被**一起删除** → 紧接着 `const rBlock1 = panel.querySelector(\'#sh-step-result-1\')` 只能是 `null` → `shRenderDupResult(null, dup, mode)`(当时**没有 null 守卫**)抛 TypeError → 流程中断在「▶ 继续查求助区」+「⏹ 停止搜索」按钮行构建**之前**、且 `shSearching = false`(原 :2498)永不执行 → 于是:① 行 `▸` 点不开(`shSetStep` 找不到目标块 ✓)· 没有继续按钮 ✓ · `#sh_clear` 命中 `if (shSearching) { toastSafe(\'正在搜索中…请稍候再清空\', \'err\'); return; }` ✓ 三条现象全部对上 ✓', '修法 A(`src\\40` clearSearchResults):**不再整片清空 `#sh_result`** ✗ 改为只清 4 个结果块自身(`innerHTML` 清空 + 隐藏),块以外的子节点(按钮行、平台解析结果 platWrap)才 `remove()` ✓ 四个块永远留在 DOM 里 ✓(清空按钮、搜索前预清空、关闭确认三处共用它 ✓ 行为不变 ✓)', '修法 B(`src\\40`):①「命中/失败」分支补 `if (rBlock1)` 守卫 ✓ ② `shRunRequestSearch()` 取 rBlock2 后补 `if (!rBlock2) { shSetStep(2, …结果块缺失); return; }` ✓ ③「继续查求助区」按钮监听器补 try/catch + finally ✓', '修法 C(`src\\40` 承重改动):`#sh_search` 整个处理体包 `try { … } catch (e) { … } finally { shSearching = false; }` —— 从今往后**任何环节抛异常都只会 toast 真实错误 + 自动放行** ✓ 再也不会把面板锁死在「正在搜索中」(连「清空」都点不动 ✗);catch 里只在 ① 仍处 running 时才改它的文案(不覆盖后续环节已得结果 ✓)', '修法 D(`src\\36-搜索求助模块.js` 防御):`shRenderDupResult()` / `shRenderRequestResult()` 开头各加 `if (!container) return;` ✓ 以后就算容器被外力清掉,也只是**少画一块**、绝不再抛 TypeError 把整条流程带走 ✓(少动少错:站点/平台解析逻辑一行未改 ✓)', '改动 C ✓(版本号三处):`src\\00-head.js` 的 `// @version 9.76.18` · `src\\01` 的 `const VERSION = 9.76.18` · 本 CHANGELOG 数组顶部条目 ✓', '测试 ✓(`tests\\38` 新增 B18~B23 + S-A12):jsdom 真开「搜索求助」面板 → 断言 `#sh_result` 恰有 4 个 `[id^="sh-step-result-"]` 子节点 ✓;点「🧹 清空」后 4 块**仍在**且内容为空 ✓;点「🔍 开始搜索」后**同步立刻**仍 4 块 + `#sh_steps` 可见 + ① 块里是「⏳ 正在查重站内种子…」✓;源码守卫:不得再出现「把 `#sh_result` 整体清空」的旧写法(`if (resultEl) resultEl.innerHTML = 空串` ✗)、四处守卫与 `finally` 必须都在 ✓;反向验证:对 `备份\\OpenCD 工具箱 脚本插件.V9.76.17.bak.txt` 重跑本组新断言**必须 FAIL** ✓', '真机复测(TM 注入 + 直注入两条路径 ✓):① 满江红 → 命中 7 条时 4 块仍在 → 「▶ 继续查求助区」出现 → 点了真的进 ② 环节 ✓;② 「🧹 清空」可清、不再被「搜索中」拒绝 ✓;③ 强制造异常时面板自动放行(不再锁死 ✓)', '回归与真机读数见 CHANGELOG.md 与 取证记录\\2026-10-06-搜索求助回归修复-V9.76.18.md ✓' ] }, { v: '9.76.17', d: '2026-10-06', items: [ '起因:用户 m13487 一次提出八项优化 —— ① 取消「促销标签高亮」「促销行色条」两个设置项(功能固定开)② 取消「颜色微调 / 主题微调」功能 ③ 页面种子探测的标题点击能打开预览 ④ 面板高度可拖到只剩标题栏 + 其余模块也支持上下拖 + 拖动时显示边缘彩色条 ⑤ 搜索求助要能清空结果、去掉「30 分钟缓存恢复」、关闭前提醒、加清空按钮 ⑥-1 评论成功要提示 + 实时显示 + 清空输入框 ⑥-2 每条评论加「引用」「回复」按钮 ⑥-3 取消「⚡ 应求+引用+关注」组合按钮 ⑥-4 正文与评论里的链接变成可点超链接 ✓', '① 促销固定开(`src\\50` 促销标签强化):删 `PROMO_KEY` 常量与读写,`shPromoPrefs()` 直接 `return { enabled: true, tint: true };` —— 用户在设置中心里再也看不到这两个勾选项(`src\\45` 删两行 mkChk ✓),但高亮 / 色条照旧生效 ✓;历史键 `ocd_helper_promo_v1` 不再读写(留在存储里不碍事 ✓)', '② 颜色微调下线(真机链一次全拆):`src\\45` 删「🎚 颜色微调」入口 + 整段 twRow 控件;`src\\58` 删 `shShowThemeTweakTool()` 整函数、`shThemePackGet()` 不再导出 tweak 字段、`shThemePackApply()` 不再读 tweak / 不再写 `TWEAK_KEY`(老主题包带 tweak 字段一并忽略 ✓ 不报错 ✓);`src\\48` 删 `applyThemeTweaks()` 整函数与调用点;`src\\01` 删 `TWEAK_KEY` 常量;`src\\63` 删 `__ocd.tweakTool` 句柄;`src\\40` 删键登记表里的「主题微调(状态色)」一行 ✓(全仓 grep 后只剩注释 ✓ 17 套主题一切照旧 ✓)', '③ 探测标题点击预览(`src\\32`):结果行标题 span 加 `class="ps-title"` + `data-pid=`(从 `x.url` 的 `id=` 取 ✓),点击即调 `shShowTorrentInfoPop(pid, 标题)` —— 与种子列表页那个「预览」按钮是同一个弹层 ✓(原来标题带 `cursor:pointer` 却点不动 ✗ 误导用户 ✗,现在真能开 ✓;没有 id 的行不绑 ✓)', '④ 面板高度拖动(`src\\44` 共享层):`makeEdgeResize()` 升级支持 `opts.mode`(`w` / `h` / `wh` 默认)—— 增加**下边缘高度拖动**(`ns-resize`)+ 双击下边缘复位;高度下限**动态取标题栏高度 + 18px**(`_panelMinH()` 扫 `[id$="-head"], .ocd-panel-head, .ocd-head, .ocd-drag-handle` ✓ 下限兜底 44)→ 面板可以一路缩到只剩标题栏 ✓;拖动期间显示**边缘彩色渐变条** `#ocd-resize-strip`(横向拖动 = 90° 渐变、纵向 = 180° ✓ 3px ✓ `pointer-events:none` ✓ 松手即隐藏 ✓);高度写入 `opts.keyH` ✓;**7 处调用点**(`src\\28` 批量面板 / `src\\32` 探测 / `src\\34` / `src\\35` / `src\\40` / `src\\53` / `src\\54`)都因默认 `mode=wh` 自动获得底部拖动 ✓(批量面板原来只能缩到 200px ✗ 现在缩到标题栏 + 18 ✓;双击标题栏收起/展开逻辑一行未动 ✓)', '⑤ 搜索求助(`src\\40`):① 每次「🔍 开始搜索」前先 `clearSearchResults(true)` —— 上一次的结果不再叠加在下面 ✗ ② 删掉整套「上次搜索结果已恢复(30 分钟内有效)」会话缓存(`_shSession` / `shSaveSession` 全拆 ✓ 不再有任何隐藏缓存 ✓)③ 新增 `hasSearchOutput()`:正在跑或已有结果时点关闭 → 弹确认(进行中给的文案是「建议双击标题栏收起面板(不丢结果)」✓ 有结果时给「不再缓存、下次打开也不会自动恢复」✓ 用户确认才清空退出 ✓)④ 搜索按钮右侧新增 **`🧹 清空`** 小按钮(只清结果与进度、保留输入框内容 ✓ 搜索进行中点它会提示不受理 ✓)', '⑥-1 评论反馈 / 实时显示 / 清输入框(`src\\41`):提交后**不再拿 `r.ok` 当唯一判据** ✗ —— 真机复测发现本站有时不回成功页(被判失败 ✗)但评论其实已经发出去了 ✓ → 现在强制 `load(true)` 重抓原帖,用「这段正文有没有出现在评论列表里」当成功证据(`shReqSameText` 比前 60 字 ✓ 再去 BBCode 标签后取 60 字做包含比对 ✓),落地即判成功 ✓;成功时清空输入框 + 复位「回复/引用」态 + **就地重渲染评论列表**(新评论绿色高亮 + `✓ 刚发布` 2 分钟内有效 ✓ + `scrollIntoView` 滚到可见 ✓)+ 分两种如实 toast(「列表已就地刷新 ✓」/「站点未回成功页,已按评论列表复核确认 ✓」)✓;失败才保留文字并给出真实原因 ✓', '⑥-2 引用 / 回复按钮(`src\\41`):每条评论右侧新增 `❝ 引用` / `↩ 回复` 两个小按钮(带 `data-cid` / `data-author` ✓)—— 引用 = 把 `[quote]作者:正文[/quote]` 填进评论框 ✓ 回复 = 填入 `[b]回复 #id 作者:[/b]` ✓(都会自动展开评论框 + 聚焦 + 把提交按钮文案改成「发表引用评论」/「回复 X」✓ 提交成功后复位 ✓)', '⑥-3 取消组合按钮(`src\\41`):删掉「⚡ 应求+引用+关注」按钮及其监听器 ✓(应求 / 引用 / 关注在面板上本来就能分别点 ✓ 不再留一个半吊子组合入口 ✗;`shReqTripleAction()` 函数保留未删 ✓ 少动少错 ✓)', '⑥-4 链接超链接化(`src\\41`):新增 `shReqLinkify()` —— 先转义再套 ``(XSS 面不变 ✓)、`target="_blank" rel="noopener noreferrer"` ✓、尾随标点(`)]}>,.;:!?,。;:!?)】`)不吞进链接 ✓、`www.` 自动补 `https://` ✓;用于**评论正文**与**帖子正文**(`#ocd-rpv-body` 由 `textContent` 改为 `innerHTML = shReqLinkify(...)` ✓ 换行靠容器原有 `white-space:pre-wrap` ✓)', '改动 C ✓(版本号三处):`src\\00-head.js` 的 `// @version 9.76.17` · `src\\01` 的 `const VERSION = 9.76.17` · 本 CHANGELOG 数组顶部条目 ✓', '只加不改 ✓:数据 / 解析 / 发种 / 发布 / 门控 / 查重 / 标签 逻辑一行未改 ✓;本次全部是交互与设置面优化 ✓(删掉的只有「用户明确要求取消」的三个设置入口与一个组合按钮 ✓)', '测试 ✓:新增断言守 8 项改动(源码守卫 + jsdom 功能断言:标题点击能开预览弹层 ✓ / makeEdgeResize 支持 mode=h 与 `#ocd-resize-strip` ✓ / 搜索清空与关闭确认 ✓ / 评论落地复核与就地高亮 ✓ / 引用回复填框 ✓ / 取消 triple ✓ / linkify 输出 ` ✗;而我们的 `.ocd-td-atab` 只声明了 width / border-collapse / font-size ✗ 没写底色,`td` 也没写字色(rules 为空 ✗)→ 站点浅绿底 + 我们的主题浅字 = 看不清 ✗', '改动 A ✓(本表 · `src\\27` 任务详情 CSS):选择器提权 `.ocd-td-atab` → **`.ocd-td-ascroll .ocd-td-atab`**(0,2,0 ✓ 稳赢站点的 0,0,1 ✓),并显式给出 `background-color:var(--bg-soft,#262b35);color:var(--fg,#e8eaf0)`;`td` 同样改成 `.ocd-td-ascroll .ocd-td-atab td` 并加 `background-color:transparent;color:var(--fg,#e8eaf0)` ✓(表头 th 保持 `var(--bg,…)` 更深一档 → 表头与行仍能区分 ✓ 全走主题变量 → 17 套主题都自适配 ✓)', '改动 B ✓(同类表格一次性兜底 · `src\\63` 的 `ocd-scroll-iso-css` 新增第 5 节):`[id^="ocd-"] table, [data-ocd-panel] table { background-color: transparent; }` —— 审计发现 `src\\` 里共 **17 处 ``**(21/36/49/55/53/60/58/62/16b),除本表外**都没有写底色** ✗ → 同样会被站点的 `table` 规则染色(只是还没被用户撞见 ✗);这条规则把我们**自己容器内**的表格底色统一清成透明(露出面板自身的深色底 ✓),选择器带 `[id^="ocd-"]` 前缀(0,1,1 ✓)→ 比站点更具体 ✓,且**不碰站点自己的表格** ✓(站内 torrents 表照旧 ✓)', '为什么不用 !important ✓:真机把命中规则一条条列出来,命中我们的只有站点那一条元素选择器 ✓ → 0,2,0 / 0,1,1 已经稳赢 ✓;不动用 !important 也就不影响用户主题与站点样式的其它覆盖 ✓(少一次强耦合 ✓)', '改动 C ✓(版本号三处):`src\\00-head.js` 的 `// @version 9.76.16` · `src\\01` 的 `const VERSION = 9.76.16` · 本 CHANGELOG 数组顶部条目 ✓', '只加不改 ✓:数据 / 解析 / 发种 / 发布 / 门控 / 查重 / 标签 逻辑一行未改 ✓;本次纯样式(可读性)修复 ✓', '测试 ✓:`tests\\37-audio-spec.test.mjs` 新增 S-A19(源码守卫:`.ocd-td-ascroll .ocd-td-atab{` + `background-color:var(--bg-soft,#262b35)` + `color:var(--fg,#e8eaf0)` + `}.ocd-td-ascroll .ocd-td-atab td{`)+ S-A20(源码守卫:`src\\63` 的 `[id^="ocd-"] table` + `[data-ocd-panel] table` + `background-color: transparent;`)+ **B47/B48 级联功能性断言**:在 jsdom 里先注入站点那条 `table{background-color:rgb(198,227,198)}`,再打开任务详情 → 断言 `getComputedStyle` 算出的表底色**不是**站点浅绿、且字色与底色的对比度 **≥ 4.5** ✓(jsdom 能真实算级联 ✓ 已实测:`[id^="ocd-"] table` 能压过站点 `table` ✓,而站点自己的表仍是 rgb(198,227,198) ✓)', '反向验证 ✓:旧快照(V9.76.15)上重跑本套件 → **B47 / B48 FAIL** ✓(B47 实测:旧快照里表格 computed 底色 = 站点 rgb(198,227,198)、字色 var(--fg,#e8eaf0) → **对比度 1.15** ✗ —— 与真机 CDP 读数 1.15 **完全一致** ✓ 说明 jsdom 复现忠实 ✓)+ S-A17 FAIL(版本号未前进 ✓);S-A19 / S-A20 读的是 src\\ 源码(已改 ✓)所以仍 PASS ✓ —— 反向信号由 B47 / B48 承担 ✓(旧快照整轮:71 条断言 PASS 68 / FAIL 3 ✓)', '回归与真机读数见 CHANGELOG.md 与 取证记录\\2026-10-05-表格底色隔离-V9.76.16.md ✓' ] }, { v: '9.76.15', d: '2026-10-05', items: [ '起因:用户 m12860 附真机截图 —— 任务详情的「🎵 音频规格解析」显示「⚠️ 在种子目录里没找到与「清风明月」匹配的音频文件夹 · 用时 40ms / 没有解析到音频文件。」;用户 m12861 原话:「音频文件在音乐专辑文件夹中,不是种子文件夹,这个任务的频谱图所在的目录就是音频文件」✓ —— 即 V9.76.14 只查「种子文件目录」(torrent_dir)✗ 而音频文件其实与该任务的**频谱图放在同一个音乐专辑文件夹**里 ✓', '取证 ✓:先用探针在**我的 CDP 探针浏览器**(profile `%LOCALAPPDATA%\\ocd-cdp`)读 `ocd_helper_fs` 的 handles → `hasSpec:false / hasTor:false`(IDB 仅 3021 字节 ≈ 空 ✗ 无任何授权 / 无任务)→ 该 profile 根本产生不了「没找到」那句 ✗;再二进制扫两个 profile 的 TM 存储 → **用户日常 Edge**(`…\\Microsoft\\Edge\\User Data\\Default`)里 TM 扩展(`iikmkjmpaadaobahmlepeloendndfphd`)有 9.76.14 脚本 + 真实任务「清风明月」(×3)+ 真实目录授权 ✓ → 确认是**用户在自家日常浏览器里复测**的 ✓(我的 tm-install 只装到 ocd-cdp ✗)', '改动 A ✓(目录来源):新增 `AUDIO_DIR_KEYS = [{key:"spec_dir",cn:"频谱图目录"},{key:"torrent_dir",cn:"种子文件目录"}]` —— **频谱图目录优先 ✓ 种子文件目录兜底 ✓**(与 src\\22 频谱图用 `FSDB.get("spec_dir")` 完全一致 ✓ 真机长期验证 ✓)', '改动 B ✓(匹配规则对齐):`audioNameHit`(松:年份命中即算 ✗ 会把别的专辑认进来 ✗)→ 换成 **`audioStrongHit`**:专辑名必须命中 ✓ 任务里有艺术家时艺术家也必须命中 ✓ 专辑缺失才退「艺术家 + 年份」✓ 再退年份 ✓ —— 与 src\\16 `matchFileInDir` 的 strongHit 同一套规则 ✓', '改动 C ✓(同名多版本宁缺勿错):候选 ≥2 且目录名带版本词(deluxe/remaster/…)或音质特征(`[16Bit-44.1kHz]` / `[24Bit-96kHz]`)时,按任务名的 `torrentNameSignature`(src\\19 的 `torrentNameVersionWords` + `torrentQualitySig` ✓ V9.74.9 同一套 ✓)**严格过滤**;名字完全一致的优先 ✓;严格过滤为空 → 明确失败并写明原因 ✓(绝不贴到另一个版本 ✗)', '改动 D ✓(多碟):专辑文件夹里没有音频文件时 → 往下一层找 CD1 / CD2 …(最多 8 个)**合并成一个列表** ✓(此前只认专辑夹直接子文件 ✗ 多碟专辑会解析不到 ✗)', '改动 E ✓(顺序与数量):候选排序改为「年份命中优先 → 名字更长优先」✓ 最多试 3 个候选(原 4 个 ✗);兜底「音频文件直接躺在授权目录根下」保留 ✓ 标签由 `(种子目录根)` 改为 `(目录根)` ✓', '改动 F ✓(权限不再一票否决):权限 `prompt` 且没有用户手势时 ✗ 原来直接终止 ✗ → 现在**记下 `permIssue` 继续试下一个来源** ✓(例如频谱图目录没续期但种子目录可用时仍能解析 ✓),但自动路径**绝不弹窗** ✗ 只在用户点击的工具条按钮里 `requestPermission` ✓;`o.dir` 注入的假目录不查权限 ✓(测试路径 ✓)', '改动 G ✓(文案如实):失败时区分两种 —— ① 权限问题:「「频谱图目录」的读取权限未续期 → 点一次「🎵 音频规格解析」(需要一次用户点击)即可续期」② 真找不到:「没找到与「X」匹配的音频文件夹(已查 频谱图目录:… · 种子文件目录:…)→ 音频文件要和该任务的频谱图放在同一个音乐专辑文件夹里」✓ —— 把「查了哪里 / 为什么没找到」直接写在用户看到的那一行 ✓', '改动 H ✓(来源可追溯):新增结果字段 `src`(键)+ **`srcCn`(中文名)** —— 任务详情概要行现在显示「· 来源:频谱图目录 · 目录:<专辑夹名>」✓;**`sanitizeTask` 白名单必须同步登记 `srcCn`** ✗(白名单式重建 ✗ 不登记就每次保存静默丢弃 ✗)', '调试句柄 ✓:`__ocd.audioSpecOfTask(id, dir, dirs)` 新增第三参 `dirs`(`[{key,cn,handle}]` 候选列表 ✓ 测试缝 ✓)→ 回归能真实验证「频谱图目录优先 / 种子目录兜底 / 两处都没有 / 多版本严格过滤 / 多碟合并」✓', '只加不改 ✓:发种 / 发布 / 门控 / 查重 / 标签 逻辑一行未改 ✓;音频规格仍**不参与任何表单字段与校验** ✓ 纯只读展示 ✓', '测试 ✓:tests\\37 新增 B21(频谱图目录优先:两处都有匹配专辑,只解析频谱图目录那份 ✓)· B22(频谱图目录里没有音频 → 兜底种子目录 ✓)· B23(两处都没有 → 文案含「没找到」+「已查」+ 两个来源中文名 ✓)· B24(同名多版本:`[16Bit-44.1kHz]` 任务名只能命中 16Bit 目录;只有 24Bit 目录时**明确失败** ✓)· B25(多碟 CD1/CD2 合并 ✓)· B26(权限未续期时继续试下一个来源 ✓);S-A13 断言同步更新 ✓', '回归与真机读数见 CHANGELOG.md 与 取证记录\\2026-10-05-音频规格解析目录改频谱图目录-V9.76.15.md ✓' ] }, { v: '9.76.14', d: '2026-10-05', items: [ '起因:用户 m12241(六条)—— ①「本地音频文件规格解析 / 做法:用户已授权 torrent_dir 后,用 File System Access 读取目录里音频文件,解析 STREAMINFO / WAVEFORMAT,自动得出 位深 / 采样率 / 声道 / 总时长 / 码率」②「这个功能在批量发布模块中 解析任务之后可 DIY 启停这个功能」③「按钮位置放在 多选/选择任务后的置顶按钮选项栏中 [音频规格解析]」④「解析后 放在任务详情中」⑤「同时优化任务详情中的布局:将 各环节状态与耗时 放到 解析结果 那一栏中 中间有竖线 隔开区分 / 各环节状态与耗时放到右侧 不需要平分 右侧只需要一点宽度就行」⑥「原来的各环节状态与耗时 换成:音频规格解析 / 下面就是每首歌的规格 / 原有弹窗高度不变,放不下时加入滚动」✓', '新增文件 ✓ `src\\16b-本地音频规格解析.js`(排在 src\\16 之后 ✓):只读**文件头 256KB** 解析 —— FLAC STREAMINFO(20bit 采样率 + 3bit 声道 + 5bit 位深 + 36bit 总样点,手写位读取器 ✓)· WAV/RF64 的 fmt 块(RIFF 小端 + 块对齐 ✓)· MP3 帧头(版本/层/码率/采样率表 + Xing/Info 帧数 → 精确时长 ✓ 否则按 CBR 估算并标 ≈)· DSF(采样率/声道/位深/样点)· AIFF COMM(含 80 位扩展浮点采样率 ✓);无法解析的格式(ape/m4a/wv/tta 等)**如实标注不支持** ✓ 不猜 ✗ —— 遵循「宁缺勿错」✓', '目录匹配 ✓:`audioPickAlbumDir(root, task)` 用 `matchKwOf(parsed)`(年份/艺术家/专辑)在 `torrent_dir` 里找 **专辑子目录**(按 专辑名/艺术家/年份 命中 + 年份优先 + 名字最短优先 ✓ 最多试 4 个候选 ✓),找不到则在根目录挑名字命中的音频文件 ✓;目录对象经 `FSDB.get("torrent_dir")` 取回并做 `queryPermission({mode:"read"})` 检查(只在**用户手势**里 `requestPermission` 续期 ✓ 自动路径不弹窗 ✗ 只提示去点一次 ✓)', '按钮与入口 ✓:工具条新增 ``(放在「▶ 继续」之后 ✓ 且登记进 `BAR_DEFAULT_ORDER` / `BAR_CN` → DIY 排序里也能看到 ✓ 0 勾选时随整条工具条自动隐藏 ✓);事件分发 `src\\27` 里 `batchAudioSpecSelected(ids)` → 逐个任务解析 → 结果写 `result.audio` ✓;任务详情里也有就地按钮「🎵 解析本任务 / ↻ 重新解析」✓(这个点击同时充当权限续期的用户手势 ✓)', 'DIY 启停 ✓:「⚙ 更多设置」新增 `🎵 解析后自动解析音频规格:开|关`(默认**关** ✓ 存 `AUDIOSPEC_KEY = "ocd_helper_audioms_v1"`);开启后 `src\\18` 在「解析完成钩子」里自动跑一次(`silent:true` 不闪中间态 ✓ 无权限/失败都**不影响解析** ✓ 只写日志 ✓)', '落库白名单 ✓(关键坑):`sanitizeTask` 是白名单式重建 ✗ → 新增 `result.audio` 的完整登记(st/ms/dir/src/note/tracks[≤400]/sum ✓ 含 est 估算标记 ✓)—— **不登记就会在每次 saveBatchTasks 时被静默丢弃** ✗', '任务详情两栏布局 ✓(用户第 ⑤ 条):左栏 `解析结果`(flex:1 ✓ 自适应 ✓)|右栏 `各环节状态与耗时`(`flex:0 0 150px` ✓ 只占一点宽度 ✓ 不平分 ✗,`border-left:1px solid` 竖线分隔 ✓ 九行改为 compact 行 = 环节名 + 状态 + 同色耗时 ✓ 备注移进 `title` 悬停提示 ✓)', '音频区块 ✓(用户第 ⑥ 条):原「各环节状态与耗时」的位置改为「🎵 音频规格解析」—— 段落标题 + 就地按钮 + 概要行(N 轨 · 全轨一致 16bit / 44.1kHz / 2ch · 合计 4:12 · 用时 xxms ✓ 混规格显示「混合 …」✓)+ **逐曲表格**(# / 文件 / 位深 / 采样率 / 声道 / 时长 / 码率 ✓ 表头 sticky ✓ 表格自身 `max-height:186px;overflow:auto` ✓ 弹窗仍是 `max-height:88vh` **高度未改** ✓ 放不下时整体与表格各自滚动 ✓)', '顺手修的既有缺陷 ✓:`#ocd-td-css` 原来**只在不存在时注入** ✗ → 脚本升级后旧 CSS 会一直残留、新样式永远不生效 ✗ —— 改为**每次打开都重写 textContent** ✓(本轮新增的两栏与表格样式才能生效 ✓)', '调试句柄 ✓(回归与真机探针用):`__ocd.audioSpecParse(buf,name,size)` · `audioSpecExts()` · `audioSpecOfTask(id,dir)` · `audioSpecTask(id,dir)` · `audioSpecSelected()` · `audioSpecPrefs()` · `setAudioSpecPrefs({auto})` · `audioSpecResult(id)` ✓(`dir` 可注入假目录 —— jsdom 没有 File System Access ✗ 照 src\\22 的 `D.canPick` 范式 ✓)', '只加不改 ✓:发种 / 发布 / 门控 / 查重 / 标签 逻辑一行未改 ✓;音频规格**不参与任何表单字段与校验** ✓ 纯只读展示 ✓', '测试 ✓:新增 tests\\37-audio-spec.test.mjs(纯解析器用**手工构造的 FLAC/WAV/MP3/DSF/AIFF 头部字节**断言 位深/采样率/声道/时长/码率 ✓;假目录(`kind`/`name`/`getFile`)验证目录匹配、逐轨结果、混规格与不支持格式的如实标注 ✓;`sanitizeTask` 白名单往返不丢 `result.audio` ✓;详情 DOM 两栏与逐曲表格 ✓;工具条按钮与 DIY 顺序 ✓;偏好三态 ✓;句柄存在性 ✓)', '反向验证 ✓:旧快照(V9.76.13)上重跑新套件 → 与本次三类改动相关的用例 FAIL ✓(恰好覆盖:解析器句柄不存在 / 工具条无按钮 / 详情无两栏无音频表 / prefs 缺失 ✓)', '回归与真机读数见 CHANGELOG.md 与 取证记录\\2026-10-05-本地音频规格解析-V9.76.14.md ✓' ] }, { v: '9.76.13', d: '2026-10-05', items: [ '起因:用户 m12010(附「📄 任务详情」截图)—— 「我认为不需要显示消耗时间 / 把这个环节耗时统计放到 任务详情内 外面只显示这些环节的状态即可」✓ 即把 V9.76.12 刚加到卡片上的九环节耗时**收进任务详情**,卡片只留状态 ✓', '改前形态 ✗:V9.76.12 把耗时小字加在卡片每个环节芯片后面(`stepSummary` 里的 `msTag`)—— 卡片一行要塞下九个环节 + 九段耗时,视觉上很挤 ✗;而「📄 任务详情 → 各环节状态」当时**只有 6 行**(查重/平台/简介/曲目/封面/预设),标签 / 种子 / 频谱 三步根本没有行 ✗,`总耗时` 也只累加 四步(platform/intro/tracks/cover)✗ —— 恰好把最想看的几个耗时漏在外面 ✗', '改动 A ✓(卡片):`stepSummary` 去掉 `_ms` / `msTag`,芯片只输出 `环节名 + 状态符号`(九色与「标签(N)」草稿同源计数**都保留** ✓);失败态的 `↻ 重试` 按钮里也不再带耗时 ✓', '改动 B ✓(任务详情):`openTaskDetail` 的 `stepLine(label, obj, k)` 多接一个环节 key,把 `stepMsText(obj.ms)` 以**同色小字**显示在该行状态之后(如「曲目 ✅ · 0ms」「查重 ✅ · 搜索无结果 · 7.7s」)✓;各环节行从 6 行补成**完整九行**(新增 标签 / 种子 / 频谱,预设与标签仍带 `src` / `N 个` 说明 ✓);段落标题改为「各环节状态与耗时」✓;`总耗时` 改用 `stepTotalMs(t)` = **九个环节 ms 之和**(并注明「九个环节之和」✓),一个环节都没计时就显示 `—` ✓', '改动 C ✓(复制全部):`buildTaskDiag` 的每行状态追加 `(7.7s)` 形式耗时 ✓;失败环节判定从写死的 5 步改成遍历 `STEP_ORDER`(种子/频谱/预设/标签 失败也会列出来 ✓);末尾追加 `合计:X.Ys(九个环节之和)` ✓', '改动 D ✓(防漂移):把 `STEP_CN` / `STEP_ORDER` / `STEP_COLOR` / `stepMsText` / `stepTagList` / 新增 `stepTotalMs` 从 `renderBatchList` 内部**提到 §27 顶层**——卡片(stepSummary)、任务详情(openTaskDetail)、复制全部(buildTaskDiag)三处共用同一份定义 ✓ 避免「中文名 / 九色 / 耗时格式」各写一份而慢慢漂移 ✗', '数据侧一行未改 ✓:V9.76.12 加的计时(src\\18 预设/标签计时 + src\\22 的 `seedMs`/`specMs`)与 `sanitizeTask` 白名单**全部保留** ✓ —— 耗时仍在 `result[k].ms` 里(卡片不显示 ≠ 不采集 ✓);标签草稿同源(`batchSyncDraftTags`)也保留 ✓', '只动显示 ✓:解析 / 查重 / 发布 / 门控逻辑一行未改 ✓;本次纯 UI 位置调整 ✓', '测试 ✓:tests\\36-batch-card-chips.test.mjs 改为「卡片上**不出现**耗时而详情里**必须出现**耗时」的双向断言(B 组:卡片 line2 里不含 `\\d+ms` / `\\d+\\.\\ds` ✓;详情 DOM 里九个环节行各带耗时 ✓;`总耗时` = 九环节之和 ✓;复制全部文本含 `(…)` 与合计 ✓;九色与标签草稿同源断言保留 ✓)', '反向验证 ✓:旧快照(V9.76.12)上重跑新套件 → 「卡片不含耗时」「详情九行」「总耗时九环节之和」相关用例 FAIL ✓(恰好覆盖本次改动 ✓)', '回归与真机读数见 CHANGELOG.md 与 取证记录\\2026-10-05-耗时收进任务详情-V9.76.13.md ✓' ] }, { v: '9.76.12', d: '2026-10-05', items: [ '起因:用户 m11590(附两张截图:待发布列表 47 张卡片 + 「静默预览发布」框)—— ①「✅ 标签(0) 这个我人工编辑了标签 这里还是显示的0」②「查重✅ 7.7s 平台-网易云音乐✅ 1.2s 简介✅ 4.3s 曲目✅ 0.0s 封面✅ 0.0s 预设(批量预设)✅ 标签(0)✅ 种子✅ 频谱✅」「除了 查重✅ 7.7s 其他环节不显示消耗时间」③「这些环节文字字体颜色每个设置不一样的颜色,视觉上美观,按照你推荐的颜色就行」✓', '根因 A(标签计数)✗:卡片芯片的「标签(N)」只读 `t.result.tags.list.length`(src\\27 stepSummary),而用户在预览框里改的标签只写进**草稿**(`silentSetDraft` → `localStorage["ocd_helper_sdrafts"]`)—— 预览框自己是「草稿优先」、发布时也是「草稿优先」(src\\25 / src\\22)→ 卡片与真实发布内容**不一致** ✗:人工改了标签,卡片永远显示解析那一刻的旧值(0)✗', '根因 B(耗时)✗:`runStep`(src\\18)只在 查重/平台/简介/曲目/封面 五步写 `cur.ms`;**预设 / 标签 / 种子 / 频谱 四步从不写 ms** ✗;而且显示条件是 `t.result[k].ms ? …`(真值判断)→ 真的 0ms 会被隐藏 ✗(截图里第 2/3 张卡片的「曲目✅」没有时间就是这个原因)✗', '修法 A ✓:`stepSummary` 的「标签(N)」改成**草稿优先**(`silentGetDraft(t.id).tags` 非空则用它,否则回落 `result.tags.list`)—— 与预览框、与发布时用的那份**同一来源** ✓;另外预览框三处草稿保存点(💾 保存修改 / ⏸ 暂停 / 确认并发布)都会把当前标签**同步进 `result.tags`**(新增 `batchSyncDraftTags(id, list)` ✓ 保留原有 ms ✓),使卡片计数、src\\24 人工跳转发布的标签校验、批量打标撤销看到同一份 ✓', '修法 B ✓:① src\\18 给「预设」与「标签」两步量耗时(`Date.now()` 差值,随 `updateTask` 一起写入 `ms`)② `src\\22 shCheckTaskAssets` 分别对 种子匹配 / 频谱匹配 计时并返回 `seedMs` / `specMs` → src\\18 写进 `torrent.ms` / `spectro.ms`(命中会话缓存时≈0ms,也如实显示 0ms ✓)③ `sanitizeTask` 白名单补上这四步的 `ms`(**不写就会被 saveBatchTasks 丢掉** ✗)—— 取值用 `Number(x.ms) >= 0 ? Number(x.ms) : undefined`:旧任务没有该字段就**不显示**(不冒充 0ms ✓)', '修法 C ✓:`stepSummary` 每个环节一个独立颜色(内联 span ✓ 不受外层 `color:var(--fg-muted)` 影响 ✓):查重 紫 #c58bff · 平台 蓝 #5b8cff · 简介 绿 #3fd68f · 曲目 青 #00c6ff · 封面 橙 #ffb020 · 预设 黄 #ffd54a · 标签 粉 #ff8fb1 · 种子 浅蓝 #7dd8ff · 频谱 黄绿 #9ae66e ✓;耗时用小字、同色、半透明写在环节名后 ✓', '耗时显示规则 ✓:`typeof ms === "number"` 才显示(**0 也显示** ✓);`ms < 1000` 显示 `Nms`(`40ms` / `0ms`),否则 `X.Ys`(`7.7s` / `12.0s`)—— 与用户截图里 `7.7s` 的写法一致 ✓', '只动显示与计时 ✓:解析 / 查重 / 发布 / 门控逻辑一行未改 ✓;`ms` 只是任务卡上的读数,不参与任何判定 ✓', '测试 ✓:新增 tests\\36-batch-card-chips.test.mjs(源码守卫 + 行为:卡片「标签(2)」取自 result · 写入草稿 4 个标签后卡片显示「标签(4)」(草稿优先 ✓)· `ms` 为 7700 / 40 / 0 三段同屏显示 `7.7s` / `40ms` / `0ms` ✓ · 九个环节的颜色 span 都在 ✓)', '反向验证 ✓:`$env:OCD_SCRIPT = "备份\\OpenCD 工具箱 脚本插件.V9.76.11.bak.txt"` 后重跑新套件 → 旧快照上「草稿标签计数」「0ms 也要显示」「四步耗时」「颜色」相关用例 FAIL ✓(恰好覆盖本次改动 ✓)', '回归与真机读数见 CHANGELOG.md 与 取证记录\\2026-10-05-批量卡片耗时与标签计数-V9.76.12.md ✓' ] }, { v: '9.76.11', d: '2026-10-04', items: [ '起因:用户 m10870 —— 「模块生态管理中 我可否加入一个 点击模块 跳转网站的模块 / 模块名:音乐专辑WEB资源求助 / 跳转链接:https://vcn7i4salsbf.feishu.cn/share/base/form/shrcnKTK5oiGRUatGaJwafhyUug / 在菜单按钮中点击 模块后跳转到这个链接」✓ 方案取用户选择(m10885–m10886):① 入口 = 悬浮球菜单里单独一组「🔗 模块链接」✓ ② 先只做能力,模块 JSON 由用户自己走面板「✅ 校验并导入」(不由脚本替他装 ✓)', '改前形态 ✗:模块只能给已有数据表补条目(标签别名 / 标签→站点类别 / 备注模板),**没有任何"功能型"贡献**;唯一和菜单有关的是宿主自己的「🧩 第三方模块(N)」入口 ✓;而规范明令禁止模块自己加菜单 / 命令 / 面板 ✗ —— 所以"让模块声明一个跳转按钮"必须先有宿主侧的渲染能力,不能放开模块执行权 ✗', '改动核心 ✓:contributes 新增第 4 类 **links**(`[{ label, url, when? }]`,≤20 条 · label ≤24 · url ≤300)—— 模块仍然只声明**纯数据**(名称 + 网址),渲染与打开全由宿主做 ✓ 模块零执行权 ✓', '校验器 ✓(src\\62b-模块生态.js):新增 `ecomodLinkCheck(u)`(只放行 `https://` 且有主机名 → 其余 `E_SCHEME`;`javascript:` / `data:` / `file:` 一律 `E_SCHEME`;空格 / 引号 / 尖括号 / 反斜杠 → `E_URL`;空 → `E_REQUIRED`;超长 → `E_LEN`)· links 逐项白名单只允许 `label | url | when`(多一个键 → `E_UNKNOWN_KEY` ✓ 模块不能自带 icon / target ✗)· 复用原有 `readWhen` 做门控校验(when.key 必须是本模块声明过的参数 ✓)· `emptyWork` 计入 links(只声明一条链接不再被当成"什么都没贡献"的 W_EMPTY ✓)', '宿主渲染 ✓:悬浮球菜单在「🧩 第三方模块(N)」之后、「🧩 模块生态管理」分组之前新增一组 **「🔗 模块链接(N)」**(`src\\31-壳层:模块注册表-+-悬浮球-+-宿主.js`)—— 每行是一个按钮(🔗 + 模块声明的名称 + 来源模块名 badge),点了收起菜单并新标签打开 ✓ 数据源是 `ecomodLinkItems()`:**跳过官方项 / 跳过已停用模块 / 跳过 when 门控未满足的链接 / 跳过网址不合法(旧数据兜底)** ✓;行点击不与官方模块行冲突(绑定前 `data-eco-link` 行直接 return ✓)', '打开方式 ✓ `ecomodOpenLink(url)`:先 `window.open(url, _blank, noopener)`,被拦则回落 `GM_openInTab(url, { active: true })`,再兜底普通 `window.open` ✓ 打开前再体检一次(http / javascript 直接拒绝并 toast ✗ 不会把危险协议塞给浏览器 ✓);并写一条 `ocdAudit("settings", "模块链接:打开 …")` ✓', '面板 ✓:模块行内多出 🔗 按钮(门控未满足时半透明并标注「(未生效)」)· 📦 模块顺序表上方新增「🔗 模块链接(声明 N · 菜单里显示 M)」区(逐条显示 label / 来源模块 / url / 未生效或已停用原因 + 新标签打开按钮)· 概览 summary 多一段「N 条模块链接」· 原来那句「第三方模块不会作为一行出现在悬浮球菜单里」已改写为「宿主级入口有两个:🧩 第三方模块(N)回到面板 · 🔗 模块链接(N)新标签打开」✓', '顺手改的话术(同一事实来源 ✓ 不会两处漂移 ✗):ECOMOD_SCHEMA.forbidden 第 1 条从「不得包含任何 … URL」改为「…;网址只允许出现在 contributes.links 里(必须 https,宿主只把它当数据渲染成菜单按钮)」✓ · AI 提示词加「示例 4(悬浮球菜单里的跳转链接)」+ 自检项改写 ✓ · 《模块规范-给作者与AI的一页说明.md》§0/§1/§2/§9/§10/§11/§13 同步(已 `node tools\\ecomod-spec-doc.mjs` 重生成 ✓)· 示例库加第 4 个示例(可直接「填入并预览」→ 导入验证 ✓)', '安全边界(写清楚 ✓):**模块依然一行代码都不能执行** ✓ 网址在宿主侧先体检再打开 ✓ 只允许 https ✓ 不联网校验、不请求、不读页面 ✓ links 不写入任何数据表 → 不参与 `ecomodTotalItems` / 冲突检测(`ecomodContribKeys` 不动 ✓)→ 不影响既有护栏 ✓', '__ocd 新增 4 个句柄 ✓:`ecomodLinkItems()` · `ecomodLinksOf(id)` · `ecomodLinkCheck(url)` · `ecomodOpenLink(url)`(`ecomodStatus()` 同时带上 `linkCount` / `links` ✓ 便于探针与测试)', '真机抓到的体验缺口(顺手补 ✓):只声明链接的模块,导入结果只写「生效 0 项」✗、预览清单写「共 0 条」✗ —— 用户会以为什么都没发生 ✗。修法:`ecomodPreview` 把每条 links 也列进预览清单(`菜单链接「名称」→ https://…` + 落点说明「悬浮球菜单 →「🔗 模块链接」组」,门控未满足则标 ⏸)✓;`ecomodImport` 返回值加 `links` 数 ✓;面板导入结果与审计日志改成「… · 菜单链接 N 个」✓', '测试 ✓:tests\\32-ecomod.test.mjs 新增 L 组 18 条(校验器 7 条 · 数据源与门控 / 停用 / 卸载 5 条 · 网址体检与打开 2 条 · 源码守卫 1 条 · 规范与提示词 1 条 · 示例库 1 条 · 预览清单 1 条)→ **198/198 PASS** ✓;顺手同步两条被本次改动影响的旧断言(C3 三个完整示例 → 四个 · J14a 3 个示例 → 4 个 ✓)', '回归与真机读数见 CHANGELOG.md 与 取证记录\\2026-10-04-模块链接入口-V9.76.11.md ✓' ] }, { v: '9.76.10', d: '2026-10-04', items: [ '起因:用户 m10340「🏷️ 自动标签设置 移动到 ⚡ 快捷设置(高频项)中」(附图 = ⚙️ 设置中心 v9.76.9 · 7 个分组)✓', '改前形态 ✗:它是左导航(#ocd-sc-nav)顶部 contactRow 里的一颗独立按钮 —— 高频入口 ✓ 但挂在导航里既没有归属分组 ✗ 也吃不到分组内的搜索 / 折叠 / 与「📌 固定」项一起排序 ✗', '改动 ✓:把入口从 contactRow **搬进** ⚡ 快捷设置(quickBox 那个 details)作为**第一项**(紧随 summary 之后)—— 照 🛠️ 工具箱维护那张卡片的范式:`#ocd-sc-autotag-card`(标题「🏷️ 自动标签设置」+ 一句说明「解析发种时按这里的规则自动打标签:流派 / 合并别名 / 发烧档位 / 数据源与置信度 / 标签→类别映射。」+ 渐变按钮)✓', '只搬位置 ✓:按钮 id 仍是 `#ocd-sc-autotag` ✓、点击仍然是 `openAutoTagSettings()`(再点一次收起 ✓)、按钮文案改成「🏷️ 打开自动标签设置」✓;左侧导航只去掉这一颗 ✗(💬 意见反馈 / ✨ 功能请求 仍在 ✓)', '测试 ✓:tests\\08-settings.test.mjs 新增 12 条(搬移 8 条 + 搜索框 4 条:type=search · autocomplete=off · 自动填充被忽略并清空 · 聚焦时清掉残留)→ **40/40 PASS** ✓;顺手给本套补上 `OCD_SCRIPT` 支持(与 tests\\31/32/34/35 同一做法 ✓ 它原先写死快照路径 ✗ 没法做反向验证 ✓)', '反向验证 ✓:`$env:OCD_SCRIPT = "备份\\OpenCD 工具箱 脚本插件.V9.76.9.bak.txt"` 后重跑本套 → **30/38**(8 条 FAIL 正好覆盖本次两类改动:搬移 4 条 + 搜索框 4 条)✓ 其中「自动填充的值被忽略并清空 | 值="Yuekui" · 0 / 7 显示」正是旧版缺陷现场 ✓', '真机抓到的真缺陷(顺手修 ✓):`#ocd-sc-q` 会被 Edge 自动填充成站点用户名(本机 = Yuekui)✗ —— 而且自动填充会触发一次 `input` ✓ → 被当成"用户在搜索" → 7 个分组全变 display:none ✗ 计数显示「0 / 7 个分组匹配」✗ 面板看起来是空的(用户会以为设置中心坏了 / 搬进来的入口不见了 ✗)', '修法 ✓(src\\45-设置中心.js):① 输入框改 `type="search" autocomplete="off" data-form-type="other"`(只能少挨一点 ✗ 真机实测 Edge 照样填 ✓)② 过滤只在"用户真的打了字"时生效 —— `qTyped` 由 keydown / paste / cut / compositionstart 置位(capture 阶段)✓ input 里 `if (!qTyped) { dropAutofill(); return; }` ✓ `dropAutofill()` 清空自动填充值并恢复全部分组 ✓ 另补 300 / 1200 / 2500ms 三次延时清 + focus 时清一次 ✓', '真机复验 ✓(V9.76.10 · Edge CDP 9222):修复前 `q.value = "Yuekui"` / `count = "0 / 7 个分组匹配"` / 7 个分组全 display:none(面板高度只剩 ~230px、卡片 w:0 h:0 ✗)→ 修复后 `inputValueOnOpen: ""` / `countOnOpen: "7 个分组"` ✓;真实鼠标点击 (1289,335) → clickLog = `BUTTON#ocd-sc-autotag`「🏷️ 打开自动标签设置」✓ → `#ocd-autotag-cfg` 打开 ✓ 设置中心仍在 ✓', '真机踩坑(留给以后):页面 `document.hidden=true`(窗口不在前台 / 屏幕锁定时)CDP 合成的鼠标事件不会投递给页面 ✗(表现="点了没反应"且 clickLog 为空 ✗)→ 先跑 `tools\\probes\\_tmp-win.mjs wake`(Page.setWebLifecycleState(active) + Emulation.setFocusEmulationEnabled(true) + Page.bringToFront)恢复 visible ✓ 再点 ✓;`tools\\edge-focus.ps1` 可把 Edge 窗口提到前台 ✓', '说明:纯位置搬迁 ✓ 不改任何行为与数据(自动标签的规则 / 别名表 / 面板内容一字未动 ✓)也不占新的「分组」位(卡片在 quickBox 内部 ✓ 分组数仍是 7 ✓)', '回归与真机读数见 CHANGELOG.md 与 取证记录\\2026-10-04-设置中心自动标签入口搬迁-V9.76.10.md ✓' ] }, { v: '9.76.9', d: '2026-10-04', items: [ '起因:用户对 V9.76.8 提的两条建议给了回复(m09657)—— ①「允许「强行突破」」= 把三个硬上限改成**建议上限** ✓ ②「件落在系统「下载」目录」= 维持现状不改 ✓(下载走浏览器原生 a[download] / Blob,落点由浏览器设置决定 ✓)', '改动核心:**护栏从"硬拦"降级为"建议值 + 二次确认"** ✓ —— 到顶不再直接拒绝 ✗,而是先把"超了什么"讲清楚,用户点「仍然导入」才写入 ✓(写入后照样可以卸载 / 否决,不会伤到已装模块 ✓)', '新增 ecomodCapsOf(value, st):只对"新装"算(已装的同 id 覆盖不算 ✗)→ 超 20 个第三方模块报 E_CAP_MODS ✓、贡献条目总量超 2000 报 E_CAP_ITEMS ✓;文案统一写「建议上限」✓(校验器 / 预览 / 概览 / 提示词共用一处 ✓ 不会两处漂移 ✓)', 'ecomodImport:caps 非空且未传 force → 返回 { ok:false, stage:"cap", needForce:true, caps, errors } ✓ **一个字都不写** ✓(写库入口仍然只有这一个 ✓);装了之后返回 overCap / caps ✓,实施记录里标「强行突破建议上限」✓', 'ecomodPackParse(text, opt):新增 opt.force ✓ —— 包内模块数超 50 记一条 E_CAP_PACK ✓ 但**清单照样完整算出来** ✓(不再早退 ✗),未带 force 时以 needForce 返回、带了就照常返回 overCap ✓(整包预览因此能先给用户看"包里到底有什么"✓)', 'ecomodImportPack:把 force 一路透传给 ecomodPackParse 与逐个 ecomodImport ✓;模块数上限同样先 needForce 再放行 ✓(stage 透传 pack/validate/cap ✓)', '界面(都在面板内就地做 ✓ 绝不另开弹窗 ✗):**#ocd-eco-forcebox** 就地确认框(标题「⚠ 已超出建议上限:仍然导入?」+ 逐条列出超出项 + 说明「上限是建议值…点「仍然导入」= 强行突破上限继续写入…」+ 按钮 #ocd-eco-force-yes「仍然导入」/ #ocd-eco-force-no「取消」)✓ 预览框(单模块与整包)提前打「⚠ 已超出建议上限」黄字提示 ✓ 概览护栏文案改成「护栏(建议值):…」并在超限后写「(⚠ 已超出建议上限:仍可继续用,导入时会先提醒、确认后即可强行突破)」✓', '⚠ 为什么确认框不 shConfirm:(V9.76.9 自己踩到的真坑 ✓)shConfirm / shUtilityModal 开头都会 `document.getElementById("ocd-util-pop").remove()` ✗,而**模块面板本身就是 ocd-util-pop** ✓ → 一弹确认框就把面板(含刚粘贴的 JSON 与结果行)顶掉 ✗ —— 与 V9.76.3「校验未通过别开弹窗」同一教训 ✓ 故改为面板内就地确认 ✓(M7a 断言面板仍在:panels === 1 ✓)', '顺手修掉一个真 bug ✓(M10 抓到):`__ocd.ecomodPackParse` 旧写法是 `(text) => ecomodPackParse(text)` ✗ —— 第二个参数 opt 被丢掉 ✓ → `__ocd.ecomodPackParse(pack, {force:true})` 永远走 needForce 分支 ✗(而 ecomodImportPack 内部直接调函数 ✓ 所以整包导入又是好的 ✓ 两条现象互相矛盾,正是这里 ✓)→ 改为 `(text, opt) => ecomodPackParse(text, opt || {})` ✓', '新增句柄:`__ocd.ecomodCaps(text)`(第 31 个 ecomod* 句柄 ✓ 只查询不写数据 ✓ 探针与界面同源 ✓)', '测试:tests\\32-ecomod.test.mjs 新增 M 组 15 条(M1 到顶拦下 needForce · M2 ecomodCaps · M3 force 写入第 21 个 · M4/M5 条目上限 2000 → 2500 · M6 卸载回基线 · M7a/M7b/M7c 真面板就地确认框 + 真点「仍然导入」/「取消」· M8 概览 · M9 预览提示 · M10 包内 51 个 · M11 整包 force 51 个 · M12 收尾 · M13 源码守卫 · M14 一页说明 · M15 句柄透传 opt)+ G1 从 30 个句柄扩到 31 个 → **180/180 PASS** ✓(曾被两处自己写错的测试卡住 ✗:M15 拿 src/62b 去找 src/63 的句柄定义 ✗、M7b/M7c 用只认数组的 has() 查对象键 ✗)', '测试环境教训 ✓(看着像代码 bug,其实是测试环境噪音 ✗):本套是"全新用户"环境 → src/49 会在 **9 秒**后弹「👋 欢迎使用」引导(shShowOnboarding → shUtilityModal → 又会 remove 掉 ocd-util-pop ✗)→ 跑到 M7 时正好被顶掉面板 ✗(M7b/M7c 误判两轮 ✗)→ 已在测试里预置「已看过本版本」标记(ocd_helper_seenver_v1 = VERSION)让引导计时器不排程 ✓ 并用 Element.remove 钩子抓到调用栈取证 ✓', '模块规范一页说明同步:第 11 节写明「上限是**建议值**,到顶会先提醒,点「仍然导入」即可强行突破(E_CAP_MODS / E_CAP_ITEMS / E_CAP_PACK)」✓(字段级上限仍是硬上限 ✓ 那是规范正确性,不是性能护栏 ✓)→ 5203 字符 / 142 行 ✓(K5 逐字符守卫生效 ✓)', '说明:本轮只把护栏的语气与流程从"拦"改成"提醒 + 确认" ✓ 上限数值一个没动 ✓ 也不放开任何能力边界(模块仍然不执行代码、不联网、不加菜单 ✓)', '真机复验(V9.76.9 · Edge CDP · open.cd)✓:装到 20 个后再点导入 → 预览框先给「⚠ 已超出建议上限:已经装了 20 个第三方模块,达到建议上限 20 个」且**一个字未写**(仍 20 ✓)→ 关掉预览再点导入 → 面板内出现黄框就地确认(`elementFromPoint` 命中 `#ocd-eco-force-yes` ✓ · `panels:1` ✓ · **没有 shConfirm 弹窗** ✓ `#ocd-confirm-mask` 不存在 ✓)→ 真实鼠标点「仍然导入」→ 模块数 21 ✓、别名 `cap21x1` 落地 ✓、结果行常驻「已导入 com.test.cap21(生效 1 项) · ⚠ 已超出建议上限(你点了「仍然导入」)」✓、概览变「已装第三方 21 / 20 …(⚠ 已超出建议上限:仍可继续用…)」✓ → 再贴第 22 个点「取消」→ 一个字节都不写(仍 21 ✓ 别名无 cap22 ✓)且结果行写「已取消导入(未写入任何数据)」✓ → 收尾:卸载 22 个测试模块 + `ecomodPruneOrder()` → installed 0 · 条目 0 · 别名表回基线 ✓', '回归与真机读数见 CHANGELOG.md 与 取证记录\\2026-10-04-模块软上限-强行突破-V9.76.9.md ✓' ] }, { v: '9.76.8', d: '2026-10-04', items: [ '起因:用户问「别人可以制作 类似 发种查重助手 这种我们的类似功能吗 可以执行任务 / 目前这个模块生态是干嘛来自,我有点记忆混乱了」(m09356)→ 我回答:**不能**,模块是纯数据(只允许 tagAlias / genreMap / noteTemplate 三类贡献 + 自己的参数),「发种查重助手」这类能力属于脚本本体的官方模块 ✓ → 随后用户说「1.2都做,同时使用手册中也详细结束这个模块生态的作用」(m09391,「结束」= 解释/介绍)', '新增:**模块规范 · 一页说明(可直接转发给别人或 AI)** —— 面板「📄 模块规范 · 一页说明」折叠块,可 📋 复制(#ocd-eco-spec-copy)或 ⬇ 下载 .md(#ocd-eco-spec-dl → 模块规范-给作者与AI的一页说明.md,走 Blob + URL.createObjectURL,零网络 ✓)', '规范一页说明(`ecomodSpecMarkdown()` · 13 节 · 5049 字符):0 一句话讲清(模块 = 一份 JSON 数据,宿主只做「填表」)/ 1 能力三件套表 / **2 不能做什么(不能执行代码、不能联网、不能读页面或账号、不能自己加菜单 / 命令 / 面板,也不能执行任何任务 —— 解析、查重、发布、批量、调度这类能力属于脚本本体;官方「发种查重助手」不可能用模块复刻)** / 3 顶层字段表 / 4 contributes 形状 / 5 when 门控 / 6 settings / 7 固定标签 id 表(27 个)/ 8 站点类别清单 / 9 最小示例 / 10 常见错误 / 11 上限 / 12 怎么交付给别人 / 13 提交前自检;字段表、标签表、类别表**全部从 ECOMOD_SCHEMA / ECOMOD_LIM / ecomodTagIds() / ecomodGenreOpts() 派生**(不会两处漂移 ✓)· 代码围栏用 ~~~(不破坏外层字符串 ✓)', '新增:AI 提示区(`ecomodPromptText`)开头插入「【模块是什么 / 不是什么(先看这一条,别越界)】」5 行边界说明,自检清单加一条「没有试图执行任务 / 联网 / 让宿主做字段表以外的事」✓(以前只说"不含代码",现在把"不能执行任何任务"写在最前面 ✓)', '文案收紧:概览护栏行 + AI 提示区标题都显式写出边界(「模块永远是纯数据:不执行代码、不联网、不能自己加菜单 / 命令 / 面板,也不能执行任何任务(解析 / 查重 / 发布这类能力只属于官方模块)」)', '新增句柄:`__ocd.ecomodSpecMarkdown()`(第 30 个 ecomod* 句柄 ✓ 可用于脚本化导出/校验 ✓)', '新工具:tools\\ecomod-spec-doc.mjs(把 ecomodSpecMarkdown() 导出成仓库里的 .md,支持 --check 逐行比对 ✓)→ 《模块规范-给作者与AI的一页说明.md》= 5049 字符 / 140 行 ✓', '测试:tests\\32-ecomod.test.mjs 新增 A17 / A18 源码守卫 + K 组 9 条行为断言(K1 长度 · K2 边界句 · K3 九节标题 · K4 标签表与类别表与提示词逐字一致 · K5 仓库 .md 与脚本输出逐字符相同 · K6 面板折叠块与两个按钮 · K7 点下载真的造出 a[download] 且文件名正确 · K8 概览护栏 · K9 AI 提示区标题)→ **163/163 PASS** ✓;反向验证(OCD_SCRIPT=备份\\OpenCD 工具箱 脚本插件.V9.76.7.bak.txt)→ **152 PASS / 11 FAIL**(A18 · K1~K9 · G1 正好覆盖本次新功能 ✓)', '界面:面板新增「📄 模块规范 · 一页说明(可直接转发给别人或 AI)」折叠块 + 「共 N 字」+ 复制 / 下载两个按钮;下载失败会提示改走复制 ✓', '说明:本轮只补"让外人(或 AI)一眼看懂模块能干什么、绝对不能干什么"的规范与文案,不改任何执行路径 ✓ 模块永远不执行代码这条边界没变 ✓' ] }, { v: '9.76.7', d: '2026-10-04', items: [ '修复:**种子列表「预览」弹窗里的「📝 简介」只显示纯文本 —— 站点原本的链接(FROM:Qobuz → Qobuz 专辑页)点不动** ✓(用户 m09091 原文:「网站种子列表注入的预览按钮中 简介 选项中 / FROM:Qobuz 这个是个超链接形式 预览访问时 也加入超链接 可点击跳转」+ 附图「📝 简介 · #201342」)', '改前取证(真机 ✓ 直接抓站点详情页原文 · id 201342):plugin_details.php 与 details.php 返回的是**同一页**(HTTP 200 · 34374 字节),其中 #divdescr 原文就是 `FROM:Qobuz
` ✓ —— **链接本来就有,是站点给的** ✓;问题出在我们的抽取:src/36 shParsePluginDetail 与 src/40 shParseTorrentInfoFromDetails 都走 shHtmlToTextKeepBr(只取 textContent)✗ → 弹窗里只剩「FROM:Qobuz」四个字 ✓ 点不动 ✗(与你的现象一致 ✓)', '改法 ①(解析时顺手留一份「带链接的简介 HTML」)✓:新增 shIntroHtmlOfBox(box)(src/40,紧挨 shSanitizeIntroHtml)→ 复用已有的富文本清洗(去 script/style/iframe/on* ✓、给 补 target=_blank + rel=noopener ✓、相对地址补成绝对 ✓)→ **只有真的含 `」的种子仍走纯文本 pre-wrap ✓(L20-L23 ✓);反向验证:把 OCD_SCRIPT 指向 V9.76.6 备份快照重跑 → **PASS 29 / FAIL 11**(L4/L5/L6/L7/L10/L11/L12/L13/L15/L16/L20 ✓ 正好是本次修复点 ✓)', '真机复验(你的浏览器 · V9.76.7 冻结态 ✓):torrents.php 注入 100 个「预览」按钮 ✓ → ① id 201218(Đinh Trang - Vol.1: Bến Xưa 2016)简介里第一条链接 = https://phunuvietnam.vn/sao-mai-dinh-trang-gay-bat-ngo-voi-ben-xua-2385720.htm ✓ target=_blank ✓;② 并发抓前 14 行详情找出 FROM 指向 qobuz.com 的种子 → id 201350「Nirvana - Bleach (Deluxe Edition) 1989」→ 简介弹窗显示 **FROM: Qobuz**(Qobuz 蓝色带下划线 ✓)· 链接 = https://www.qobuz.com/us-en/album/bleach-nirvana/tietg8e0jjdh8 · 文字 Qobuz · target=_blank · rel=noopener noreferrer ✓ → **用真实鼠标点这个链接 → 真的新开了 Qobuz 标签页**(标题「Bleach (Deluxe Edition), Nirvana - Qobuz」✓)→ 关标签页 ✓ 弹窗关干净 ✓(截图:取证记录\\种子简介-9.76.7-FROM链接可点.png ✓)', '闸门 ✓:node build.mjs(65 源文件 · 43146 行 · 2571758 字节)· node build.mjs --check「完全一致」· node --check exit 0 · node tests\\run-tests.mjs → **34 套测试全部通过** ✓;细节与真机读数见 取证记录\\2026-10-04-种子简介链接-V9.76.7.md ✓', ] }, { v: '9.76.6', d: '2026-10-04', items: [ '修复:**待发布列表里「人工复核」过的任务发不出去** ✓(用户 m08617 原文:「待发布列表中,人工复核的任务,无法发布,被自动跳过,如果是人工复核加入队列解析的查重状态应该改为:人工复核 这种应该允许发布」+「如果需要真实数据测试可调试我的浏览器页面来修复这个问题」)', '改前取证(源码 ✓):①「🔁 复核并重新解析(跳过查重)」与「↻ 重试选中」只把 state 置 dup-ok + dupSkipped=1 ✓ **完全没动 result.dup** ✗(src/18-批量发种.js batchRecheckNoDup / batchRetrySelected)→ 解析完成后任务进了待发布列表 ✓ 但 result.dup.st 还是 fail ✗;② 失败汇总表的「✅ 复核」虽然把 dup 置 st:ok ✓ 却把 state 置回 pending ✗ → 查重环节**又跑一遍** ✗,同一批命中再判一次「疑似」✗ 人工放行结论被冲掉 ✗;③ 发布闸门只看 dup.st 是不是 fail:src/24-人工跳转发布.js 拦「该任务查重疑似重复,禁止发布」✗、src/22-静默发布.js 静默/批量发布直接跳过 ✗ —— 于是这些任务**永远发不出去** ✗,卡片上还一直挂着旧结论「🟡疑似(全词)⁉️」✗', '改前取证(真机 ✓ 用你的浏览器复刻了一条同结构的任务:2026 LOTTE - Lotte WEB Qobuz [24Bit-44.1kHz] FLAC,dup={st:fail, level:suspect, levelTag:全词})→ 卡片文本 = 「⋯前往发布✅ 已解析 | 🟡疑似(全词)⁉️ 平台-Qobuz✅ 0.9s 标签(1)✅ 种子✅ 频谱✅」✓ 与你的截图完全一致 ✓;点「前往发布」→ 弹「提示 该任务查重疑似重复,禁止发布 知道了」✓ 且 state 停在 parsed ✓(**不是**进入 publishing ✓)—— 问题复现 ✓', '改法 ①(统一「人工复核」状态)✓:新增 dupMarkManual(x, note)(src/18)→ 把查重结论改写成 **dup.st = manual** ✓ 并把原 level / levelTag / hitIds / hits **留作痕迹** ✓(事后仍能看出当初命中的是「全词」还是「低置信兜底」✓)。为什么用 st 而不是新增字段:本文件 sanitizeTask 是**白名单式重建** ✗ 新字段会在每次保存时被静默丢弃 ✗(跟 holdManual 当初那个坑一样 ✗)→ 直接复用 st(6 字符 ≤ 8 ✓)最稳 ✓', '改法 ②(三个写入点都走同一套逻辑 ✓):新增 ocdBatchManualRelease(id)(src/27,含「把命中种子写进本地误判白名单」✓)→ 「🔁 复核并重新解析」✓、「↻ 重试选中」✓ 两条队列路径在 patch 里调 dupMarkManual ✓;待发布列表卡片上**新增「✅ 人工复核」按钮** ✓(查重仍挡发布时才出现 ✓ 点它 = 站内确认弹窗 → 放行 ✓ 白名单 +N ✓ toast 回显 ✓ **不重跑查重、不改解析结果** ✓)—— 你截图里那 29 条现在有了就地放行的入口 ✓;失败汇总表原有的「✅ 复核」也改成:置 **dup-ok**(不再回 pending ✓)→ 直接进解析 ✓ 不会再被查重环节冲掉 ✓', '改法 ③(发布闸门放行 manual ✓):新增 dupBlocksPublish(dup) / dupIsManual(dup)(src/18)✓,src/24 与 src/22 改成只拦 dup.st 为 fail 的情况 ✓ → 人工复核(manual)**允许发布** ✓(拦下时的提示也补了一句「已人工核实就在卡片点『✅ 人工复核』放行」✓);src/23 静默预览框的「🔍 查重」行新增「✅ 人工复核放行」状态 ✓(旧版会显示成「未检测」✗)', '改法 ④(状态显示与防回退 ✓):待发布列表芯片:dup.st 为 manual → 显示「**人工复核✅**」✓(不再显示旧「🟡疑似(...)⁉️」✗);batchDupTask 开头加守卫 ✓ —— 人工复核过的任务**不再重跑查重** ✗(否则同一批命中会再判一次「疑似」把放行结论冲掉 ✗),「↻ 重置待解析」后再跑也一样保留 ✓', '回归与真机复验 ✓:tests\\32-ecomod.test.mjs 之外新增 tests\\34-batch-manual.test.mjs(K 组:dupMarkManual 状态与痕迹保留 ✓、sanitizeTask 不丢 ✓、三个写入点源码守卫 ✓、dupBlocksPublish 语义 ✓、闸门源码守卫 ✓、芯片文案 ✓、按钮与 shConfirm 形状 ✓);真机复验读数见 CHANGELOG.md 与 取证记录\\2026-10-04-批量-人工复核放行-V9.76.6.md ✓', ] }, { v: '9.76.5', d: '2026-10-04', items: [ '第三方模块生态第二轮补充 ✓(用户 m08123 从我给的 8 条里勾定的范围:① 参数门控 · ② 来源徽标 + 否决 + 一致性检查 · ③ 导入前预览 · ④ 整包导出 / 导入 · ⑥ 护栏上限 · ⑦ 示例模块库 · ⑧ 设置中心概览;**⑤ 新增贡献类型本轮不做** ✗ —— 那会让受攻击面变大,等你需要再说 ✓)', '① **参数门控(when)** ✓:模块可以给**单条贡献**加条件 —— 规范里新增 when = {"key":"参数键","equals":true|"字符串"|数字} ✓(只允许这两个子键 ✓ key 必须是本模块 settings 里声明过的键 ✓ 否则导入被拒 E_REQUIRED / E_ENUM ✓;equals 类型与参数类型不符只给**警告** W_GATE_TYPE ✓ 不拦 ✓)。效果:条件不满足时这条只是「暂时不生效」✗(不是卸载 ✓ 也不是假装写进去了 ✓)—— 真机实测把开关一打开,被门控的别名自动生效 ✓ 一关掉自动收回 ✓ 不用卸载重装 ✓(ecomodSetValue / ecomodValuesReset 里都会跑一次 ecomodRegate ✓;收回时只把这一条窄化回滚 ✓ 不动这个模块已经生效的其它条目 ✓ 也不抢别人正在占用的条目 ✓)', '② **来源徽标 + 否决 + 一致性检查** ✓:面板新增「🧾 逐条管理(来源 · 现在生不生效 · 否决 / 恢复)」区 ✓,每条贡献都写清是哪个模块带来的、当前是「✓ 生效中 / ⏸ 门控未生效(并写明条件)/ 🚫 已否决 / ↩ 已让位给「谁」」✓,右侧一枚「否决 / 恢复」按钮 ✓(否决 = 这条以后不再写回 ✓ 模块照旧装着 ✓ 随时可恢复 ✓ 比直接去别名编辑器里删更可靠 —— 删了模块还会写回来 ✗)。模块顺序表里也给第三方行加了 `⏸ N` / `🚫 N` 计数 ✓。**别名编辑器**里同样给模块带来的别名打了 🧩 徽标 ✓(悬停写明出处与状态 ✓),手动删除按钮的提示也改成「若它来自第三方模块,模块还装着时会被重新写回 —— 要永久去掉请到「🧩 第三方模块生态」里否决它」✓。另有**一致性检查** ✓:能看出「模块以为自己写进去了、但表里其实没有」(例如你在别名编辑器里手动删过 ✓)/「类别映射被别人改过」/「登记里的拥有者已经卸载或停用」三类问题 ✓ 面板列出来点一次「🔧 一键修复」即可 ✓(修复会重新写回、清理失效登记、并把让位者唤醒 ✓)', '③ **导入前预览(两步式 · 默认开启)** ✓:粘贴或选文件后先出**只读**清单 ✓ —— 「共 N 条:新增 X / 表里已有 Y / 冲突 Z / 门控未生效 W」+ 逐条列出「+新增 / =表里已有 / ⚠ 有冲突(被谁占着)/ ⏸ 门控未生效」✓;覆盖同 id 模块时还给出**版本与贡献 diff** ✓(旧版本 → 新版本、新增哪些条、移除哪些条、参数有没有变 ✓);框里写明「这一步没有写任何东西;点确认才写入」✓ 点「✅ 确认写入」才真正落库 ✓ 点「取消」什么都不改 ✓(导入区有「写入前先看预览」复选框 ✓ 想直接写入也可以取消勾选 ✓)', '④ **整包导出 / 导入(换机迁移)** ✓:工具条新增「📦 导出全部(迁移用)」✓ 导出的包里是 {kind:"ocd-ecomod-pack", packVersion:1, mods:[{module:模块JSON, enabled, values, excluded}]} ✓ —— **参数值与否决记录一起带走** ✓。这个包可以直接贴回导入框 ✓(面板会自动识别成模块包并给出「共 N 个模块」的预览 ✓ 不会误走单模块流程 ✓);换机时模块、参数、否决记录一次全回来 ✓(非包 JSON 会被明确拒绝 E_KIND ✓)', '⑥ **护栏上限** ✓:最多 20 个第三方模块 ✓ 合计最多 2000 条贡献 ✓ 一个包最多 50 个模块 ✓ —— 超了会在导入前直接拦下并说明原因(E_CAP_MODS / E_CAP_ITEMS ✓),不是等到装一半才失败 ✗;面板顶部新增「📊 概览:已装第三方 X/20 · 贡献条目 Y/2000 · 生效中 N 个 · ⏸ 门控未生效 N 条 · 🚫 已否决 N 条」✓ 接近上限会变色提醒 ✓', '⑦ **示例模块库** ✓:面板里新增「📚 示例模块库」折叠区,3 个**真能用**的小模块 ✓ —— com.sample.lofi(氛围音乐别名 lo-fi / lofi / chillhop → 电子 ✓ 自带开关门控 ✓)· com.sample.zh-genre(中文流派别名:古典音乐 / 爵士乐 / 摇滚乐 / 新世纪音乐 ✓ 下拉总开关 ✓)· com.sample.note(试听备注模板 ✓)。每个都有「填入并预览」(先看清会发生什么 ✓)与「直接导入」✓', '⑧ **设置中心里的分组也一并升级** ✓:折叠标题改为「🧩 第三方模块生态(声明式模块 · AI 提示区 · 导入预览 · 门控与否决 · 排序与冲突)」✓ —— 概览、启停、参数、排序、导入预览、门控与否决、一致性检查都在同一个面板里 ✓ 不用两处来回找 ✓', '对外暴露 11 个新句柄 ✓(src/63-启动.js):__ocd.ecomodPreview / ecomodRegate / ecomodExclude / ecomodConsistency / ecomodConsistencyFix / ecomodTagSource / ecomodGenreSource / ecomodExportAll / ecomodPackParse / ecomodImportPack / ecomodSamples ✓ —— ecomod* 句柄从 18 个增至 **29 个** ✓(tests\\32 G1 已同步 ✓)', '规范与提示词同步 ✓(单一事实来源没破 ✓):ECOMOD_SCHEMA 的两条贡献 item 串都补了 when ✓、新增 whenDoc 说明 ✓、AI 提示词里新增一节「【单条贡献的 when 门控(可选 · V9.76.5 新增)】」✓、示例 1 直接演示 when ✓、自检清单加了一条「写了 when 的话:when.key 是本模块 settings 里声明过的键,when.equals 与参数类型匹配」✓', '回归 ✓:tests\\32-ecomod.test.mjs 新增 J 组 22 条(J-A1/J-A2 源码守卫 + J1 预览只读 · J2 预览写明门控 · J3 取消不写 · J4 确认后只落地不带门控的条目 · J5 开关打开自动生效 · J6 关掉自动收回且参数值保留 · J7 逐条管理区 · J8 否决收回 · J9 恢复写回 · J10 一致性检查报出「别名被手动删掉」· J11 一键修复写回 · J12a/b/c 整包导出→卸载→导回(参数一起回来)→ 非包被拒 · J13 整包贴进导入框自动识别 · J14a/b 示例库 3 条且「填入并预览」可用 · J15 状态计数与护栏上限 · J16 别名编辑器 🧩 徽标 · J17 护栏拦截码 · J18 收尾干净 ✓),并把 H2 / H4 / H9 改成两步式(选文件、拖拽同样先出预览再确认 ✓)→ 本套断言 126 → **149 条 PASS 149 / FAIL 0** ✓', '测试里发现并顺手补的一个小地方 ✓:条目 id 是**归一化**过的形式(ecomodNormKey:gatefree-style → tagAlias:gatefreestyle ✓ 与归属登记的键保持同一套 ✓),控制台 / 探针要按 id 操作时容易猜错 ✗ → 「否决 / 恢复」按钮的 title 现在直接写明条目 id 与用法 ✓(__ocd.ecomodExclude(模块 id, 条目 id, true/false) ✓)', ] }, { v: '9.76.4', d: '2026-10-04', items: [ '两处补齐(用户在 m07681 反馈「导入测试的空模块,在菜单界面没有看见,或者重新给个可交互的测试模块,不需要任何功能,只需要测试可点击,界面等」)✓:① 悬浮球菜单里给出**模块生态入口** ✓(原来菜单只列 getModuleRegistry() 的 9 个官方模块 ✗ 第三方模块确实「看不见」✗ —— 真机取证确认不是用户看漏 ✓ 菜单文本里 /第三方模块|模块生态/ 零命中 ✗);② 面板里补上**模块参数界面** ✓(模块自己声明的开关/输入框真的能点、能存 ✓)', '① 菜单入口 ✓(src/31-壳层:模块注册表-+-悬浮球-+-宿主.js):renderShellMenu 末尾新增一个分组「第三方模块(N)」+ 一行「🧩 模块生态管理」(badge 显示「N 个已装」/「未装」✓),点击即打开第三方模块生态面板 ✓(collapseShellMenu(fab) → openEcomodManager() ✓ 面板不可用则 toastSafe 提示 ✓)。注意这是**宿主级入口** ✓ 不是「模块自己加菜单」✗ —— 模块规范仍然禁止第三方新增面板 / 命令 / 菜单 ✓ 规范未放宽 ✓', '② 模块参数界面 ✓(src/62b-模块生态.js,面板里新增「⚙ 模块参数(模块自己声明的开关 / 输入框 —— 改一下就会立刻保存)」区):把模块 settings 声明的 5 种类型全部渲染成真控件 ✓ switch → 复选框 ✓ text → 单行输入 ✓ number → 数字输入 ✓ select → 下拉(options[].value/label ✓)✓ textarea → 多行 ✓;改动立刻入库 ✓ 并在该行右侧回显「✓ 已保存:<值>」(绿色 ✓ 失败则红字 + 原因 ✓ 同时弹一条 toast ✓);每个模块一枚「↺ 恢复默认」按钮 ✓ 写回模块声明的 default ✓;块尾说明「参数只存在你自己的浏览器里(键 ocd_helper_moduleeco_v1),不上传、不联网;模块停用后参数保留」✓', '新增 4 个函数 ✓:ecomodValueOf(record, key)(取值,缺值回落 spec.default)✓ ecomodValues(id)(返回 key→value 映射,模块不存在返回 null ✓)✓ ecomodSetValue(id, key, val)(switch 强制布尔 ✓ number 非有限数回落 default ✓ select 不在 options 里回落 default ✓ 其余 String(val).slice(0,500) ✓ 并写 ocdAudit 审计 ✓ 未安装/键不存在都返回 {ok:false, reason} ✓)✓ ecomodValuesReset(id)(清空后逐个写回 default ✓ 返回 {ok:true, n, values} ✓)✓;面板重绘前先把参数区一起重画 ✓(draw() 幂等 ✓)', '对外暴露 3 个句柄 ✓(src/63-启动.js):__ocd.ecomodValues / __ocd.ecomodSetValue / __ocd.ecomodValuesReset ✓ —— ecomod* 句柄从 15 个增至 18 个 ✓(tests\\32 G1 已同步 ✓)', '这补上了 V9.76.0 就写在文件头、却一直没实现的缺口 ✓:src/62b-模块生态.js 开头写着「全部规则来自一份数据 ECOMOD_SCHEMA → 同时派生 ①导入校验器 ②模块设置界面 ③AI 提示词」✗ 而实际上 ② 从未实现 ✗(settings 只在校验时归一化入库 ✓ 界面上只有列表摘要里一句「N 个参数」✗)→ 本轮把它补齐 ✓', '顺带修正面板里那句提示 ✓(V9.76.2 写的「第三方模块不会出现在悬浮球菜单里」仍然成立 ✓ 但现在补上了出口 ✗→✓):「…不过悬浮球菜单底部有一个「🧩 第三方模块(N)」入口,点它就能直接回到这个面板;装好的模块本身就在上面这张「模块顺序」表里,停用 / 卸载 / 导出也都在这里。」✓', '交付一个**可交互的测试模块** ✓ 新建 测试用交互模块.json(id test.interactive.module · 名称「交互测试模块」· v1.0.0):声明 5 个参数(开关 sw_on=true ✓ 数字 num_count=3 ✓ 单行文本 txt_note="hello" ✓ 下拉 sel_mode="b"(a/b/c)✓ 多行文本 area_memo="第一行" ✓),**不带任何 contributes** ✓ 不做任何实际功能 ✓ 只用来验证「面板能不能点、能不能存」✓', '真机取证 ✓(Edge 9222 · https://open.cd/ · V9.76.4 冻结态):菜单分组出现「第三方模块(1)」✓ 行「🧩 模块生态管理 · 1 个已装」✓ 点它(点内层 .ocd-sm-name 也能冒泡 ✓)→ 面板打开 ✓ 菜单收起 ✓;面板「⚙ 模块参数」区 5 个控件逐个改一轮 → 全部落库 ✓ {sw_on:false, num_count:7, txt_note:"真机点击测试", sel_mode:"c", area_memo:"第二行(真机)"} ✓ 回显「✓ 已保存:关 / 7 / 真机点击测试 / c」绿色 rgb(63,214,143) ✓ 控件数 5 ✓ 「↺ 恢复默认」在位 ✓ 截图 2 张留证 ✓(取证记录\\模块生态-9.76.4-菜单入口.png · 取证记录\\模块生态-9.76.4-模块参数界面.png ✓)', '回归 ✓:tests\\32-ecomod.test.mjs 新增 A14/A15/A16(源码守卫 + 运行时接口 ✓)与 I 组「模块参数界面」14 条(真实面板 DOM + 真实 change 事件 ✓ I1 参数区在位 ✓ I2 五种控件类型 ✓ I3 初值=default ✓ I4~I8 逐个改动落库 ✓ I9 回显含「已保存」✓ I10 恢复默认回写 ✓ I11 非法值兜底(number "abc"→3 ✓ select "z"→"b" ✓)✓ I12 未知键被拒 ✓ I13 卸载后 ecomodValues 返回 null 且参数区显示空态 ✓ I14 收尾干净 ✓)→ 本套断言 109 → **126 条 PASS 126 / FAIL 0** ✓ 全量 **32 套测试全部通过** ✓', '⚠ 真机还抓到一个**不属于脚本缺陷**的严重环境问题 ✓(已一并修掉并写进工具 ✓,这也解释了此前「菜单里看不到新功能」的一大半 ✗):tools\\tm-install.mjs 不带 --update 时走 #nav=new **新建**脚本 ✗ —— 我在 V9.76.1 / V9.76.2 / V9.76.3(两次)/ V9.76.4 每轮都用裸命令安装 ✗,于是 Tampermonkey 里累计留下 **6 份同名「OpenCD 工具箱」脚本且全部启用** ✗(9.76.1 / 9.76.2 / 9.76.3×3 / 9.76.4 ✓ 真机读 chrome.storage.local 的 !extdb.@meta# 记录取证 ✓ 另有 3 条在回收站 ✓)→ 6 份脚本同时注入同一个页面 ✗ 悬浮球菜单等 UI 由**最旧**那份(9.76.1)渲染 ✓ 而 window.__ocd 全局被**最后**执行的那份(9.76.4)覆盖 ✓ → 页面里出现「混体」✗(探针实测:menuVer="v9.76.1" 而 __ocd.version()="9.76.4" ✗ 菜单里也没有任何新入口 ✗)', '清理与硬化 ✓:① 用 TM 自己的删除通道清掉多余的 5 份 ✓(chrome.runtime.sendMessage({method:"purgeScripts", uuids:[…]}) ✓ 读扩展源码 extension.js 反查得到的方法名 ✓ 不是去猜存储结构 ✓)→ 清理后只剩 1 份 9.76.4 ✓;② tools\\tm-install.mjs 新增「查重与清理」步骤(安装前读 TM 存储统计同名未删除脚本:0 份 → 新建 ✓ 1 份 → **自动改走覆盖更新** ✓ ≥2 份 → 先 purgeScripts 删掉多余的(保留最新一份)再覆盖更新 ✓)✓ 并把装后校验从「读仪表盘 DOM 数行数」✗(实测那个列表经常不渲染 ✗ 只有 filler + <新建用户脚本> ✗)改为**读 TM 存储**断言「同名未删除脚本恰好 1 份且版本号 = 源文件 VERSION」✓;真机复跑工具(**故意不带 --update** ✓ 正是出问题的那条命令)→ 日志「查重结果 {live:1, kept:4fa24fe9@9.76.4} → 自动改走覆盖更新 ✓」→「安装后回读 {n:1, vers:["9.76.4"]} ✓」✓ 再也不会有第二份 ✗', '清理后真机复验 ✓:重新加载 open.cd 后菜单标题显示 **v9.76.4** ✓(清理前是 v9.76.1 ✗)分组「当前页面(6)· 其他页面(2)· 第三方模块(1)」✓ 点击入口打开面板 ✓ 稳定复现 ✓(同一页面二次开合菜单结果一致 ✓)', ] }, { v: '9.76.3', d: '2026-10-04', items: [ '新增「用文件导入模块」✓(用户在 m07257 问「可否采用导入文件的形式来导入第三方模块」):模块面板的导入区现在支持两种方式 —— ① 粘贴 JSON(原有 ✓)② 点「📁 选择 .json 文件…」选本机文件 ✓ ③ 把 .json 文件直接拖进导入区 ✓', '实现 ✓:src/62b-模块生态.js 导入区新增隐藏 + 「📁 选择 .json 文件…」按钮(ocd-eco-filebtn)✓ 导入区容器加 id="ocd-eco-drop" 并挂 dragover / dragleave / drop ✓', '读文件只在本机 ✓:用 FileReader.readAsText 读成文本 → 先填进原文本框 → 再调用同一条导入流程(校验 → 有冲突弹窗裁定 → 写入)✓ 所以文件导入与粘贴走的是完全相同的校验与冲突裁决路径 ✓ 不新增任何旁路 ✗ 全程不发网络请求 ✓(模块规范也禁止模块联网 ✓)', '两道防护 ✓:文件 > 256 KB(262144 字节)直接拒绝并提示「模块 JSON 一般不超过 256 KB,请确认没选错文件」✓;FileReader onerror → 「读取文件失败:<文件名>」✓;拖入的不是文件 → 「没有读到文件(把 .json 文件拖进来即可)」✓', '结果区提示 ✓:读入成功后先显示「已读入 <文件名>(<字节数> 字节)→ 开始校验…」✓ 校验/写入的结果仍常驻显示在同一个结果区 ✓(承接 V9.76.2 的「结果不被重绘清空」修复 ✓)', '导入区文案改为「📥 导入模块 JSON(粘贴内容,或选择 / 拖入 .json 文件 —— 先校验、再预览、最后才写入)」+ 一行说明「文件只在你本机读取(不上传、不联网);读到的文字会先填进上面的框,再走同一条流程:校验 → 有冲突弹窗裁定 → 写入。也可以把 .json 文件直接拖到这一块。」✓', '同一文件可重复选择 ✓(change 处理里读走 files[0] 后立刻 fileEl.value = "" 清空 ✓ 否则选同一个文件不会再触发 change ✗)', '顺带修掉一个连带的真缺陷 ✓(做文件导入时在 jsdom 回归里发现,真机同样成立):校验未通过时旧代码用 shUtilityModal 另开一个「❌ 模块校验未通过」弹窗 ✗,而 shUtilityModal 开头就会 remove 掉旧的 ocd-util-pop ✗ —— 模块面板本身就是 ocd-util-pop ✓ → 面板连同刚粘贴/刚选的文件内容一起被顶掉 ✗(用户关掉错误弹窗后回到页面,还得重开面板重选文件 ✗)。改为错误就地显示在面板内 ✓:新增 #ocd-eco-errbox(红框 + 可滚动错误列表 + 「📋 复制修复提示」按钮 ✓ 与旧弹窗入口等价 ✓),面板不关、文本框内容不丢 ✓(见 tests\\32 H11/H12 ✓)', '再顺带 ✓:draw() 重绘前先把导入框内容接过来、重绘后放回去 ✓(原来一次成功导入(会 draw() 重绘 ✓)之后框里的内容就没了 ✗;现在刚粘贴/刚选的文件一直看得见 ✓ 与 V9.76.2「结果不被重绘清空」同一思路 ✓)', '回归 ✓:tests\\32-ecomod.test.mjs 新增 A9~A11(源码守卫)与 H 组「文件导入」(真实面板 DOM + 真实 File / FileReader ✓)→ 本套断言 106 条 PASS 106 / FAIL 0 ✓(其中 H2 文件导入生效 ✓ H5 别名落库 ✓ H7/H8 超大文件被拒且不写入 ✓ H9 拖拽路径 ✓ H11/H12 校验被拒时错误就地显示、面板与文本框都还在 ✓ H13 点「复制修复提示」真的会弹提示 ✓ H10 收尾清干净 ✓)', '真机复验时又抓到一个真缺陷 ✓(V9.76.0 起一直存在):面板里四个复制按钮(📋 复制 AI 提示(含我的现状)/ 📋 仅规范 / 📋 复制修复提示 / 导出)都是走 ecomodCopy(text, msg) → 里面写的是 copyText(String(text||""), msg || "已复制") ✗,而 copyText(text, onDone) 的第二个参数是「复制成功后的回调函数」✗ —— 传字符串进去,回调里 onDone() 一执行就抛 TypeError ✓ 被外层 catch 吞掉 ✓ 结果是:剪贴板其实写进去了 ✓ 但界面上一点提示都没有 ✗(用户根本分不清到底复制成功没有 ✗)。真机取证:点「📋 复制修复提示」后 1.2 秒内轮询 DOM 完全没有出现任何 toast ✗(同一时刻其它地方复制都有 toast ✓)。改为传真正的回调 ✓ copyText(String(text||""), () => { try { toastSafe(msg || "已复制", "ok"); } catch (e) {} }) ✓ —— 真机复验:点按钮立刻弹出提示(文案 = 已复制 + 「规范 + 错误」修复提示)✓(tests\\32 新增 A12 源码守卫 + H13 真实点击弹窗断言 ✓)', '再往下查,第三层根因浮出水面 ✓(同一个真机取证里量到的):吐司样式的 CSS(.ocd-sil-toast{ position:fixed; top:20px; right:20px; … })原本是混在 injectBatchCss() 的「批量面板」样式块里的 ✗,而 injectBatchCss() 只在批量发种面板构建时才被调用 ✗ —— 结果是:只要这个页面没打开过批量面板(例如 open.cd 首页 ✓),toast 元素就完全没有样式 ✓ 于是它按 position:static 被排到文档最底部 ✗。真机取证(V9.76.3):首页点「📋 复制 AI 提示」后确实创建了 toast 元素 ✓ 但 getBoundingClientRect() = {x:8,y:2703,w:1447,h:16} ✗(视口只有 875 高 ✗)computeStyle position=static ✗,页面上没有 '; container.appendChild(box); const main = document.getElementById('ocd-helper-panel'); if (main) { const cls = Array.from(main.classList).find(c => c.indexOf('ocd-theme-') === 0); if (cls) box.classList.add(cls); } applyOpenAnim(box); box.querySelector('#ps_delay').value = String(probeCfgGet('dlDelay', PROBE_CFG.dlDelay)); box.querySelector('#ps_method').value = probeCfgGet('dlMethod', PROBE_CFG.dlMethod); box.querySelector('#ps_sort').value = PROBE_DB.sortKey; box.querySelector('#ps_dens').textContent = PROBE_DB.compact ? '舒适' : '紧凑'; const $ = id => box.querySelector(id); makeEdgeResize(box, { min: 520, max: 900, def: 600, key: 'ocd_helper_probe_w_v1', getPanel: () => box }); { const head = $('#ps_head'); makeDraggable(head, 'ocd_probe_pos_v1', box); head.addEventListener('dblclick', (e) => { if (e.target.closest('button')) return; $('#ps_collapse').click(); }); } let collapsed = probeCfgGet('collapsed', false); const applyCollapse = () => { const bodyParts = [$('#ps_scan').parentElement, $('#ps_scan').parentElement.nextElementSibling, $('#ps_list'), box.lastElementChild]; shBulkSetCollapsed(bodyParts, collapsed); $('#ps_collapse').textContent = collapsed ? '▢' : '—'; const t = $('#ps_total'); if (t) t.textContent = collapsed ? '· ' + PROBE_DB.items.length + ' 条' : ''; probeCfgSet('collapsed', collapsed); }; $('#ps_collapse').onclick = () => { collapsed = !collapsed; applyCollapse(); }; $('#ps_close').onclick = () => closeProbeUI(); makePinBtn($('#ps_head'), box, '#ps_collapse'); $('#ps_scan').onclick = () => { PROBE_DB.items = probeScanPage(); probeCleanSel(); probeRender(); }; $('#ps_site').onclick = () => probeSiteScan(); const siteCfgPrompt = () => { const perPage = PROBE_DB.lastListPageCount || 0; const perHint = perPage ? ('每页 ' + perPage + ' 个种子(实测)') : '每页数量以站点设置为准'; const v = prompt('设置深度探测页数(' + perHint + ',1~20 页):\n当前配置将扫描约 ' + (perPage ? perPage * probeCfgGet('scanPages', CFG_SCAN_PAGES) : '?') + ' 个种子', String(probeCfgGet('scanPages', CFG_SCAN_PAGES))); if (v === null) return; const n = Math.max(1, Math.min(20, parseInt(v, 10) || 5)); probeCfgSet('scanPages', n); const sp = box.querySelector('#ps_sitepages'); if (sp) sp.textContent = String(n); toastSafe('🎯 深度探测页数已设为 ' + n + ' 页'); }; $('#ps_sitecfg').onclick = siteCfgPrompt; $('#ps_help').onclick = () => { let hw = document.getElementById('ocd-probe-help'); if (hw) { hw.remove(); return; } hw = document.createElement('div'); hw.id = 'ocd-probe-help'; hw.style.cssText = 'position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);z-index:10000060;width:min(600px,92vw);max-height:84vh;overflow-y:auto;background:var(--bg,#14161c);color:var(--fg,#e8eaf0);border:1px solid var(--border,#333);border-radius:12px;padding:16px 18px;box-shadow:0 20px 60px rgba(0,0,0,.55);font:13px/1.8 system-ui,-apple-system,"Microsoft YaHei",sans-serif;'; hw.innerHTML = '
' + '🎯 页面种子探测 · 使用说明' + '✕
' + '

基本用法

' + '
    ' + '
  • 任意 open.cd 页面点击「🎯 探测本页」扫描当前页的种子下载链接(自动去重、提取标题);
  • ' + '
  • 「🌍 深度探测」按设置的页数顺序抓取种子列表页聚合(点「⚙ 页数」设置页数;扫描中可中止/继续);
  • ' + '
  • 勾选条目后「⬇ 批量下载选中」——串行限速防风控,间隔与方式可在底部调整。
  • ' + '
' + '

⚠️ GM 下载提示 not_whitelisted 的解决办法

' + '

下载方式为「GM下载」时若失败并提示 not_whitelisted,是油猴的下载白名单未包含 .torrent,按以下步骤开启:

' + '
    ' + '
  1. 点击浏览器工具栏的 Tampermonkey 图标 → [控制面板/仪表盘];
  2. ' + '
  3. 顶部 [设置] 标签页;
  4. ' + '
  5. [通用] 区域 → [配置模式] → 选择 [高级];
  6. ' + '
  7. 下滑找到 下载 BETA 区域 → 文件扩展名白名单 输入框 → 换行添加 [.torrent];
  8. ' + '
  9. 点击 保存,回到本页面重新批量下载即可。
  10. ' + '
' + '

💡 不想配置也没关系:下载方式选「自动」时,GM 失败会自动改用 fetch 下载。

' + '

常见问题

' + '
    ' + '
  • 探测不到任何条目? 当前页面没有种子下载链接。前往 种子列表页 / 收藏页 再探测。
  • ' + '
  • 批量下载后浏览器下载栏没有文件? ① 看失败数与「重试失败」按钮;② fetch 方式首次批量需允许「下载多个文件」;② 换「iframe」方式再试。
  • ' + '
  • 如何重置「已下载」状态? 底部「清已下载」按钮。
  • ' + '
  • 暂停扫描/下载? 深度探测中点「⏹ 中止」,可「▶ 继续」断点续扫。
  • ' + '
  • 面板挡住页面内容? 按住标题栏拖动移位(自动记忆);点「—」收成细条。
  • ' + '
' + '
'; document.body.appendChild(hw); const main2 = document.getElementById('ocd-helper-panel'); if (main2) { const cls = Array.from(main2.classList).find(c => c.indexOf('ocd-theme-') === 0); if (cls) hw.classList.add(cls); } hw.querySelector('#ps-help-close').addEventListener('click', () => hw.remove()); hw.querySelector('#ps-help-ok').addEventListener('click', () => hw.remove()); hw.addEventListener('click', (e) => { if (e.target === hw) hw.remove(); }); }; $('#ps_site').oncontextmenu = (e) => { e.preventDefault(); siteCfgPrompt(); }; window.__probeShowCmds = (mode) => { const bx = document.getElementById('ocd-probe-panel'); if (!bx) return; bx.querySelector('#ps_stop').style.display = mode === 'running' ? '' : 'none'; bx.querySelector('#ps_resume').style.display = mode === 'paused' ? '' : 'none'; bx.querySelector('#ps_cancel').style.display = mode === 'paused' ? '' : 'none'; }; $('#ps_stop').onclick = () => { PROBE_DB.scanStop = true; window.__probeShowCmds('paused'); const siteBtn2 = $('#ps_site'); if (siteBtn2) siteBtn2.textContent = '🌍 深度探测(' + probeCfgGet('scanPages', CFG_SCAN_PAGES) + '页)(已暂停)'; toastSafe('⏹ 已请求停止:当前页完成后暂停', 'ok'); }; $('#ps_resume').onclick = () => { window.__probeShowCmds('running'); if (PROBE_DB.scanning && PROBE_DB.scanStop) { PROBE_DB.scanStop = false; toastSafe('▶ 继续扫描'); } else { probeSiteScan(); } }; $('#ps_cancel').onclick = () => { PROBE_DB.scanStop = true; PROBE_DB.scanning = false; PROBE_DB.lastPage = 0; const siteBtn2 = $('#ps_site'); if (siteBtn2) siteBtn2.textContent = '🌍 深度探测(' + probeCfgGet('scanPages', CFG_SCAN_PAGES) + '页)'; window.__probeShowCmds('idle'); toastSafe('已取消剩余扫描'); }; box.querySelectorAll('#ps_promos .ps-pf').forEach(lab => { lab.addEventListener('click', (e) => { e.preventDefault(); const sp = lab.querySelector('span'); const on = sp.dataset.on === '1'; sp.dataset.on = on ? '' : '1'; sp.style.background = !on ? sp.style.borderColor : 'transparent'; sp.style.color = !on ? '#fff' : sp.style.borderColor; applyPromoFilters(); }); }); function applyPromoFilters() { const actives = Array.from(box.querySelectorAll('#ps_promos .ps-pf span[data-on="1"]')) .map(sp => decodeURIComponent(sp.closest('.ps-pf').dataset.pf)); PROBE_DB.promoSet = actives; PROBE_DB.onlyFree = actives.indexOf('free') !== -1; probeRender(); } $('#ps_free').onchange = () => applyPromoFilters(); $('#ps_dl').onchange = e => { PROBE_DB.statusFilter = e.target.checked ? 'dl' : ''; probeRender(); }; $('#ps_kw').oninput = e => { PROBE_DB.kw = e.target.value.trim(); probeRender(); }; $('#ps_sort').onchange = e => { PROBE_DB.sortKey = e.target.value; probeRender(); }; $('#ps_dir').onclick = () => { PROBE_DB.sortDir = (PROBE_DB.sortDir === 'asc') ? 'desc' : 'asc'; $('#ps_dir').textContent = PROBE_DB.sortDir === 'asc' ? '升序' : '降序'; probeRender(); }; $('#ps_dens').onclick = () => { PROBE_DB.compact = !PROBE_DB.compact; probeCfgSet('compact', PROBE_DB.compact); $('#ps_dens').textContent = PROBE_DB.compact ? '舒适' : '紧凑'; probeRender(); }; $('#ps_hide').onchange = e => { PROBE_DB.hideDl = e.target.checked; probeRender(); }; $('#ps_all').onclick = () => { probeFiltered().forEach(x => PROBE_DB.selected.add(x.url)); probeRender(); }; $('#ps_none').onclick = () => { PROBE_DB.selected.clear(); probeRender(); }; $('#ps_inv').onclick = () => { probeFiltered().forEach(x => { if (PROBE_DB.selected.has(x.url)) PROBE_DB.selected.delete(x.url); else PROBE_DB.selected.add(x.url); }); probeRender(); }; $('#ps_delay').onchange = e => { probeCfgSet('dlDelay', Number(e.target.value)); }; $('#ps_method').onchange = e => { probeCfgSet('dlMethod', e.target.value); }; $('#ps_retry').onclick = () => { const urls = (PROBE_DB.failedUrls || []).slice(); if (!urls.length) return; PROBE_DB.failedUrls = []; $('#ps_retry').style.display = 'none'; probeDoDownload(urls); }; $('#ps_cledl').onclick = () => { if (!confirm('清除全部「已下载」标记?\n(列表中带删除线的条目将恢复为未下载显示;已下载的文件本身不受影响)')) return; try { localStorage.removeItem('ocd_probe_dl'); } catch (e) {} PROBE_DB.dlDone = probeDlGet(); probeRender(); toastSafe('🧹 已下载标记已清除', 'ok'); }; $('#ps_dlbtn').onclick = () => { const urls = probeFiltered().filter(x => PROBE_DB.selected.has(x.url)).map(x => x.url); if (!urls.length) { toastSafe('未选中任何条目', 'err'); return; } if (!confirm('确认批量下载选中的 ' + urls.length + ' 个种子?\n(串行间隔 ' + probeCfgGet('dlDelay', PROBE_CFG.dlDelay) + 'ms)')) return; probeDoDownload(urls); }; PROBE_DB.items = probeScanPage(); probeCleanSel(); probeRender(); applyCollapse(); focusPanel(box); } function closeProbeUI() { const el = document.getElementById('ocd-probe-panel'); if (el) el.remove(); if (_openModuleId === 'probedl') _openModuleId = null; } /* ================================================================ * §33 深度探测与下载引擎 * ================================================================ */ async function probeSiteScan() { if (PROBE_DB.scanning) return; const pages = Math.max(1, Math.min(20, probeCfgGet('scanPages', CFG_SCAN_PAGES))); let startPage; if (PROBE_DB.lastPage && PROBE_DB.lastPage < pages) startPage = PROBE_DB.lastPage; else startPage = 1; if (startPage >= pages) { toastSafe('✅ 所有页均已扫描完成', 'ok'); return; } PROBE_DB.scanning = true; PROBE_DB.scanStop = false; if (window.__probeShowCmds) window.__probeShowCmds('running'); const box = document.getElementById('ocd-probe-panel'); const siteBtn = box && box.querySelector('#ps_site'); const hint = box && box.querySelector('#ps_dlhint'); const seen = new Map(); PROBE_DB.items.forEach(x => { if (x.url) seen.set(x.url, x); }); let lastPage = startPage; for (let p = startPage; p < pages; p++) { if (PROBE_DB.scanStop) break; lastPage = p + 1; if (siteBtn) siteBtn.textContent = '⏳ ' + (p + 1) + '/' + pages + '页(跳过第1页)'; if (hint) hint.textContent = '深度探测中:第 ' + (p + 1) + '/' + pages + ' 页…' + (startPage === 1 && p === 1 ? '(第1页已由自动探测覆盖)' : ''); const html = await new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url: 'https://open.cd/torrents.php?page=' + p, timeout: advTimeoutMs('fetch', 15000), onload: r => resolve(r.status === 200 ? r.responseText : ''), onerror: () => resolve(''), ontimeout: () => resolve(''), }); }); if (!html) continue; const doc = new DOMParser().parseFromString(html, 'text/html'); doc.querySelectorAll('tr').forEach(tr => { const a = tr.querySelector('a[href*="download.php"]'); if (!a) return; const norm = probeNormUrl(a.getAttribute('href')); if (!norm || seen.has(norm)) return; seen.set(norm, { url: norm, title: probeExtractTitle(tr, norm), promo: probePromoType(tr), st: probeStatus(tr), sizeText: (function () { const td = Array.from(tr.querySelectorAll('td')).find(td => /(TB|GB|MB|KB)/i.test((td.textContent || '').replace(/\u00A0/g, ' ')) && (td.textContent || '').trim().length < 30); const m = td ? (td.textContent || '').match(/([\d.]+)\s*(?: |\s|\u00A0)*(TB|GB|MB|KB)/i) : null; return m ? (m[1] + ' ' + m[2].toUpperCase()) : ''; })(), promoUntil: (function () { if (!probePromoType(tr)) return ''; const td = tr.querySelector('td img[class*="pro_"]'); const sp = td && td.closest('td') && td.closest('td').querySelector('span[title]'); return sp ? (sp.getAttribute('title') || '').slice(0, 16) : ''; })(), page: 'P' + (p + 1), }); }); await new Promise(r => setTimeout(r, probeCfgGet('scanGap', 1500))); } PROBE_DB.items = Array.from(seen.values()); const stopped = PROBE_DB.scanStop; PROBE_DB.scanning = false; PROBE_DB.scanStop = false; if (stopped) { PROBE_DB.lastPage = lastPage; if (box) window.__probeShowCmds('paused'); if (hint) hint.textContent = '已中止(扫到第 ' + lastPage + ' 页)'; } else { PROBE_DB.lastPage = 0; if (siteBtn) siteBtn.textContent = '🌍 深度探测(' + pages + '页)'; if (box) window.__probeShowCmds('idle'); if (hint) hint.textContent = '深度探测完成:' + PROBE_DB.items.length + ' 条'; } probeCleanSel(); probeRender(); } function probeTorrentInnerName(bytes) { try { const pat = [0x34, 0x3a, 0x6e, 0x61, 0x6d, 0x65]; let idx = -1; outer: for (let i = 0; i < bytes.length - pat.length; i++) { for (let j = 0; j < pat.length; j++) { if (bytes[i + j] !== pat[j]) continue outer; } idx = i + pat.length; break; } if (idx === -1) return ''; let lenStr = ''; let k = idx; while (k < bytes.length && bytes[k] !== 0x3a && lenStr.length < 10) { lenStr += String.fromCharCode(bytes[k]); k++; } const len = parseInt(lenStr, 10); if (!len || len <= 0 || len > 500) return ''; k++; return new TextDecoder('utf-8').decode(bytes.slice(k, k + len)); } catch (e) { return ''; } } function probeSafeName(title, url) { let n = String(title || '').trim().replace(/[\\\/:*?"<>|\x00-\x1f]/g, '_').replace(/[.\s]+$/, ''); if (!n) n = 'torrent_' + ((url.match(/[?&]id=(\d+)/) || [])[1] || Date.now()); return n.slice(0, 180); } function probeDoDownload(urls) { const box = document.getElementById('ocd-probe-panel'); const hint = box && box.querySelector('#ps_dlhint'); const method = probeCfgGet('dlMethod', 'auto'); const delay = probeCfgGet('dlDelay', PROBE_CFG.dlDelay); PROBE_DB.failedUrls = []; const retryBtn = box && box.querySelector('#ps_retry'); if (retryBtn) retryBtn.style.display = 'none'; PROBE_DB.dlRunning = true; (async () => { let ok = 0, fail = 0; const urlTitle = {}; PROBE_DB.items.forEach(x => { urlTitle[x.url] = x.title; }); for (let i = 0; i < urls.length; i++) { const u = urls[i]; const uTitle = urlTitle[u] || ''; if (hint) hint.textContent = '下载中 ' + (i + 1) + '/' + urls.length + ' …'; const okOne = await probeDownloadOne(u, method, uTitle); if (okOne) { ok++; probeDlAdd(u); PROBE_DB.dlDone.add(u); } else { fail++; PROBE_DB.failedUrls.push(u); } if (box) { const tEl = box.querySelector('#ps_total'); if (tEl) tEl.textContent = '(' + PROBE_DB.items.length + ' 条)'; } await new Promise(r => setTimeout(r, delay)); } PROBE_DB.dlRunning = false; if (hint) hint.textContent = '完成:成功 ' + ok + ',失败 ' + fail + (fail ? '(可用重试按钮)' : ''); if (fail && retryBtn) { retryBtn.style.display = ''; retryBtn.textContent = '↻ 重试失败(' + fail + ')'; } probeRender(); if (box && !PROBE_DB.failedUrls.length) toastSafe('🎯 探测下载完成:' + ok + ' 个种子', 'ok'); })(); } function probeDownloadOne(url, method, title) { return new Promise((resolve) => { let m = method === 'auto' ? (typeof GM_download === 'function' ? 'gm' : 'iframe') : method; const fname = probeSafeName(title, url) + '.torrent'; console.log('[Probe] 下载:', url.slice(0, 70), '| 方式:', m, '| 文件名:', fname); const tryIframe = () => { try { const f = document.createElement('iframe'); f.style.display = 'none'; f.src = url; document.body.appendChild(f); setTimeout(() => f.remove(), PROBE_CFG.iframeCleanMs); resolve(true); } catch (e) { resolve(false); } }; if (m === 'gm') { fetch(url, { credentials: 'include' }).then(r => r.arrayBuffer()).then(buf => { const inner = probeTorrentInnerName(new Uint8Array(buf)); const gmName = probeSafeName(inner, url) + '.torrent'; GM_download({ url, name: gmName, onload: () => { console.log('[Probe] GM成功:', gmName); resolve(true); }, onerror: (e) => { const errType = (e && e.error) || e; console.error('[Probe] GM失败:', errType); if (errType === 'not_whitelisted' && method === 'auto') { if (!probeDownloadOne._warned) { probeDownloadOne._warned = true; toastSafe('ℹ️ GM下载未授权,已自动改用 fetch 保存(可在 Tampermonkey 高级设置开启下载模式)', 'err'); } const b = new Blob([buf]); const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = gmName; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 5000); resolve(true); return; } resolve(false); }, ontimeout: () => { console.error('[Probe] GM超时'); resolve(false); }, }); }).catch(err => { console.error('[Probe] GM预取异常:', err.message || err); resolve(false); }); return; } if (m === 'fetch') { try { fetch(url, { credentials: 'include' }).then(r => { if (!r.ok) { console.error('[Probe] fetch HTTP', r.status); return resolve(false); } return r.arrayBuffer().then(buf => { const inner = probeTorrentInnerName(new Uint8Array(buf)); const b = new Blob([buf]); const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = probeSafeName(inner, url) + '.torrent'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 5000); resolve(true); }); }).catch(err => { console.error('[Probe] fetch 异常:', err.message || err); resolve(false); }); } catch (err) { resolve(false); } return; } tryIframe(); }); } /* ================================================================ * §34 数据统计面板 * ================================================================ */ function dpFmtNum(n, dec) { const v = Number(n) || 0; return v.toLocaleString('en-US', { minimumFractionDigits: dec || 0, maximumFractionDigits: dec == null ? 2 : dec }); } function dpFmtSize(mb) { const v = Number(mb) || 0; if (v >= 1048576) return dpFmtNum(v / 1048576, 2) + ' TB'; if (v >= 1024) return dpFmtNum(v / 1024, 2) + ' GB'; if (v >= 1) return dpFmtNum(v, 2) + ' MB'; return dpFmtNum(v * 1024, 0) + ' KB'; } function dpFmtBig(n) { const v = Number(n) || 0; if (Math.abs(v) >= 100000000) return dpFmtNum(v / 100000000, 2) + '亿'; if (Math.abs(v) >= 10000) return dpFmtNum(v / 10000, 2) + '万'; return dpFmtNum(v, v % 1 === 0 ? 0 : 1); } function dpRollNumber(el, fromVal, toVal, fmtFn, endHtml) { if (!el) return; const start = performance.now(); const dur = 300; const step = (t) => { const p = Math.min(1, (t - start) / dur); const ease = 1 - Math.pow(1 - p, 3); const v = fromVal + (toVal - fromVal) * ease; el.textContent = fmtFn(v); if (p < 1) requestAnimationFrame(step); else if (endHtml) el.innerHTML = endHtml; else el.textContent = fmtFn(toVal); }; requestAnimationFrame(step); } const dpCollapse = { trend: true, earn: true, pub: true, health: true, snap: true }; function openDataPanelUI(container) { setTimeout(() => { try { shMountDataPanelTabs(); } catch (e) { console.warn('[DataPanel] 标签挂载失败:', (e && e.message) || e); } }, 0); if (document.getElementById('ocd-dp-panel')) return; const panel = document.createElement('div'); panel.id = 'ocd-dp-panel'; panel.style.cssText = [ 'position:fixed', 'top:0', 'right:0', 'height:auto', 'max-height:100vh', 'width:' + (Number(dpPref.get().panelWidth) || (dpPref.get().panelWide ? 560 : 400)) + 'px', 'max-width:96vw', 'z-index:9999990', 'display:flex', 'flex-direction:column', 'background:var(--bg,rgba(24,26,32,.97))', 'color:var(--fg,#e8eaf0)', 'border-left:1px solid var(--border,rgba(255,255,255,.10))', 'border-radius:12px 0 0 12px', 'box-shadow:-12px 0 40px rgba(0,0,0,.5)', 'font:13px/1.6 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif', 'overflow:hidden', 'animation:var(--dp-in, ocd-dp-slide) .3s ease-out' ].join(';'); panel.innerHTML = '
' + ' ' + ' 📊 数据统计面板 open.cd' + ' ' + ' ' + ' ' + ' ' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + ' ' + ' ' + ' ' + ' ' + '
'; container.appendChild(panel); const mainP = document.getElementById('ocd-helper-panel'); const themeCls = (mainP && Array.from(mainP.classList).find(c => c.indexOf('ocd-theme-') === 0)) || ('ocd-theme-' + store.get(THEME_KEY, 'dark')); panel.classList.add(themeCls); applyOpenAnim(panel); if (!document.getElementById('dp-slide-css')) { const css = document.createElement('style'); css.id = 'dp-slide-css'; css.textContent = '@keyframes ocd-dp-slide{from{transform:translateX(40px);opacity:0}to{transform:none;opacity:1}}' + '@keyframes dp-blink{0%,100%{opacity:1}50%{opacity:.3}}'; document.head.appendChild(css); } makeEdgeResize(panel, { min: 380, max: 720, def: dpPref.get().panelWide ? 560 : 400, key: '', getPanel: () => panel, applyVal: (w) => { panel.style.width = w + 'px'; dpPref.set({ panelWidth: w }); }, }); const savedW0 = Number(dpPref.get().panelWidth) || 0; if (savedW0 >= 380 && savedW0 <= 720) panel.style.width = savedW0 + 'px'; const $ = (id) => panel.querySelector('#' + id); panel.querySelector('#dp_head').style.cursor = 'move'; panel.querySelectorAll('#dp_head button').forEach(el => { el.style.cursor = 'pointer'; }); async function renderAll() { const snaps = await ocdDB.getAll('snapshots'); snaps.sort((a, b) => b.ts - a.ts); const cur = snaps[0] || null; const prev = snaps[1] || null; const now = Date.now(); const lt = $('dp_lasttime'); const light = $('dp_statlight'); if (cur) { const ageMin = Math.round((now - cur.ts) / 60000); lt.textContent = '上次采集 ' + new Date(cur.ts).toTimeString().slice(0, 5) + (ageMin > 120 ? '(已延迟 ' + Math.round(ageMin / 60) + ' 小时)' : ''); light.style.background = ageMin > 120 ? '#f59e0b' : 'var(--ok,#3fd68f)'; light.title = ageMin > 120 ? '采集延迟(超过 2 小时)' : '采集正常'; light.style.animation = ageMin > 120 ? '' : 'dp-blink 2s ease-in-out infinite'; const p0 = dpPref.get(); if (p0.autoCollect) { const nextTs = p0.lastCollectTs + p0.freqMin * 60 * 1000; lt.textContent += ' · 下次约 ' + new Date(Math.max(nextTs, now)).toTimeString().slice(0, 5); } } else { lt.textContent = '尚无快照'; light.style.background = '#f59e0b'; light.style.animation = ''; light.title = '尚未采集'; } const hero = $('dp_hero'); if (!cur) { hero.innerHTML = '
' + '
🌱 尚未开始记录
' + '
抓取第一份基准快照后开始记录数据变化
之后可开启自动采集(每小时),约 7 天后趋势完整
' + ' ' + '
'; const bb = hero.querySelector('#dp_bootstrap'); if (bb) bb.addEventListener('click', () => { $('dp_collect').click(); }); } else { if (!renderAll._incMode) renderAll._incMode = 'day'; const incMode = renderAll._incMode; const modeLabel = { day: '今日', week: '本周', month: '本月' }[incMode]; const today0 = new Date(now); today0.setHours(0, 0, 0, 0); const week0 = new Date(today0); week0.setDate(week0.getDate() - ((week0.getDay() + 6) % 7)); const month0 = new Date(now); month0.setDate(1); month0.setHours(0, 0, 0, 0); const modeCutoff = { day: today0.getTime(), week: week0.getTime(), month: month0.getTime() }[incMode]; const baseOf = (field) => { const cutoff = modeCutoff; let base = null; for (let i = snaps.length - 1; i >= 0; i--) { if (snaps[i].ts <= cutoff) { base = snaps[i]; break; } } return base ? base[field] : null; }; const mkHero = (icon, label, mainHtml, field, fmt) => '
' + '
' + icon + ' ' + label + '
' + '
' + mainHtml + '
' + '
' + (baseOf(field) == null ? '暂无对比' : (function () { const d = cur[field] - baseOf(field); if (Math.abs(d) < 1e-6) return '±0 ' + modeLabel + ''; const cls = d > 0 ? '#22c55e' : '#ef4444'; return '' + (d > 0 ? '+' : '') + fmt(d) + ' ' + modeLabel + ' ▲'; })()) + '
' + '
'; const twoTone = (main) => { const i = String(main).lastIndexOf('.'); if (i === -1) return esc(main); return esc(main.slice(0, i)) + '' + esc(main.slice(i)) + ''; }; hero.innerHTML = '
' + mkHero('💎', '魔力值', '' + twoTone(dpFmtBig(cur.magic)) + '', 'magic', (d) => dpFmtBig(d)) + mkHero('⬆', '上传量', '' + twoTone(dpFmtSize(cur.uploadedMB)) + '', 'uploadedMB', (d) => dpFmtSize(d)) + mkHero('🌱', '发布数', '' + twoTone(dpFmtBig(cur.published)) + ' 条', 'published', (d) => dpFmtNum(d, 0)) + '
'; hero.querySelectorAll('.dp-inc-badge').forEach(b => b.addEventListener('click', () => { renderAll._incMode = incMode === 'day' ? 'week' : incMode === 'week' ? 'month' : 'day'; renderAll(); })); } const sb = $('dp_statusbar'); if (cur) { const ratioColor = cur.ratio >= 1 ? 'var(--ok,#3fd68f)' : '#f59e0b'; const sep = '|'; sb.innerHTML = '分享率:' + esc(dpFmtNum(cur.ratio, 3)) + '' + sep + '下载量:' + esc(dpFmtSize(cur.downloadedMB)) + '' + sep + '做种/下载:⬆' + cur.seeding + ' / ⬇' + cur.leeching + '' + sep + '⚠ H&R:' + cur.hr + '' + sep + (cur.hourlyMagic > 0 ? '时魔:' + esc(dpFmtNum(cur.hourlyMagic, 1)) + '' + sep : '') + '本月:+' + esc(dpFmtSize(cur.uploadedMonthMB)) + ''; } else sb.innerHTML = '采集后显示 分享率/下载量/做种/H&R'; { const tw = document.getElementById('dp_trend'); if (tw) { tw.innerHTML = ''; buildTrendChart(tw); } } { const er = $('dp_earn'); er.innerHTML = ''; const earnWrap = document.createElement('div'); earnWrap.style.cssText = 'margin:0 0 10px;border:1px solid var(--border,#333);border-radius:9px;padding:8px 10px;'; earnWrap.innerHTML = '
' + ' ▾' + ' 🏆 收益榜' + ' ' + ' ' + '
' + '
暂无统计数据
' + '
'; er.appendChild(earnWrap); earnWrap.querySelector('#dp_earn_head').addEventListener('click', (e) => { if (e.target.closest('button')) return; const bodyEl2 = earnWrap.querySelector('#dp_earn_body'); dpCollapse.earn = !dpCollapse.earn; bodyEl2.style.display = dpCollapse.earn ? 'none' : ''; earnWrap.querySelector('#dp_earn_arrow').textContent = dpCollapse.earn ? '▸' : '▾'; }); if (dpCollapse.earn) { earnWrap.querySelector('#dp_earn_body').style.display = 'none'; earnWrap.querySelector('#dp_earn_arrow').textContent = '▸'; } earnWrap.addEventListener('click', (e) => { if (e.target.id === 'dp_earn_bootstrap') { const cb = earnWrap.querySelector('#dp_earn_collect'); if (cb) cb.click(); } }); earnWrap.querySelector('#dp_earn_collect').addEventListener('click', async () => { const btn = earnWrap.querySelector('#dp_earn_collect'); const done = btnLoading(btn, '⏳ 统计中…'); const bodyEl = earnWrap.querySelector('#dp_earn_body'); const waitStart = Date.now(); const waitTimer = setInterval(() => { const sec = Math.round((Date.now() - waitStart) / 1000); const dots = '…'.repeat(1 + (sec % 3)); bodyEl.innerHTML = '
' + '⏳ 正在从站点拉取发布列表' + dots + '
' + '已等待 ' + sec + ' 秒 · 全量数据较大,服务端生成需要时间
'; }, 1000); try { const rows = await dpCollectSeedEarnings((pg, n) => { bodyEl.textContent = '抓取第 ' + pg + ' 页… 已解析 ' + n + ' 条'; }); clearInterval(waitTimer); done('✅'); renderEarn(rows); toastSafe('✅ 收益统计完成:' + rows.length + ' 条', 'ok'); } catch (e) { clearInterval(waitTimer); done('❌ 失败'); bodyEl.textContent = '❌ ' + (e.message || '统计失败'); } }); ocdDB.getAll('metaLibrary').then(arr => { const seeds = arr.filter(x => x.id && String(x.id).indexOf('seed_') === 0); if (seeds.length) renderEarn(seeds); }); } { const pw = $('dp_pub'); pw.innerHTML = ''; const pubWrap = document.createElement('div'); pubWrap.style.cssText = 'border:1px solid var(--border,#333);border-radius:9px;padding:8px 10px;'; pubWrap.innerHTML = '
' + ' ▾' + ' 📦 发布管理' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + '
' + '
' + ' ' + ' ' + ' ' + '
' + '
数据来自收益统计
请先在上方收益榜点「⟳ 统计」
'; pw.appendChild(pubWrap); pubWrap.querySelector('#dp_pub_head').addEventListener('click', (e) => { if (e.target.closest('button') || e.target.closest('input')) return; dpCollapse.pub = !dpCollapse.pub; pubWrap.querySelector('#dp_pub_body').style.display = dpCollapse.pub ? 'none' : ''; pubWrap.querySelector('#dp_pub_arrow').textContent = dpCollapse.pub ? '▸' : '▾'; }); if (dpCollapse.pub) { pubWrap.querySelector('#dp_pub_body').style.display = 'none'; pubWrap.querySelector('#dp_pub_arrow').textContent = '▸'; } const pubBody = pubWrap.querySelector('#dp_pub_body'); const state = { view: 'all', kw: '', sortKey: 'uploadedMB', sortDir: 'desc' }; const renderList = async () => { const arr = (await ocdDB.getAll('metaLibrary')).filter(x => x.id && String(x.id).indexOf('seed_') === 0); if (!arr.length) return; const dir = state.sortDir === 'asc' ? 1 : -1; let list = arr.slice().sort((a, b) => ((a[state.sortKey] || 0) - (b[state.sortKey] || 0)) * dir); if (state.view === 'alive') list = list.filter(x => x.seeders > 0); if (state.view === 'dead') list = list.filter(x => x.seeders === 0); if (state.view === 'promo') list = list.filter(x => !!x.promo); if (state.kw) list = list.filter(x => x.title.toLowerCase().indexOf(state.kw) !== -1); const totalUp = list.reduce((s, x) => s + x.uploadedMB, 0); const SORT_CN = { uploadedMB: '上传', seeders: '做种人数', seedSec: '做种天数' }; const sortDirCN = state.sortDir === 'desc' ? '↓降序' : '↑升序'; let html = '
匹配 ' + list.length + ' 条 · 累计上传 ' + esc(dpFmtSize(totalUp)) + '' + ' · 按' + SORT_CN[state.sortKey] + sortDirCN + '
'; if (!list.length) { pubBody.innerHTML = html + '
当前筛选无结果
'; return; } html += list.slice(0, 200).map(x => { const alive = x.seeders > 0; const days = Math.max(1, Math.round(x.seedSec / 86400)); return '
' + '' + (alive ? '🟢' : '💀') + '' + (x.promo ? '' + esc(x.promo) + '' : '') + '' + esc(x.title) + '' + '' + esc(dpFmtSize(x.uploadedMB)) + '' + '|' + '' + x.seeders + ' 人' + '|' + '' + days + '天' + '
'; }).join(''); if (list.length > 200) html += '
共 ' + list.length + ' 条,仅显示前 200(用搜索缩小范围)
'; pubBody.innerHTML = html; }; pubWrap.querySelectorAll('.dp-ptab').forEach(b => b.addEventListener('click', () => { state.view = b.dataset.v; pubWrap.querySelectorAll('.dp-ptab').forEach(x => { x.style.borderColor = ''; x.style.color = ''; }); b.style.borderColor = 'var(--accent,#5b8cff)'; b.style.color = 'var(--accent,#5b8cff)'; renderList(); })); const t0p = pubWrap.querySelector('.dp-ptab[data-v="all"]'); if (t0p) { t0p.style.borderColor = 'var(--accent,#5b8cff)'; t0p.style.color = 'var(--accent,#5b8cff)'; } let kwTimer = null; pubWrap.querySelector('#dp_pub_kw').addEventListener('input', (e) => { clearTimeout(kwTimer); kwTimer = setTimeout(() => { state.kw = e.target.value.trim().toLowerCase(); renderList(); }, 250); }); pubWrap.querySelector('#dp_pub_sort').addEventListener('change', (e) => { state.sortKey = e.target.value; renderList(); }); pubWrap.querySelector('#dp_pub_dir').addEventListener('click', () => { state.sortDir = state.sortDir === 'desc' ? 'asc' : 'desc'; pubWrap.querySelector('#dp_pub_dir').textContent = state.sortDir === 'desc' ? '↓' : '↑'; renderList(); }); renderList(); } { const hw = $('dp_health'); if (!hw) return; hw.innerHTML = ''; const healthWrap = document.createElement('div'); healthWrap.style.cssText = 'margin:0 0 10px;border:1px solid var(--border,#333);border-radius:9px;padding:8px 10px;'; healthWrap.innerHTML = '
' + ' ▾' + ' 🩺 健康状态' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + '
' + '
基于收益统计判断:断种 = 您发布的种子 0 人做种;先在收益榜点「⟳ 统计」
'; hw.appendChild(healthWrap); healthWrap.querySelector('#dp_health_head').addEventListener('click', (e) => { if (e.target.closest('button')) return; dpCollapse.health = !dpCollapse.health; healthWrap.querySelector('#dp_health_body').style.display = dpCollapse.health ? 'none' : ''; healthWrap.querySelector('#dp_health_arrow').textContent = dpCollapse.health ? '▸' : '▾'; }); if (dpCollapse.health) { healthWrap.querySelector('#dp_health_body').style.display = 'none'; healthWrap.querySelector('#dp_health_arrow').textContent = '▸'; } const bodyEl = healthWrap.querySelector('#dp_health_body'); let view = 'dead'; ocdDB.getAll('snapshots').then(snaps => { const cur = snaps.sort((a, b) => b.ts - a.ts)[0]; if (cur && cur.hr > 0) { const tab = healthWrap.querySelector('.dp-htab[data-v="hr"]'); if (tab) { tab.style.borderColor = '#ef4444'; tab.style.color = '#ef4444'; } } }); const renderView = async () => { if (view === 'dead') { const arr = (await ocdDB.getAll('metaLibrary')).filter(x => x.id && String(x.id).indexOf('seed_') === 0); if (!arr.length) { bodyEl.textContent = '先在收益榜点「⟳ 统计」获取发布数据'; return; } const dead = arr.filter(x => x.seeders === 0).sort((a, b) => b.uploadedMB - a.uploadedMB); if (!dead.length) { bodyEl.innerHTML = '✅ 所有发布种子均有人做种,无断种风险'; return; } bodyEl.innerHTML = '
⚠ ' + dead.length + ' 颗发布种子已 0 人做种(按累计上传排序):
' + dead.slice(0, 20).map(x => '
' + '🪦 ' + esc(x.title.slice(0, 40)) + ' (曾上传 ' + esc(dpFmtSize(x.uploadedMB)) + ')
').join('') + (dead.length > 20 ? '
… 其余 ' + (dead.length - 20) + ' 颗略
' : ''); } else { const hrs = (await ocdDB.getAll('reqLedger')).filter(x => x.id && String(x.id).indexOf('hr_') === 0) .sort((a, b) => b.ts - a.ts); if (!hrs.length) { bodyEl.innerHTML = '✅ 无 H&R 记录(点「⟳ 扫描」核实)'; return; } bodyEl.innerHTML = '
⚠ ' + hrs.length + ' 颗种子处于 H&R 状态:
' + hrs.map(x => '
' + '⚠ ' + esc(x.title.slice(0, 40)) + ' (' + esc(dpFmtSize(x.sizeMB)) + ')
').join(''); } }; healthWrap.querySelectorAll('.dp-htab').forEach(b => b.addEventListener('click', () => { view = b.dataset.v; healthWrap.querySelectorAll('.dp-htab').forEach(x => { x.style.borderColor = ''; x.style.color = ''; }); b.style.borderColor = 'var(--accent,#5b8cff)'; b.style.color = 'var(--accent,#5b8cff)'; renderView(); })); const t0 = healthWrap.querySelector('.dp-htab[data-v="dead"]'); if (t0) { t0.style.borderColor = 'var(--accent,#5b8cff)'; t0.style.color = 'var(--accent,#5b8cff)'; } healthWrap.querySelector('#dp_health_scan').addEventListener('click', async () => { const btn = healthWrap.querySelector('#dp_health_scan'); const done = btnLoading(btn, '⏳ 扫描中…'); try { const hrs = await dpCollectHrList(); if (!hrs.length) await ocdDB.clearStore('reqLedger'); done('✅'); renderView(); toastSafe('✅ H&R 扫描完成' + (hrs.length ? '' : ':当前无 H&R'), 'ok'); } catch (e) { done('❌'); toastSafe('❌ 扫描失败:' + (e.message || '').slice(0, 50), 'err'); } }); renderView(); } function renderEarn(seeds) { const bodyEl = $('dp_earn_body'); if (!bodyEl) return; const rows = seeds.filter(x => x.uploadedMB > 0) .sort((a, b) => b.uploadedMB - a.uploadedMB).slice(0, 10); const maxD = rows.length ? rows[0].uploadedMB : 1; const totalUp = seeds.reduce((s, x) => s + x.uploadedMB, 0); let html = '
累计发布上传:' + esc(dpFmtSize(totalUp)) + '(' + seeds.length + ' 条参与统计)
'; html += rows.map((r, i) => { const barW = Math.max(2, Math.round(r.uploadedMB / maxD * 100)); const days = Math.round(r.seedSec / 86400); const hrs = Math.round(r.seedSec / 3600); const seedDur = days >= 1 ? (days + ' 天') : (hrs + ' 小时'); const tip = r.title + '\n累计上传:' + dpFmtSize(r.uploadedMB) + ' · 做种人数 ' + r.seeders + ' · 已做种 ' + seedDur; return '
' + '' + (i + 1) + '.' + '' + esc(r.title) + '' + '' + esc(dpFmtSize(r.uploadedMB)) + '' + '' + '' + '' + days + '天' + '
'; }).join(''); bodyEl.innerHTML = html; } const body = $('dp_body'); if (!snaps.length) { body.innerHTML = ''; } else { if (dpCollapse.snap === undefined) dpCollapse.snap = false; let rows = '
' + '' + (dpCollapse.snap ? '▸' : '▾') + ' 📜 快照明细(' + snaps.length + ' 条)' + ' 趋势图 / 收益榜 / 健康 Tab
'; rows += '
'; let lastDay = ''; rows += snaps.slice(0, 30).map(x => { const d = new Date(x.ts); const dayKey = (d.getMonth() + 1) + '月' + d.getDate() + '日'; let html = ''; if (dayKey !== lastDay) { lastDay = dayKey; html += '
📅 ' + dayKey + '
'; } const tstr = d.toTimeString().slice(0, 5); html += '
' + '' + tstr + '' + '💎 ' + esc(dpFmtBig(x.magic)) + '' + '⬆ ' + esc(dpFmtSize(x.uploadedMB)) + '' + '🌍 ' + x.seeding + '' + '' + '
'; return html; }).join(''); rows += '
'; body.innerHTML = rows; const snapHead = body.querySelector('#dp_snap_head'); if (snapHead) snapHead.addEventListener('click', () => { dpCollapse.snap = !dpCollapse.snap; const listEl = body.querySelector('#dp_snap_list'); if (listEl) listEl.style.display = dpCollapse.snap ? 'none' : ''; const arrow = body.querySelector('#dp_snap_arrow'); if (arrow) arrow.textContent = dpCollapse.snap ? '▸' : '▾'; }); } const info = $('dp_foot_info'); const cnt = snaps.length; const sizeKB = Math.round(JSON.stringify(snaps).length / 1024); info.textContent = cnt ? ('☁ ' + cnt + ' 条快照 · 约 ' + sizeKB + ' KB') : '☁ 无数据'; } function buildTrendChart(container) { let rangeDays = 7; const wrap = document.createElement('div'); wrap.style.cssText = 'margin:0 0 10px;border:1px solid var(--border,#333);border-radius:9px;padding:8px 10px;'; wrap.innerHTML = '
' + ' ▾' + ' 📈 趋势' + ' ' + ' ● 魔力' + ' ● 上传' + ' ● 发布' + ' ' + ' ' + '
' + '
'; container.appendChild(wrap); wrap.querySelector('#dp_trend_head').addEventListener('click', (e) => { if (e.target.closest('button')) return; const boxEl = wrap.querySelector('.dp-trend-box'); const collapsed = boxEl.style.display === 'none'; boxEl.style.display = collapsed ? '' : 'none'; dpCollapse.trend = collapsed; wrap.querySelector('#dp_trend_arrow').textContent = collapsed ? '▾' : '▸'; }); if (dpCollapse.trend) { wrap.querySelector('.dp-trend-box').style.display = 'none'; wrap.querySelector('#dp_trend_arrow').textContent = '▸'; } const box = wrap.querySelector('.dp-trend-box'); const draw = async () => { const _lim2 = Math.max(10, Math.min(5000, Number((advGroup('data') || {}).snapLimit) || 500)); const _pg2 = await ocdDB.getAllPaged('snapshots', { limit: _lim2 }); const snaps = (_pg2.rows || []).sort((a, b) => a.ts - b.ts); const cutoff = Date.now() - rangeDays * 86400 * 1000; const pts = snaps.filter(x => x.ts >= cutoff); if (pts.length < 2) { box.innerHTML = '
快照不足(需 ≥2 条),积累 ' + (2 - pts.length > 0 ? 2 - pts.length : 1) + ' 条后可绘制趋势
'; return; } const W = 340, H = 120, padL = 6, padB = 14, padT = 6; const iw = W - padL * 2, ih = H - padB - padT; const magics = pts.map(p => p.magic); const ups = pts.map(p => p.uploadedMB); const pubs = pts.map(p => p.published); const [mnM, mxM] = [Math.min(...magics), Math.max(...magics)]; const [mnU, mxU] = [Math.min(...ups), Math.max(...ups)]; const [mnP, mxP] = [Math.min(...pubs), Math.max(...pubs)]; const rng = (mn, mx) => (mx - mn) || 1; const xOf = (i) => padL + (pts.length === 1 ? iw / 2 : iw * i / (pts.length - 1)); const yM = (v) => padT + ih * (1 - (v - mnM) / rng(mnM, mxM)); const yU = (v) => padT + ih * (1 - (v - mnU) / rng(mnU, mxU)); const yP = (v) => padT + ih * (1 - (v - mnP) / rng(mnP, mxP)); const path = (vals, yf) => vals.map((v, i) => (i ? 'L' : 'M') + xOf(i).toFixed(1) + ',' + yf(v).toFixed(1)).join(' '); const tip = document.createElement('div'); tip.style.cssText = 'position:absolute;display:none;z-index:5;background:var(--bg,#242632);border:1px solid var(--border,#444);border-radius:7px;padding:5px 8px;font-size:10px;pointer-events:none;white-space:nowrap;box-shadow:0 4px 16px rgba(0,0,0,.4);'; let svg = ''; svg += ''; svg += ''; svg += ''; svg += ''; if (pts.length <= 40) { magics.forEach((v, i) => { svg += ''; }); } svg += '' + new Date(pts[0].ts).toDateString().slice(4, 10) + ''; svg += '' + new Date(pts[pts.length - 1].ts).toDateString().slice(4, 10) + ''; svg += ''; box.innerHTML = svg; box.appendChild(tip); box.onmousemove = (ev) => { const r = box.getBoundingClientRect(); const rx = (ev.clientX - r.left) / r.width; const i = Math.min(pts.length - 1, Math.max(0, Math.round(rx * (pts.length - 1)))); const p = pts[i]; tip.innerHTML = new Date(p.ts).toDateString().slice(4, 10) + '
' + '💎 ' + esc(dpFmtBig(p.magic)) + '
' + '⬆ ' + esc(dpFmtSize(p.uploadedMB)) + '
' + '🌱 ' + esc(dpFmtNum(p.published, 0)) + ' 条'; tip.style.display = 'block'; tip.style.left = Math.min(r.width - 110, Math.max(0, rx * r.width - 50)) + 'px'; tip.style.top = '4px'; }; box.onmouseleave = () => { tip.style.display = 'none'; }; }; wrap.querySelectorAll('.dp-tr').forEach(b => b.addEventListener('click', () => { rangeDays = Number(b.dataset.d) || 7; wrap.querySelectorAll('.dp-tr').forEach(x => { x.style.borderColor = ''; x.style.color = ''; }); b.style.borderColor = 'var(--accent,#5b8cff)'; b.style.color = 'var(--accent,#5b8cff)'; draw(); })); const b7 = wrap.querySelector('.dp-tr[data-d="7"]'); if (b7) { b7.style.borderColor = 'var(--accent,#5b8cff)'; b7.style.color = 'var(--accent,#5b8cff)'; } draw(); return { draw }; } const collectBtn = $('dp_collect'); collectBtn.addEventListener('click', async () => { const done = btnLoading(collectBtn, '⏳ 采集中…'); const oldSnaps = await ocdDB.getAll('snapshots'); const oldSnap = oldSnaps.sort((a, b) => b.ts - a.ts)[0] || null; try { const newSnap = await dpCollectSnapshot(); done('✅'); await renderAll(); if (oldSnap && newSnap) { const twoTone2 = (main) => { const i = String(main).lastIndexOf('.'); if (i === -1) return esc(main); return esc(main.slice(0, i)) + '' + esc(main.slice(i)) + ''; }; const pairs = [ { sel: '.dp-magic-num', from: oldSnap.magic, to: newSnap.magic, fmt: (v) => dpFmtBig(v), end: () => twoTone2(dpFmtBig(newSnap.magic)) }, { sel: '.dp-up-num', from: oldSnap.uploadedMB, to: newSnap.uploadedMB, fmt: (v) => dpFmtSize(v), end: () => twoTone2(dpFmtSize(newSnap.uploadedMB)) }, { sel: '.dp-pub-num', from: oldSnap.published, to: newSnap.published, fmt: (v) => dpFmtNum(v, 0), end: () => twoTone2(dpFmtNum(newSnap.published, 0)) + ' 条' }, ]; pairs.forEach(p2 => { if (Math.abs(p2.to - p2.from) > 1e-6) { const el = panel.querySelector(p2.sel); if (el) dpRollNumber(el, p2.from, p2.to, p2.fmt, p2.end()); } }); } } catch (e) { done('❌ 失败'); console.error('[DataPanel] 采集失败:', e.message || e); $('dp_statlight').style.background = '#ef4444'; toastSafe('❌ 采集失败:' + (e.message || '').slice(0, 60), 'err'); setTimeout(() => { $('dp_statlight').style.background = 'var(--ok,#3fd68f)'; }, 4000); } }); $('dp_body').addEventListener('click', async (e) => { const del = e.target.closest('.dp-snapdel'); if (!del) return; if (!confirm('删除这条快照?')) return; await ocdDB.del('snapshots', Number(del.dataset.ts)); renderAll(); }); $('dp_wide').addEventListener('click', () => { const p = dpPref.get(); dpPref.set({ panelWide: !p.panelWide, panelWidth: !p.panelWide ? 560 : 400 }); panel.style.width = (!p.panelWide ? 560 : 400) + 'px'; }); $('dp_close').addEventListener('click', () => closeDataPanelUI()); makePinBtn(panel.querySelector('#dp_head'), panel, '#dp_collect'); { const head = $('dp_head'); makeDraggable(head, 'ocd_dp_pos_v1', panel); let minimized = false, prevHeight = ''; head.addEventListener('dblclick', (e) => { if (e.target.closest('button')) return; const blocks = ['ocd-dp-tabs', 'ocd-dp-tabhost', 'dp_lasttime', 'dp_hero', 'dp_statusbar', 'dp_trend', 'dp_earn', 'dp_pub', 'dp_health', 'dp_body', 'dp_foot']; const blockEls = blocks.map(id => panel.querySelector('#' + id)).filter(Boolean); if (!minimized) { prevHeight = panel.style.height || ''; panel.style.height = 'auto'; shBulkSetCollapsed(blockEls, true); minimized = true; } else { panel.style.height = prevHeight; shBulkSetCollapsed(blockEls, false); minimized = false; } }); head.title = '拖动移动 · 双击最小化/还原'; } $('dp_export').addEventListener('click', async () => { await exportOcdData(); toastSafe('⬇ 数据已导出', 'ok'); }); $('dp_import').addEventListener('click', async () => { const mode = confirm('确定 = 合并导入(推荐)\n取消 = 覆盖导入(清空现有后写入)') ? 'merge' : 'cover'; const r = await importOcdData(mode); if (r.ok) { toastSafe('⬆ 导入完成:' + r.n + ' 条', 'ok'); renderAll(); } else toastSafe('❌ 导入失败:' + (r.err || '文件无效'), 'err'); }); $('dp_clean').addEventListener('click', async () => { if (!confirm('执行智能分层瘦身?\n\n策略:\n· 7 天内 → 全部保留(小时粒度)\n· 7~90 天 → 每天聚合(保留当天最后一条)\n· 90 天以上 → 每周聚合(保留当周最后一条)\n\n趋势图使用聚合数据不受影响。')) return; const snaps = (await ocdDB.getAll('snapshots')).sort((a, b) => a.ts - b.ts); const now = Date.now(); const dayMs = 86400 * 1000; const toDelete = []; const bucket = {}; for (const s of snaps) { const age = now - s.ts; const d = new Date(s.ts); let key; if (age < 7 * dayMs) { continue; } if (age < 90 * dayMs) { key = 'd_' + d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate(); } else { const yearStart = new Date(d.getFullYear(), 0, 1).getTime(); const weekNo = Math.floor((s.ts - yearStart) / (7 * dayMs)); key = 'w_' + d.getFullYear() + '_' + weekNo; } if (bucket[key]) toDelete.push(bucket[key]); bucket[key] = s; } const keepDays = Number(dpPref.get().snapKeepDays) || 90; const hardCutoff = now - keepDays * 2 * dayMs; for (const s of snaps) { if (s.ts < hardCutoff && toDelete.indexOf(s) === -1) toDelete.push(s); } let n = 0; for (const s of toDelete) { await ocdDB.del('snapshots', s.ts); n++; } toastSafe('🗑 智能瘦身完成:清理 ' + n + ' 条(保留 7 天内全量 + 90 天内天级 + 老数据周级)', 'ok'); renderAll(); }); panel.querySelector('#dp_gear').addEventListener('click', (e) => { e.stopPropagation(); let pop = document.getElementById('dp-set-pop'); if (pop) { pop.remove(); return; } pop = document.createElement('div'); pop.id = 'dp-set-pop'; pop.style.cssText = 'position:fixed;z-index:' + ocdTopZ(10) + ';width:280px;background:var(--bg,#242632);color:var(--fg,#e8eaf0);border:1px solid var(--accent,#5b8cff);border-radius:10px;padding:12px;font:12px/1.8 -apple-system,"Microsoft YaHei",sans-serif;box-shadow:0 12px 40px rgba(0,0,0,.5);'; const p = dpPref.get(); pop.innerHTML = '
⚙ 采集设置
' + '' + '
' + ' 采集频率:' + '
' + '
' + ' 快照保留:' + '
' + '
' + ' 用户 ID:' + ' ' + '
' + '
' + ' ' + ' ' + '
'; document.body.appendChild(pop); { const cls = Array.from(panel.classList).find(c => c.indexOf('ocd-theme-') === 0); if (cls) pop.classList.add(cls); } const pr = panel.getBoundingClientRect(); pop.style.visibility = 'hidden'; pop.style.display = 'block'; let px = pr.left - pop.offsetWidth - 12; if (px < 8) px = pr.left + 12; pop.style.left = px + 'px'; pop.style.top = Math.max(8, Math.min(pr.top + 50, window.innerHeight - pop.offsetHeight - 8)) + 'px'; pop.style.visibility = 'visible'; pop.querySelector('#dp-set-freq').value = String(p.freqMin); pop.querySelector('#dp-set-keep').value = String(p.snapKeepDays); pop.querySelector('#dp-set-cancel').addEventListener('click', () => pop.remove()); pop.querySelector('#dp-set-save').addEventListener('click', () => { dpPref.set({ autoCollect: pop.querySelector('#dp-set-auto').checked, freqMin: Number(pop.querySelector('#dp-set-freq').value) || 60, snapKeepDays: Number(pop.querySelector('#dp-set-keep').value) || 90, }); const uid = pop.querySelector('#dp-set-uid').value.trim(); if (uid) store.set('ocd_helper_dp_uid_v1', uid); pop.remove(); toastSafe('✅ 采集设置已保存', 'ok'); }); const openedAt = Date.now(); setTimeout(() => document.addEventListener('click', function dpSetClose(ev) { if (!document.getElementById('dp-set-pop')) { document.removeEventListener('click', dpSetClose); return; } if (Date.now() - openedAt < 300) return; if (ev.target.closest('#dp-set-pop') || ev.target.closest('#dp_gear')) return; pop.remove(); document.removeEventListener('click', dpSetClose); }), 0); }); renderAll(); focusPanel(panel); { const p = dpPref.get(); if (p.autoCollect && p.lastCollectTs && Date.now() - p.lastCollectTs > p.freqMin * 60 * 1000) { dpCollectSnapshot().then(() => renderAll()).catch(() => {}); } } } function closeDataPanelUI() { const el = document.getElementById('ocd-dp-panel'); if (el) el.remove(); if (_openModuleId === 'datapanel') _openModuleId = null; } /* ================================================================ * §35 发种查重助手 * ================================================================ */ const dcCollapse = { settings: false, results: false }; /* V9.75.7(用户 m03710):面板打开期间的测试/真机探针句柄(closeDupCheckUI 时置回 null ✓ 不参与业务 ✓) */ let _dcDebug = null; function rawLineOf(s) { return String(s || '').split(/[\\/]/).pop().replace(/^["']|["']$/g, '').replace(/\.torrent$/i, '').trim(); } function dcPrecheckKeyword(raw) { const kw = dupKeywordOf(raw); if (!kw) return { ok: false, reason: '无法提取有效关键词' }; const words = String(kw).split(/\s+/).filter(Boolean).filter(w => w.length >= 2); const coreWords = words.filter(w => !VA_PAT.test(w.replace(/\s/g, ''))); if (!coreWords.length) return { ok: false, reason: '无有效核心词' }; if (coreWords.length === 1) { const w = coreWords[0]; if (/^\d{1,4}$/.test(w)) return { ok: false, reason: '纯年份/纯数字关键词(命中会泛滥)' }; if (!/[a-zA-Z]/.test(w) && w.length <= 4) return { ok: false, reason: '过短关键词(≤4字符无字母,命中会泛滥)' }; } return { ok: true, kw }; } function openDupCheckUI(container) { if (document.getElementById('ocd-dc-panel')) return; const panel = document.createElement('div'); panel.id = 'ocd-dc-panel'; panel.style.cssText = [ 'position:fixed', 'top:0', 'right:0', 'height:auto', 'max-height:100vh', 'width:' + (Number(store.get('ocd_helper_dcp_width_v1', 0)) || (alhPref.get().panelWide ? 560 : 400)) + 'px', 'max-width:96vw', 'z-index:9999990', 'display:flex', 'flex-direction:column', 'background:var(--bg,rgba(24,26,32,.97))', 'color:var(--fg,#e8eaf0)', 'border-left:1px solid var(--border,rgba(255,255,255,.10))', 'border-radius:12px 0 0 12px', 'box-shadow:-12px 0 40px rgba(0,0,0,.5)', 'font:13px/1.6 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif', 'overflow:hidden' ].join(';'); panel.innerHTML = '
' + ' ' + ' 👁️‍🗨️ 发种查重助手 open.cd' + ' ' + '
' + '
' + ' ' + '
' + '
' + ' ' + ' ' + ' ' + ' ' + '
' + '
' + '
' + '
' + ' ▸' + ' 查重条件' + ' ' + ' ' + '
' + ' ' + '
' + '
' + ' ' + ' ' + '
' + '
粘贴关键词开始查重
支持标准格式 / 「艺人 - 专辑」/ 纯专辑名,每行一个
' + '
' + '
' + '
' + ' 就绪' + ' ' + ' ' + ' ' + '
'; container.appendChild(panel); const mainP = document.getElementById('ocd-helper-panel'); const themeCls = (mainP && Array.from(mainP.classList).find(c => c.indexOf('ocd-theme-') === 0)) || ('ocd-theme-' + store.get(THEME_KEY, 'dark')); panel.classList.add(themeCls); makeEdgeResize(panel, { min: 360, max: 720, def: 400, key: DCP_WIDTH_KEY, getPanel: () => panel }); if (!document.getElementById('dp-slide-css')) { const css = document.createElement('style'); css.id = 'dp-slide-css'; css.textContent = '@keyframes ocd-dp-slide{from{transform:translateX(40px);opacity:0}to{transform:none;opacity:1}}' + '.dc-results-body [data-dckw]{content-visibility:auto;contain-intrinsic-size:auto 60px;}'; document.head.appendChild(css); } applyOpenAnim(panel); const $ = (id) => panel.querySelector('#' + id); panel.querySelector('#dc_head').style.cursor = 'move'; panel.querySelectorAll('#dc_head button').forEach(el => { el.style.cursor = 'pointer'; }); { const savedPos = store.get('ocd_helper_dcp_pos_v1', null); if (savedPos && savedPos.x != null) { panel.style.left = Math.min(savedPos.x, window.innerWidth - 100) + 'px'; panel.style.top = Math.min(savedPos.y, Math.max(0, window.innerHeight - 60)) + 'px'; panel.style.right = 'auto'; panel.style.borderRadius = '12px'; } } const dcQueue = { tasks: [], running: false, stopFlag: false, done: 0, clean: 0, dup: 0, err: 0, msSum: 0, msN: 0 }; function dcSetTasks(lines, append) { if (!append) dcQueue.tasks = []; let added = 0; const blocked = []; for (const raw of lines) { const l = raw.split(/[\\/]/).pop().replace(/^["']|["']$/g, '').replace(/\.torrent$/i, '').trim(); const pr = dcPrecheckKeyword(l); if (!pr.ok) { blocked.push('[' + l.slice(0, 16) + '](' + pr.reason + ')'); continue; } const kw = pr.kw; if (dcQueue.tasks.some(t => t.kw === kw)) continue; dcQueue.tasks.push({ kw, raw: raw, rawShort: l, state: 'pending', note: '', url: '', hits: [], degraded: false }); added++; } return { added, blocked }; } function dcRefreshStatus() { const pend = dcQueue.tasks.filter(t => t.state === 'pending').length; $('dc_pendcount').textContent = dcQueue.tasks.length ? ('待查 ' + pend + ' 条') : ''; const info = $('dc_foot_info'); const o = getDupOpts(); const AREA_CN = { 0: '标题', 1: '简介', 3: '发布者', 4: 'IMDb', 5: '曲目', 6: '艺术家' }; const MODE_CN = { 0: '与', 1: '或', 2: '精准' }; const condTxt = '条件: ' + (AREA_CN[o.area] || o.area) + '/' + (MODE_CN[o.mode] || o.mode) + (o.filter ? ' · 过滤开' : '') + (Object.values(o.checks).some(a => a.length) ? ' · 已选筛选' : ''); if (!dcQueue.tasks.length) { info.textContent = '就绪 · ' + condTxt; return; } const gapNow = dcQueue.tasks.length > 50 ? 2000 : (dcQueue.tasks.length > 20 ? 1500 : 1000); const eta = (dcQueue.msN && pend) ? Math.round((dcQueue.msSum / dcQueue.msN + gapNow) * pend / 1000) : 0; let etaTxt = ''; if (dcQueue.running && eta > 0) etaTxt = ' · 剩余约 ' + (eta >= 60 ? Math.floor(eta / 60) + ' 分 ' + (eta % 60) + ' 秒' : eta + ' 秒'); info.textContent = '进度 ' + dcQueue.done + '/' + dcQueue.tasks.length + ' · ✅' + dcQueue.clean + ' ❓' + dcQueue.dup + ' ❌' + dcQueue.err + ' · ' + condTxt + etaTxt; } function dcRenderResults() { const wrap = $('dc_results_body'); if (!wrap) return; wrap.classList.add('dc-results-body'); const head = $('dc_results_head'); if (head && dcQueue.tasks.length) head.style.display = 'flex'; if (!dcQueue.tasks.length) return; if (!dcRenderResults._filter) dcRenderResults._filter = 'all'; if (!dcRenderResults._expandedKws) dcRenderResults._expandedKws = new Set(); const fv = dcRenderResults._filter; let html = ''; for (const t of dcQueue.tasks) { if (fv !== 'all' && t.state !== fv) continue; let borderColor; if (t.state === 'dup' && t.level) { borderColor = { certain: '#ff5252', partial: '#f59e0b', suspect: '#ffd54a' }[t.level] || '#f59e0b'; } else { borderColor = { pending: '#9aa3b2', running: '#5b8cff', clean: '#3fd68f', dup: '#f59e0b', error: '#ff5252' }[t.state] || '#666'; } html += '
'; html += '
'; /* V9.75.7(用户 m03710):每条结果卡前的多选框 ✓ 勾选后弹出批量操作条 ✓ */ html += ''; let stIcon; if (t.state === 'dup' && t.level === 'certain') stIcon = '🔴'; else if (t.state === 'dup' && t.level === 'partial') stIcon = '🟠'; else stIcon = { pending: '⏸', running: '⏳', clean: '✅', dup: '❓', error: '❌' }[t.state] || '·'; html += '' + stIcon + ''; html += '' + esc(t.kw) + ''; if (t.degraded) html += '低置信'; if (t.state === 'running') html += ''; if (t.state === 'clean') html += '跳转↗'; if (t.state === 'dup') { const lvBadge = t.level ? ({ certain: '🔴', partial: '🟠', suspect: '🟡' }[t.level] || '') : ''; const tagBadge = t.levelTag ? '·' + esc(t.levelTag) : ''; const isExp = dcRenderResults._expandedKws.has(t.kw); html += ''; } if (t.state === 'error') html += ''; html += '
'; if (t.note && t.state !== 'running') html += '
' + esc(t.note) + '
'; if (t.state === 'dup' && t.hits.length) { const isExp = dcRenderResults._expandedKws.has(t.kw); html += '
'; html += t.hits.map((h, i) => { const rsn = Array.isArray(h.reasons) && h.reasons.length ? '' + h.reasons.map(x => esc(x)).join(' · ') + '' : ''; const metaB = (h.sizeText || h.promo) ? '' + (h.promo ? '' + esc(h.promo) + ' ' : '') + (h.sizeText ? esc(h.sizeText) : '') + '' : ''; const lvIcon = { certain: '🔴', partial: '🟠', suspect: '🟡' }[h.level] || ''; return '
' + '' + (i + 1) + '.' + (lvIcon ? '' + lvIcon + '' : '') + '' + esc(h.title) + '' + metaB + rsn + '
'; }).join(''); if (t.hits.length > 1) { html += ''; } html += '
'; } html += '
'; } if (!html) html = '
当前筛选无结果
'; wrap.innerHTML = html; const fbar = $('dc_filter_bar'); if (fbar) fbar.querySelectorAll('.dc-ft').forEach(b => { const on = b.dataset.v === fv; b.style.borderColor = on ? 'var(--accent,#5b8cff)' : ''; b.style.color = on ? 'var(--accent,#5b8cff)' : ''; b.style.fontWeight = on ? '700' : ''; }); dcRefreshStatus(); } $('dc_results_head').addEventListener('click', (e) => { if (e.target.closest('button')) return; const bodyEl = $('dc_results_body'); dcCollapse.results = !dcCollapse.results; bodyEl.style.display = dcCollapse.results ? 'none' : ''; $('dc_results_arrow').textContent = dcCollapse.results ? '▸' : '▾'; }); if (dcCollapse.results) { $('dc_results_body').style.display = 'none'; $('dc_results_arrow').textContent = '▸'; } $('dc_filter_bar').addEventListener('click', (e) => { const b = e.target.closest('.dc-ft'); if (!b) return; e.stopPropagation(); dcRenderResults._filter = b.dataset.v; dcRenderResults(); }); $('dc_results_wrap').addEventListener('click', async (e) => { /* V9.75.7(用户 m03710):结果卡多选框 ✓ 勾选高亮 + 同步批量操作条 ✓ */ const ck = e.target.closest('.dc-ck'); if (ck) { const t = dcQueue.tasks.find(x => x.kw === ck.dataset.kw); if (t) { t._sel = !!ck.checked; const card = ck.closest('[data-dckw]'); if (card) { card.style.background = t._sel ? 'rgba(91,140,255,.10)' : ''; card.style.boxShadow = t._sel ? '0 0 0 2px rgba(91,140,255,.45)' : ''; } } dcSelSync(); return; } const tgl = e.target.closest('.dc-dup-tgl'); if (tgl) { const card = tgl.closest('[data-dckw]'); const t = dcQueue.tasks.find(x => x.kw === (card && card.dataset.dckw)); const det = card && card.querySelector('.dc-dup-detail'); if (det && t) { const show = det.style.display === 'none'; det.style.display = show ? '' : 'none'; tgl.textContent = '命中' + t.hits.length + (show ? '▾' : '▸'); if (show) dcRenderResults._expandedKws.add(t.kw); else dcRenderResults._expandedKws.delete(t.kw); } return; } const oa = e.target.closest('.dc-dup-openall'); if (oa) { const ids = (oa.dataset.ids || '').split(','); for (const id of ids) window.open('https://open.cd/plugin_details.php?id=' + id + '&hit=1', '_blank'); return; } const sk = e.target.closest('.dc-skip'); if (sk) { const t = dcQueue.tasks.find(x => x.kw === sk.dataset.kw); if (t) t._skip = true; return; } const rt = e.target.closest('.dc-retry'); if (rt) { const kw = rt.dataset.kw; const t = dcQueue.tasks.find(x => x.kw === kw); if (!t || dcQueue.running) return; t.state = 'running'; dcRenderResults(); const before = Date.now(); let r = await dcCheckOne(kw); const deg = await dcDegradedCheck(kw, t.rawShort || t.raw); if (deg.degraded) { r = deg.r; r.degraded = true; } t.state = r.state; t.note = r.note; t.url = r.url; t.hits = (r.hits || []).map(h => ({ id: h.id, title: h.title, level: h.level, reasons: h.reasons, sizeText: h.sizeText, promo: h.promo })); t.level = r.level || ''; t.levelTag = deg.levelTag || ''; t.degraded = !!r.degraded; dcQueue.done++; dcQueue.msSum += (Date.now() - before); dcQueue.msN++; if (r.state === 'clean') dcQueue.clean++; else if (r.state === 'dup') dcQueue.dup++; else dcQueue.err++; dcRenderResults(); } }); /* ================================================================ * V9.75.7(用户 m03710 / m03793):结果卡多选 + 批量动作 * · 删除选中(仅面板内结果 ✓ 可撤销 ✓ 正在查询的任务不删 ✓) * · 导入批量发种(含疑似命中 → 二次确认 ✓ 跳过查重 ✓ 等待手动「▶ 继续」✓) * · 白名单(把选中项的命中种子写入本地误判白名单 ✓) * ================================================================ */ function dcSelList() { return dcQueue.tasks.filter(t => t._sel); } function dcSelVisible() { const fv = dcRenderResults._filter || 'all'; return dcQueue.tasks.filter(t => fv === 'all' || t.state === fv); } function dcSelClear() { for (const t of dcQueue.tasks) t._sel = false; } function dcSelSync() { const sel = dcSelList(); const bar = $('dc_selbar'); if (bar) bar.style.display = sel.length ? 'flex' : 'none'; const info = $('dc_sel_info'); if (info) { const dupN = sel.filter(t => t.state === 'dup').length; info.textContent = '已选 ' + sel.length + ' / ' + dcQueue.tasks.length + ' 条' + (dupN ? ('(其中疑似命中 ' + dupN + ' 条)') : ''); } const allBtn = $('dc_sel_all'); if (allBtn) { const vis = dcSelVisible(); allBtn.textContent = (vis.length && vis.every(t => t._sel)) ? '取消全选' : '全选'; } return sel.length; } function dcSelRedraw() { if (dcQueue.tasks.length) dcRenderResults(); else { const wrap = $('dc_results_body'); if (wrap) wrap.innerHTML = '
无结果(已全部删除)
'; const head = $('dc_results_head'); if (head) head.style.display = 'none'; dcRefreshStatus(); } dcSelSync(); } /* ---- 删除选中(带撤销) ---- */ function dcDeleteSelected() { const sel = dcSelList(); if (!sel.length) { toastSafe('请先勾选要删除的结果', 'err'); return 0; } const running = sel.filter(t => t.state === 'running'); const rem = sel.filter(t => t.state !== 'running'); if (!rem.length) { toastSafe('选中的都是正在查询的任务,请先「⏹ 停止」再删除', 'err'); return 0; } const saved = []; for (const t of rem) { const idx = dcQueue.tasks.indexOf(t); if (idx === -1) continue; saved.push({ t, idx }); t._sel = false; if (t.state === 'clean') dcQueue.clean = Math.max(0, dcQueue.clean - 1); else if (t.state === 'dup') dcQueue.dup = Math.max(0, dcQueue.dup - 1); else if (t.state === 'error') dcQueue.err = Math.max(0, dcQueue.err - 1); else if (t.state !== 'pending') dcQueue.done = Math.max(0, dcQueue.done - 1); } dcQueue.tasks = dcQueue.tasks.filter(t => rem.indexOf(t) === -1); dcSelRedraw(); if (running.length) toastSafe('⚠ 已跳过 ' + running.length + ' 个正在查询的任务(未删除)', 'warn'); const undo = () => { for (const x of saved.slice().sort((a, b) => a.idx - b.idx)) { dcQueue.tasks.splice(Math.min(x.idx, dcQueue.tasks.length), 0, x.t); } for (const x of saved) { if (x.t.state === 'clean') dcQueue.clean++; else if (x.t.state === 'dup') dcQueue.dup++; else if (x.t.state === 'error') dcQueue.err++; else if (x.t.state !== 'pending') dcQueue.done++; } dcSelRedraw(); toastSafe('↩ 已恢复 ' + saved.length + ' 条结果', 'ok'); }; try { toastAction('🗑 已删除选中 ' + saved.length + ' 条', '撤销', undo); } catch (e) { toastSafe('🗑 已删除选中 ' + saved.length + ' 条', 'ok'); } try { ocdAudit('dupcheck', '删除选中结果', saved.length + ' 条'); } catch (e) {} return saved.length; } /* ---- 白名单(本地误判库 ✓ 与「🗒 白名单」按钮同一份数据 ✓) ---- */ function dcSelWhitelist() { const sel = dcSelList(); if (!sel.length) { toastSafe('请先勾选要加入白名单的结果', 'err'); return 0; } let ok = 0, skip = 0; for (const t of sel) { const hitIds = (t.hits || []).map(h => String(h.id)).filter(Boolean); if (!hitIds.length) { skip++; continue; } let fp = ''; try { const parsed = parseFolderNameLoose(t.raw || t.kw); fp = parsed ? dcAlbumFingerprint(parsed) : ''; } catch (e) { fp = ''; } if (!fp) { skip++; continue; } try { dcWhitelistAdd(fp, hitIds, dupKeywordOf(t.raw || t.kw)); } catch (e) { skip++; continue; } t._wl = 1; ok++; } const st = (function () { try { return dcWhitelistStats(); } catch (e) { return { albums: 0, hitIds: 0 }; } })(); if (ok) { toastSafe('🗒 已加入白名单 ' + ok + ' 条(库内共 ' + st.albums + ' 专辑 / ' + st.hitIds + ' 种子)' + (skip ? (' · 跳过 ' + skip + ' 条(无命中记录或无法识别)') : ''), 'ok'); try { ocdAudit('dupcheck', '加入误判白名单', ok + ' 条'); } catch (e) {} } else { toastSafe('未加入白名单:选中的 ' + sel.length + ' 条都没有命中记录(无重复/失败/待查)', 'err'); } dcSelRedraw(); return ok; } /* ---- 导入批量发种(低层 ✓ 不再弹窗 ✓ 供二次确认分支与探针调用 ✓) ---- */ function dcImportNow(kws) { const list = Array.isArray(kws) ? dcQueue.tasks.filter(t => kws.indexOf(t.kw) !== -1) : dcSelList(); const lines = list.map(t => t.raw || t.kw).filter(Boolean); if (!lines.length) { toastSafe('没有可导入的项', 'err'); return 0; } let added = 0; try { added = batchAddTasks(lines, { skipDup: 1, holdManual: 1, src: '查重助手' }) || 0; } catch (e) { toastSafe('导入失败:' + ((e && e.message) || e), 'err'); return 0; } for (const t of list) t._imp = 1; dcSelRedraw(); if (added) { try { toastAction('📥 已导入 ' + added + ' 条到批量发种「待解析」(已跳过查重 ✓ 需点「▶ 继续」开跑)', '打开面板', () => { try { shOpenModuleById('batch'); } catch (e) {} }, { level: 'ok' }); } catch (e) { toastSafe('📥 已导入 ' + added + ' 条到批量发种「待解析」', 'ok'); } try { ocdAudit('dupcheck', '导入批量发种', added + ' 条(跳过查重 · 等待手动继续)'); } catch (e) {} } else { toastSafe('⚠ 没有新增任务(可能已在批量列表中,或被格式校验拒绝)', 'warn'); } return added; } function dcSelImport() { const sel = dcSelList(); if (!sel.length) { toastSafe('请先勾选要导入的结果', 'err'); return; } const risky = sel.filter(t => t.state === 'dup'); const cleanN = sel.filter(t => t.state === 'clean').length; const doImp = (onlyClean) => { const list = onlyClean ? sel.filter(t => t.state === 'clean') : sel; if (!list.length) { toastSafe('没有可导入的无重复项', 'err'); return; } dcImportNow(list.map(t => t.kw)); }; if (!risky.length) { doImp(false); return; } /* 用户 m03793:勾选里含「疑似命中」→ 二次弹窗确认后再导入 ✓ */ const rows = risky.slice(0, 8).map(t => '· ' + esc(t.kw) + '(命中 ' + ((t.hits || []).length) + ' 条)').join('
'); const html = '
选中的 ' + sel.length + ' 条中有 ' + '' + risky.length + ' 条「疑似命中」(站点上可能已有重复种子):
' + '
' + rows + (risky.length > 8 ? ('
…等共 ' + risky.length + ' 条') : '') + '
' + '
导入后进入批量发种「待解析」并 跳过查重环节,' + '需在批量面板勾选后点「▶ 继续」才会开始解析。
'; const btns = [{ text: '取消', value: false }]; if (cleanN) btns.push({ text: '仅导入无重复 ' + cleanN + ' 条', value: 'clean' }); btns.push({ text: '全部导入 ' + sel.length + ' 条', value: 'all', primary: true }); const p = (typeof shConfirm === 'function') ? shConfirm({ title: '导入批量发种(含疑似命中)', html: html, buttons: btns }) : Promise.resolve(window.confirm('选中项含疑似命中,确认全部导入?') ? 'all' : false); Promise.resolve(p).then(v => { if (!v) { toastSafe('已取消导入', 'ok'); return; } doImp(v === 'clean'); }).catch(() => {}); } async function dcRun(append) { const lines = $('dc_input').value.split('\n').map(s => s.trim()).filter(Boolean); if (!lines.length && !dcQueue.tasks.some(t => t.state === 'pending')) { toastSafe('请先粘贴关键词(每行一个)', 'err'); return; } if (lines.length > 100) { toastSafe('❌ 超过 100 行上限(当前 ' + lines.length + ' 行),请分批', 'err'); return; } const res2 = dcSetTasks(lines, append); let added = res2.added; const blocked = res2.blocked; if (blocked.length) { const force = confirm('❌ ' + blocked.length + ' 条信息不足已拦截:\n' + blocked.slice(0, 3).join('\n') + (blocked.length > 3 ? '\n… 等共 ' + blocked.length + ' 条' : '') + '\n\n这类关键词命中会泛滥。\n\n确定 = 强制查重全部拦截项 取消 = 保持拦截'); if (force) { for (const l of lines) { const pr = dcPrecheckKeyword(l); if (pr.ok) continue; const kw = dupKeywordOf(l); if (!kw || dcQueue.tasks.some(t => t.kw === kw)) continue; dcQueue.tasks.push({ kw, raw: rawLineOf(l), rawShort: l, state: 'pending', note: '', url: '', hits: [], degraded: false }); added++; } toastSafe('⚠ 已强制入队 ' + blocked.length + ' 条(结果可能泛滥)', 'ok'); } else { toastSafe('❌ ' + blocked.length + ' 条信息不足已拦截', 'err'); } } if (!added && append) { toastSafe('没有新增任务(重复或被拦截)', 'err'); return; } dcRenderResults(); if (!dcQueue.running) { let probe = await dcCheckOne('__probe__' + Date.now()); if (probe.state === 'error') { await new Promise(res => setTimeout(res, 2000)); probe = await dcCheckOne('__probe__' + Date.now()); } if (probe.state === 'error') { toastSafe('❌ 登录态或网络异常(' + probe.note + '),已重试仍失败,请检查后重试', 'err'); dcRenderResults(); return; } } if (dcQueue.running) { toastSafe('⏬ 已追加 ' + added + ' 条到队列', 'ok'); dcRenderResults(); return; } dcQueue.running = true; dcQueue.stopFlag = false; $('dc_run').style.display = 'none'; $('dc_append').style.display = ''; $('dc_stop').style.display = ''; $('dc_statlight').style.background = 'var(--ok,#3fd68f)'; $('dc_statlight').style.animation = 'dp-blink 1.5s ease-in-out infinite'; for (;;) { const t = dcQueue.tasks.find(x => x.state === 'pending'); if (!t || dcQueue.stopFlag) break; t.state = 'running'; dcRenderResults(); const t0 = Date.now(); let r = await dcCheckOne(t.kw); if (t._skip) { delete t._skip; t.state = 'error'; t.note = '已跳过'; t.url = dupSearchUrl(t.kw, getDupOpts()); t.hits = []; dcQueue.done++; dcQueue.err++; dcRenderResults(); const remainS = dcQueue.tasks.filter(x => x.state === 'pending').length; if (remainS > 0 && !dcQueue.stopFlag) { const gapS = dcQueue.tasks.length > 50 ? 2000 : (dcQueue.tasks.length > 20 ? 1500 : 1000); await new Promise(res => setTimeout(res, gapS)); } continue; } const deg = await dcDegradedCheck(t.kw, t.rawShort || t.raw); if (deg.degraded) { r = deg.r; r.degraded = true; } t.state = r.state; t.note = r.note; t.url = r.url; t.hits = (r.hits || []).map(h => ({ id: h.id, title: h.title, level: h.level, reasons: h.reasons, sizeText: h.sizeText, promo: h.promo })); t.level = r.level || ''; t.levelTag = deg.levelTag || ''; t.degraded = !!r.degraded; dcQueue.done++; dcQueue.msSum += (Date.now() - t0); dcQueue.msN++; if (r.state === 'clean') dcQueue.clean++; else if (r.state === 'dup') dcQueue.dup++; else dcQueue.err++; dcRenderResults(); const remain = dcQueue.tasks.filter(x => x.state === 'pending').length; if (remain > 0 && !dcQueue.stopFlag) { const gap = dcQueue.tasks.length > 50 ? 2000 : (dcQueue.tasks.length > 20 ? 1500 : 1000); await new Promise(res => setTimeout(res, gap)); } } dcQueue.running = false; $('dc_run').style.display = ''; $('dc_append').style.display = 'none'; $('dc_stop').style.display = 'none'; $('dc_statlight').style.animation = ''; $('dc_statlight').style.background = dcQueue.err > 0 ? '#f59e0b' : 'var(--ok,#3fd68f)'; if (dcQueue.stopFlag) toastSafe('⏹ 查重已停止,剩余任务保留', 'ok'); else toastSafe('🎉 查重完成:✅' + dcQueue.clean + ' ❓' + dcQueue.dup + ' ❌' + dcQueue.err, 'ok'); dcRefreshStatus(); } $('dc_run').addEventListener('click', () => dcRun(false)); $('dc_append').addEventListener('click', () => dcRun(true)); $('dc_stop').addEventListener('click', () => { dcQueue.stopFlag = true; }); /* V9.75.7(用户 m03710):批量操作条按钮(全选 / 删除选中 / 导入批量发种 / 白名单 ✓) */ $('dc_sel_all').addEventListener('click', () => { const vis = dcSelVisible(); if (!vis.length) { toastSafe('当前筛选下没有可勾选的结果', 'err'); return; } const allOn = vis.every(t => t._sel); for (const t of vis) t._sel = !allOn; dcSelRedraw(); }); $('dc_sel_del').addEventListener('click', () => dcDeleteSelected()); $('dc_sel_imp').addEventListener('click', () => dcSelImport()); $('dc_sel_wl').addEventListener('click', () => dcSelWhitelist()); $('dc_copy_clean').addEventListener('click', () => { const list = dcQueue.tasks.filter(t => t.state === 'clean'); if (!list.length) { toastSafe('暂无「无重复」结果', 'err'); return; } copyText(list.map(t => t.raw || t.kw).join('\n'), () => toastSafe('✅ 已复制 ' + list.length + ' 条无重复关键词', 'ok')); }); $('dc_wl').addEventListener('click', () => { const st = dcWhitelistStats(); if (!st.albums) { toastSafe('🗒 白名单为空(复核确认时自动记录)', 'ok'); return; } if (!confirm('本地误判白名单:\n\n· 收录专辑:' + st.albums + ' 张\n· 命中种子:' + st.hitIds + ' 条\n\n点击「确定」清空全部记录(下次查重将不再自动放过)。')) return; dcWhitelistClear(); toastSafe('🗑 白名单已清空', 'ok'); dcRefreshStatus(); }); $('dc_copy').addEventListener('click', () => { if (!dcQueue.tasks.length) { toastSafe('暂无查重结果', 'err'); return; } const lines = ['【发种查重结果】 ' + new Date().toLocaleString()]; const groups = [ ['❌ 失败', 'error'], ['❓ 疑似重复', 'dup'], ['✅ 无重复', 'clean'], ['⏳ 未检测', 'pending'] ]; for (const [label, st] of groups) { const list = dcQueue.tasks.filter(t => t.state === st); if (!list.length) continue; lines.push('── ' + label + '(' + list.length + ' 条)──'); for (const t of list) { lines.push('· ' + t.kw + ' ' + (t.note || '')); if (st === 'dup' && t.hits.length) { t.hits.forEach((h, i) => lines.push(' ' + (i + 1) + '. ' + h.title + ' https://open.cd/plugin_details.php?id=' + h.id)); } else if (st !== 'clean' && t.url) { lines.push(' ' + t.url); } } } copyText(lines.join('\n'), () => toastSafe('✅ 结果已复制到剪贴板', 'ok')); }); let precheckTimer = null; $('dc_input').addEventListener('input', () => { clearTimeout(precheckTimer); precheckTimer = setTimeout(() => { const lines = $('dc_input').value.split('\n').map(s => s.trim()).filter(Boolean); const pre = $('dc_precheck'); if (!lines.length) { pre.innerHTML = '💡 推荐标准格式「年份 艺人 - 专辑」,识别率最高;含繁体字的关键词自动启用降级兜底查询'; return; } if (lines.length > 100) { pre.innerHTML = '❌ 超过 100 行上限(当前 ' + lines.length + ' 行),请分批查重'; return; } let okN = 0; const blocked = []; for (const l of lines) { const r = dcPrecheckKeyword(l); if (r.ok) okN++; else blocked.push('[' + l.slice(0, 16) + '](' + r.reason + ')'); } let html = '✓ ' + okN + ' 条有效'; if (blocked.length) html += ' · ❌ ' + blocked.length + ' 条信息不足已拦截:' + esc(blocked.slice(0, 2).join(';')) + (blocked.length > 2 ? ' 等' : '') + ''; pre.innerHTML = html; }, 250); }); $('dc_input').value = store.get('ocd_helper_dcp_input_v1', '') || ''; $('dc_input').addEventListener('input', () => { store.set('ocd_helper_dcp_input_v1', $('dc_input').value); }); $('dc_input').dispatchEvent(new Event('input')); { const head = $('dc_head'); makeDraggable(head, DCP_POS_KEY, panel); let minimized = false; head.addEventListener('dblclick', (e) => { if (e.target.closest('button')) return; const blocks = ['dc_input_wrap', 'dc_settings_wrap', 'dc_results_wrap', 'dc_foot']; const blockEls = blocks.map(id => panel.querySelector('#' + id)).filter(Boolean); if (!minimized) { shBulkSetCollapsed(blockEls, true); minimized = true; } else { shBulkSetCollapsed(blockEls, false); minimized = false; } }); head.title = '拖动移动 · 双击最小化/还原'; } $('dc_close').addEventListener('click', () => closeDupCheckUI()); makePinBtn(panel.querySelector('#dc_head'), panel, '#dc_close'); function renderDupSettings() { const body = $('dc_set_body'); body.innerHTML = ''; const o = getDupOpts(); const mkSelRow = (labelText, val, options, key) => { const w = document.createElement('div'); w.style.cssText = 'display:flex;align-items:center;gap:6px;margin:4px 0;'; w.innerHTML = '' + labelText + ''; const sel = document.createElement('select'); sel.style.cssText = 'flex:1;padding:2px 6px;border-radius:5px;border:1px solid var(--border,#444);background:var(--input-bg,#1a1f2a);color:var(--fg);'; for (const [v, t] of options) { const op = document.createElement('option'); op.value = v; op.textContent = t; sel.appendChild(op); } sel.value = String(val); sel.addEventListener('change', () => { const cur = getDupOpts(); cur[key] = Number(sel.value); saveDupOpts(cur); toastSafe('✅ 已保存(下次查询生效)', 'ok'); }); w.appendChild(sel); body.appendChild(w); return sel; }; mkSelRow('搜索范围:', o.area, [[0, '标题'], [1, '简介'], [3, '发布者'], [4, 'IMDb'], [5, '曲目列表'], [6, '艺术家名']], 'area'); mkSelRow('匹配模式:', o.mode, [[0, '与(推荐)'], [1, '或'], [2, '精准']], 'mode'); const fltL = document.createElement('label'); fltL.style.cssText = 'display:flex;align-items:center;gap:6px;cursor:pointer;margin:4px 0;'; fltL.innerHTML = ' 二次过滤(标题规范化比对,疑似更准)'; body.appendChild(fltL); fltL.querySelector('#dc_flt').addEventListener('change', (e) => { const cur = getDupOpts(); cur.filter = e.target.checked; saveDupOpts(cur); toastSafe('✅ 已保存(下次查询生效)', 'ok'); }); for (const g of DUP_CHECK_GROUPS) { const gWrap = document.createElement('div'); gWrap.style.cssText = 'margin:5px 0;'; gWrap.innerHTML = '
' + g.label + '
'; const chipBox = document.createElement('div'); chipBox.style.cssText = 'display:flex;flex-wrap:wrap;gap:3px;'; for (const [name, labelText] of g.items) { const on = (o.checks[g.key] || []).indexOf(name) !== -1; const chip = document.createElement('span'); chip.textContent = labelText; chip.dataset.name = name; chip.dataset.gkey = g.key; chip.style.cssText = 'cursor:pointer;padding:2px 8px;border-radius:6px;font-size:10px;border:1px solid ' + (on ? 'var(--accent,#5b8cff);color:var(--accent,#5b8cff);background:var(--bg-soft,rgba(91,140,255,.1));' : 'var(--border,#444);color:var(--fg-muted);'); chip.addEventListener('click', () => { const cur = getDupOpts(); if (!cur.checks[g.key]) cur.checks[g.key] = []; const i = cur.checks[g.key].indexOf(name); if (i === -1) cur.checks[g.key].push(name); else cur.checks[g.key].splice(i, 1); saveDupOpts(cur); const nowOn = cur.checks[g.key].indexOf(name) !== -1; chip.style.borderColor = nowOn ? 'var(--accent,#5b8cff)' : 'var(--border,#444)'; chip.style.color = nowOn ? 'var(--accent,#5b8cff)' : 'var(--fg-muted)'; toastSafe('✅ 已保存(下次查询生效)', 'ok'); }); chipBox.appendChild(chip); } gWrap.appendChild(chipBox); body.appendChild(gWrap); } for (const sd of DUP_SELECTS) { const w = document.createElement('div'); w.style.cssText = 'display:flex;align-items:center;gap:6px;margin:4px 0;'; w.innerHTML = '' + sd.label + ''; const sel = document.createElement('select'); sel.style.cssText = 'flex:1;padding:2px 6px;border-radius:5px;border:1px solid var(--border,#444);background:var(--input-bg,#1a1f2a);color:var(--fg);'; for (const [v, t] of sd.items) { const op = document.createElement('option'); op.value = v; op.textContent = t; sel.appendChild(op); } sel.value = o.selects[sd.key] || '0'; sel.addEventListener('change', () => { const cur = getDupOpts(); cur.selects[sd.key] = sel.value; saveDupOpts(cur); toastSafe('✅ 已保存(下次查询生效)', 'ok'); }); w.appendChild(sel); body.appendChild(w); } const numW = document.createElement('div'); numW.style.cssText = 'display:flex;gap:6px;align-items:center;margin:4px 0;'; numW.innerHTML = '做种数≤' + '年份'; body.appendChild(numW); numW.querySelector('#dc_set_seeders').addEventListener('change', (e) => { const cur = getDupOpts(); cur.seeders = e.target.value.trim(); saveDupOpts(cur); toastSafe('✅ 已保存(下次查询生效)', 'ok'); }); numW.querySelector('#dc_set_year').addEventListener('change', (e) => { const cur = getDupOpts(); cur.year = e.target.value.trim(); saveDupOpts(cur); toastSafe('✅ 已保存(下次查询生效)', 'ok'); }); const retryW = document.createElement('div'); retryW.style.cssText = 'display:flex;align-items:center;gap:6px;margin:4px 0;'; retryW.innerHTML = '网络失败重试'; const retrySel = document.createElement('select'); retrySel.style.cssText = 'flex:1;padding:2px 6px;border-radius:5px;border:1px solid var(--border,#444);background:var(--input-bg,#1a1f2a);color:var(--fg);'; retrySel.innerHTML = ''; retrySel.value = String(o.retryCount); retrySel.addEventListener('change', () => { const cur = getDupOpts(); cur.retryCount = Number(retrySel.value); saveDupOpts(cur); toastSafe('✅ 已保存(下次查询生效)', 'ok'); }); retryW.appendChild(retrySel); body.appendChild(retryW); } $('dc_set_head').addEventListener('click', (e) => { if (e.target.closest('#dc_set_reset')) return; const body = $('dc_set_body'); const open = body.style.display === 'none'; if (open && !body.hasChildNodes()) renderDupSettings(); body.style.display = open ? 'block' : 'none'; $('dc_set_arrow').textContent = open ? '▾' : '▸'; }); /* V9.75.7(用户 m03710 / m03793):给回归测试与真机探针暴露句柄(不参与业务逻辑 ✓) */ _dcDebug = { seed: (list) => { dcQueue.tasks = (Array.isArray(list) ? list : []).map(x => { const o = (x && typeof x === 'object') ? x : { kw: x }; const kw = String(o.kw || '').trim(); return { kw: kw, raw: String(o.raw || kw), rawShort: '', state: String(o.state || 'clean'), note: String(o.note || ''), url: String(o.url || ''), hits: Array.isArray(o.hits) ? o.hits : [], degraded: !!o.degraded, level: String(o.level || ''), levelTag: '', _sel: false, }; }).filter(x => x.kw); dcQueue.done = dcQueue.tasks.filter(t => t.state !== 'pending').length; dcQueue.clean = dcQueue.tasks.filter(t => t.state === 'clean').length; dcQueue.dup = dcQueue.tasks.filter(t => t.state === 'dup').length; dcQueue.err = dcQueue.tasks.filter(t => t.state === 'error').length; dcSelRedraw(); return dcQueue.tasks.length; }, count: () => dcQueue.tasks.length, state: () => dcQueue.tasks.map(t => t.kw + ':' + t.state + (t._sel ? ':sel' : '') + (t._wl ? ':wl' : '') + (t._imp ? ':imp' : '')), sel: () => dcSelList().map(t => t.kw), selbar: () => { const el = $('dc_selbar'); return el ? (el.style.display || 'none') : 'no-el'; }, selinfo: () => { const el = $('dc_sel_info'); return el ? el.textContent : 'no-el'; }, filter: (v) => { dcRenderResults._filter = v || 'all'; dcRenderResults(); dcSelSync(); return dcRenderResults._filter; }, clearSel: () => { dcSelClear(); dcSelRedraw(); return dcSelList().length; }, del: () => dcDeleteSelected(), imp: () => dcSelImport(), impNow: (kws) => dcImportNow(kws), wl: () => dcSelWhitelist(), whitelist: () => { try { return dcWhitelistStats(); } catch (e) { return { albums: 0, hitIds: 0 }; } }, render: () => { dcRenderResults(); return dcQueue.tasks.length; }, }; focusPanel(panel); } function closeDupCheckUI() { _dcDebug = null; const el = document.getElementById('ocd-dc-panel'); if (el) el.remove(); if (_openModuleId === 'dupcheck') _openModuleId = null; } /* ================================================================ * §36 搜索求助模块 * ================================================================ */ function shGetCfg() { const d = store.get(SH_KEY, {}) || {}; return { mode: d.mode === 'album' ? 'album' : 'track', priorityPlatform: String(d.priorityPlatform || 'auto'), excludeFinished: d.excludeFinished !== false, defaultBonus: Number.isInteger(d.defaultBonus) ? d.defaultBonus : 1000, appendBonus: Number.isInteger(d.appendBonus) ? d.appendBonus : 2000, }; } function shSaveCfg(patch) { const cur = shGetCfg(); Object.assign(cur, patch || {}); store.set(SH_KEY, cur); return cur; } function shGetHistory() { const h = store.get(SH_HIST_KEY, []); return Array.isArray(h) ? h : []; } function shSaveHistory(arr) { store.set(SH_HIST_KEY, (arr || []).slice(0, 20)); } function shAddHistory(rec) { const h = shGetHistory(); h.unshift(Object.assign({ ts: Date.now() }, rec)); shSaveHistory(h.slice(0, 20)); } const SH_MYPOSTS_KEY = 'ocd_helper_sh_myposts_v1'; function shGetMyPosts() { const arr = store.get(SH_MYPOSTS_KEY, []); return Array.isArray(arr) ? arr : []; } function shSaveMyPosts(arr) { const cap = advNum('myPostsCap', 600); const list = arr || []; if (cap > 0 && list.length > 2000) advWarnOnce('myPostsCap', '我的求种已超过 2000 条', '建议在求种账本导出 CSV 后清理,或把上限设为 0(不限)'); store.set(SH_MYPOSTS_KEY, cap > 0 ? list.slice(0, cap) : list); } const SH_MYRES_KEY = 'ocd_helper_sh_myres_v1'; function shKeyMyRes() { return SH_MYRES_KEY; } function shGetMyRes() { const arr = store.get(SH_MYRES_KEY, []); return Array.isArray(arr) ? arr : []; } function shSaveMyRes(arr) { const cap = advNum('myResCap', 600); const list = arr || []; if (cap > 0 && list.length > 2000) advWarnOnce('myResCap', '我的应求已超过 2000 条', '可把上限设为 0(不限)'); store.set(SH_MYRES_KEY, cap > 0 ? list.slice(0, cap) : list); } const MYREQ_MAX_PAGES = 120; function shParseViewReqPager(doc, baseUrl) { const out = []; const seen = {}; if (!doc) return { links: out, perPage: 0, total: 0 }; const base = baseUrl || 'https://open.cd/viewrequests.php?finished=mine'; const baseNoQuery = base.replace(/[?#].*$/, ''); let perPage = 0, total = 0; doc.querySelectorAll('a[href*="page="]').forEach((a) => { const raw = String(a.getAttribute('href') || '').replace(/&/g, '&'); if (!/[?&]page=\d+/.test(raw)) return; if (!/viewrequests\.php/i.test(raw) && raw.charAt(0) !== '?' && raw.charAt(0) !== '/') return; const abs = raw.indexOf('http') === 0 ? raw : (raw.charAt(0) === '?' ? (baseNoQuery + raw) : ('https://open.cd/' + raw.replace(/^\/+/, ''))); const m = abs.match(/[?&]page=(\d+)/); if (!m) return; const sitePage = parseInt(m[1], 10) || 0; const rm = String(a.textContent || '').trim().match(/(\d+)\s*[-–~]\s*(\d+)/); if (rm) { const from = parseInt(rm[1], 10), to = parseInt(rm[2], 10); if (!perPage && to > from) perPage = to - from + 1; if (to > total) total = to; } if (seen[abs]) return; seen[abs] = 1; out.push({ url: abs, sitePage: sitePage, page: sitePage + 1 }); }); out.sort((a, b) => a.sitePage - b.sitePage); return { links: out, perPage: perPage, total: total }; } async function shFetchSiteReqList(kind, maxPages, maxItems) { const itemCap = Math.max(0, Number(maxItems) || 0); const cap = Math.max(1, Math.min(MYREQ_MAX_PAGES, Number(maxPages) || MYREQ_MAX_PAGES)); const base = 'https://open.cd/viewrequests.php?finished=' + (kind === 'myres' ? 'myres' : 'mine'); const out = []; const seen = {}; const seenUrl = {}; seenUrl[base] = 1; const queue = [{ url: base }]; let pages = 0, siteTotal = 0, sitePerPage = 0; while (queue.length && pages < cap) { const job = queue.shift(); job.page = pages + 1; let html = ''; try { html = await gmFetchText(job.url, { Referer: 'https://open.cd/viewrequests.php' }); } catch (e) { console.warn('[MyReq] 第 ' + job.page + ' 页抓取失败:', (e && e.message) || e); break; } const rows = shParseRequestList(html); let added = 0; rows.forEach((r) => { if (!r.id || seen[r.id]) return; seen[r.id] = 1; r.src = kind; r.ts = r.ts || Date.now(); out.push(r); added++; }); pages++; { const pg = shParseViewReqPager(shParserDocOf(html), base); if (pages === 1) { sitePerPage = pg.perPage || rows.length || 0; siteTotal = pg.total || 0; console.log('[MyReq] ' + base + ' 首页 ' + rows.length + ' 条 · 每页 ' + (sitePerPage || '?') + ' · 分页窗口最大 ' + (siteTotal || '?') + '(真机验证:page 为 0 基且顺序遍历每页都是新数据)'); } const nextSite = (typeof job.sitePage === 'number') ? (job.sitePage + 1) : pages; const hasPage = /[?&]page=\d+/.test(base); const uNext = hasPage ? base.replace(/([?&])page=\d+/, '$1page=' + nextSite) : (base + '&page=' + nextSite); if (!seenUrl[uNext]) { seenUrl[uNext] = 1; queue.push({ url: uNext, sitePage: nextSite }); } } if (itemCap > 0 && out.length >= itemCap) break; if (added === 0 && pages > 1) break; if (out.length >= 1000) break; if (queue.length) await new Promise(r => setTimeout(r, 300)); } console.log('[MyReq] ' + kind + ' 共 ' + out.length + ' 条 / ' + pages + ' 页' + (siteTotal ? ('(站点声明 ' + siteTotal + ')') : '')); return { rows: out, pages: pages, total: siteTotal }; } const SH_SYNCCFG_KEY = 'ocd_helper_sh_synccfg_v1'; function shGetSyncCfg() { const d = store.get(SH_SYNCCFG_KEY, null); return { enabled: d && d.enabled === true, gapMin: (d && Number.isInteger(d.gapMin) && d.gapMin >= 1 && d.gapMin <= 1440) ? d.gapMin : 5, limit: (d && Number.isInteger(d.limit) && d.limit >= 0 && d.limit <= 100000) ? d.limit : 20, autoMine: !d || d.autoMine !== false, autoMyres: !d || d.autoMyres !== false, maxPages: (d && Number.isInteger(d.maxPages) && d.maxPages >= 1 && d.maxPages <= 20) ? d.maxPages : 3, lastAuto: (d && Number(d.lastAuto)) || 0, }; } function shSaveSyncCfg(cfg) { const cur = shGetSyncCfg(); store.set(SH_SYNCCFG_KEY, { enabled: !!cfg.enabled, gapMin: Math.max(1, Math.min(1440, parseInt(cfg.gapMin, 10) || cur.gapMin || 5)), limit: Math.max(0, Math.min(100000, (cfg.limit === 0 || cfg.limit === '0') ? 0 : (parseInt(cfg.limit, 10) || cur.limit || 20))), autoMine: cfg.autoMine !== undefined ? !!cfg.autoMine : cur.autoMine, autoMyres: cfg.autoMyres !== undefined ? !!cfg.autoMyres : cur.autoMyres, maxPages: Math.max(1, Math.min(20, parseInt(cfg.maxPages, 10) || cur.maxPages || 3)), lastAuto: Number(cfg.lastAuto) || cur.lastAuto || 0, }); } const SH_FOLLOWS_KEY = 'ocd_helper_sh_follows_v1'; function shGetFollows() { const arr = store.get(SH_FOLLOWS_KEY, []); return Array.isArray(arr) ? arr : []; } function shSaveFollows(arr) { store.set(SH_FOLLOWS_KEY, (arr || []).slice(0, 200)); } let _syncFollowTimer = null; function shSyncFollowUI() { if (_syncFollowTimer) return; _syncFollowTimer = setTimeout(() => { _syncFollowTimer = null; try { document.querySelectorAll('.sh-site-follow').forEach(btn => { const id = btn.dataset.reqid; if (!id) return; const isF = shIsFollow(id); if (isF) { btn.textContent = '⭐ 已关注'; btn.title = '点击取消关注'; btn.style.background = 'rgba(162,89,255,.15)'; } else { btn.textContent = '☆ 关注'; btn.title = '关注该求种帖,可在「我的求种」→「⭐ 我关注的」查看'; btn.style.background = 'transparent'; } }); document.querySelectorAll('.sh-follow-btn').forEach(btn => { const id = btn.dataset.reqid; if (!id) return; const isF = shIsFollow(id); if (isF) { btn.textContent = '⭐ 已关注'; btn.title = '点击取消关注'; btn.style.background = 'rgba(162,89,255,.15)'; } else { btn.textContent = '☆ 关注'; btn.title = '关注该求种帖,跟踪状态更新'; btn.style.background = 'transparent'; } }); if (typeof window.__shMyreqRefresh === 'function') { try { window.__shMyreqRefresh(); } catch (e) {} } } catch (e) { console.warn('[Follow] UI 同步异常:', e.message); } }, 50); } function shIsFollow(id) { return shGetFollows().some(x => x.id === String(id)); } function shAddFollow(rec) { const arr = shGetFollows(); const id = String(rec.id || ''); if (!id) return null; const idx = arr.findIndex(x => x.id === id); const clean = { id, title: String(rec.title || ''), bonus: Number(rec.bonus) || 0, status: rec.status || 'unknown', postTime: String(rec.postTime || ''), pubUid: String(rec.pubUid || ''), ts: rec.ts || Date.now(), lastCheck: 0, }; if (idx !== -1) arr[idx] = Object.assign(arr[idx], clean); else arr.unshift(clean); shSaveFollows(arr); shSyncFollowUI(); return clean; } function shDelFollow(id) { shSaveFollows(shGetFollows().filter(x => x.id !== String(id))); shSyncFollowUI(); } function shUpdateFollow(id, patch) { const arr = shGetFollows(); const idx = arr.findIndex(x => x.id === String(id)); if (idx === -1) return; arr[idx] = Object.assign(arr[idx], patch || {}); shSaveFollows(arr); } function shAddMyPost(rec) { const arr = shGetMyPosts(); const idx = arr.findIndex(x => x.id === rec.id); const clean = { id: String(rec.id || ''), title: String(rec.title || ''), bonus: Number(rec.bonus) || 0, mode: rec.mode || 'album', artist: String(rec.artist || ''), album: String(rec.album || ''), year: String(rec.year || ''), ts: rec.ts || Date.now(), status: rec.status || 'pending', lastCheck: 0, history: Array.isArray(rec.history) ? rec.history.slice(0, 20) : [], }; if (idx !== -1) arr[idx] = Object.assign(arr[idx], clean); else arr.unshift(clean); shSaveMyPosts(arr); return clean; } function shUpdateMyPost(id, patch) { const arr = shGetMyPosts(); const idx = arr.findIndex(x => x.id === id); if (idx === -1) return; arr[idx] = Object.assign(arr[idx], patch || {}); shSaveMyPosts(arr); } function shDelMyPost(id) { shSaveMyPosts(shGetMyPosts().filter(x => x.id !== id)); } const SH_DRAFT_KEY = 'ocd_helper_sh_drafts_v1'; function shGetDrafts() { const arr = store.get(SH_DRAFT_KEY, []); return Array.isArray(arr) ? arr : []; } function shSaveDrafts(arr) { store.set(SH_DRAFT_KEY, (arr || []).slice(0, 50)); } function shAddDraft(d) { const arr = shGetDrafts(); const rec = Object.assign({ id: 'draft_' + Date.now().toString(36), ts: Date.now() }, d); arr.unshift(rec); shSaveDrafts(arr); return rec; } function shDelDraft(id) { shSaveDrafts(shGetDrafts().filter(x => x.id !== id)); } function shGetDraft(id) { return shGetDrafts().find(x => x.id === id) || null; } function shGetMyMagic() { try { const html = document.body.innerHTML; const m = html.match(/魔力值\s*<\/font>\s*[::]\s*([\d,.]+)/); if (m) return parseFloat(m[1].replace(/,/g, '')) || 0; const m2 = html.match(/魔力值\s*[::]\s*([\d,.]+)/); if (m2) return parseFloat(m2[1].replace(/,/g, '')) || 0; } catch (e) {} return 0; } function shPlatformsAll() { return [ { api: 'qobuz', name: 'Qobuz' }, { api: 'apple', name: 'Apple Music' }, { api: 'deezer', name: 'Deezer' }, { api: 'tidal', name: 'Tidal' }, { api: 'allmusic', name: 'AllMusic' }, { api: 'netease', name: '网易云音乐' }, { api: 'qq', name: 'QQ 音乐' }, { api: 'kugou', name: '酷狗音乐' }, { api: 'kuwo', name: '酷我音乐' }, ]; } function shParseTorrentTable(html, keyword, area) { const out = []; try { const doc = new DOMParser().parseFromString(html, 'text/html'); const dlLinks = doc.querySelectorAll( 'a[href*="download.php?id="], a[href*="/download.php?id="], a[href*="download.php?passkey"]' ); const seen = {}; const kwNorm = dupNormTitle(keyword); for (const dl of dlLinks) { const dlHref = dl.getAttribute('href') || ''; const idM = dlHref.match(/[?&]id=(\d+)/); if (!idM) continue; const id = idM[1]; if (seen[id]) continue; seen[id] = 1; const tr = dl.closest('tr'); if (!tr) continue; let title = ''; const tn = tr.querySelector('table.torrentname'); if (tn) { const titleA = tn.querySelector('a[href*="plugin_details.php?id="], a[href*="details.php?id="]'); if (titleA) { const h = titleA.getAttribute('href') || ''; if (h.indexOf('userdetails.php') === -1 && h.indexOf('dllist=') === -1) { title = titleA.textContent.replace(/\s+/g, ' ').trim(); } } } if (!title) { const candidates = tr.querySelectorAll('a[href*="plugin_details.php?id="], a[href*="details.php?id="]'); for (const a of candidates) { const h = a.getAttribute('href') || ''; if (h.indexOf('userdetails.php') !== -1) continue; if (h.indexOf('dllist=') !== -1) continue; const t = a.textContent.replace(/\s+/g, ' ').trim(); if (t) { title = t; break; } } } if (!title) continue; let level, score, reasons; const titleHasKw = kwNorm && dupNormTitle(title).indexOf(kwNorm) !== -1; if (area === 5) { if (titleHasKw) { level = 'partial'; score = 3; reasons = ['标题含关键词']; } else { level = 'suspect'; score = 0; reasons = ['标题未匹配(需验证曲目列表)']; } } else { const ana = dcAnalyzeHit(keyword, title, null); if (ana.level === 'clean') continue; level = ana.level; score = ana.score; reasons = ana.reasons; } const meta = dcExtractHitMeta(tr.outerHTML); out.push({ id, title, level, score, reasons, sizeText: meta.sizeText || '', promo: meta.promo || '', area, }); } } catch (e) { console.warn('[搜索求助] 站内解析异常:', e.message); } return out; } function shParseRequestTable(html) { const out = []; try { const doc = new DOMParser().parseFromString(html, 'text/html'); const links = doc.querySelectorAll('a[href*="viewrequests.php?action=view"]'); const seen = {}; for (const a of links) { const href = a.getAttribute('href') || ''; const idM = href.match(/[?&]id=(\d+)/); if (!idM) continue; const id = idM[1]; if (seen[id]) continue; const bEl = a.querySelector('b'); const title = (bEl ? bEl.textContent : a.textContent).trim(); if (!title) continue; const tr = a.closest('tr'); if (!tr) continue; const tds = tr.querySelectorAll('td'); let bonus = 0; let postTime = ''; let statusHtml = ''; for (let i = 0; i < tds.length; i++) { const td = tds[i]; const txt = td.textContent.trim(); if (!bonus) { const fEl = td.querySelector('font[color="#ff0000"], font[color="#FF0000"]'); if (fEl) { const numM = fEl.textContent.match(/\d+/); if (numM) bonus = Number(numM[0]); } } if (!postTime && /^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}$/.test(txt)) { postTime = txt; } if (/求种成功|有应求|求种中/.test(txt)) { statusHtml = txt; } } const status = shReqStatusOf(statusHtml); seen[id] = 1; out.push({ id, title, bonus, postTime, status }); } } catch (e) { console.warn('[搜索求助] 求助区解析异常:', e.message); } return out; } function buildShKeywords(artist, track, album, year, mode) { const main = mode === 'track' ? String(track || '').trim() : String(album || '').trim(); const a = String(artist || '').trim(); const y = String(year || '').trim(); const aVA = isVAArtist(a); const artistList = aVA ? [] : String(normArtist(a)).split(/[,,、;;\/]+/).map(x => x.trim()).filter(x => x.length >= 2); const list = []; const seen = {}; const push = (kw, tag) => { const k = String(kw || '').replace(/\s+/g, ' ').trim(); if (!k || seen[k]) return; seen[k] = 1; list.push({ kw: k, tag }); }; if (a && !aVA) push(a + ' ' + main, '全词'); if (artistList.length > 1) { artistList.forEach(an => push(an + ' ' + main, '分拆艺人')); } push(main, '仅主词'); if (y) push(y + ' ' + main, '年份+主词'); if (a && !aVA && y) push(a + ' ' + y, '艺人+年份'); if (artistList.length === 1 && artistList[0] !== a) { push(artistList[0] + ' ' + main, '规范艺人'); } return list; } async function shDupSearch(artist, track, album, year, mode) { const kws = buildShKeywords(artist, track, album, year, mode); const results = { state: 'clean', hits: [], note: '', kwUsed: '', kwTag: '' }; if (!kws.length) return results; const areas = mode === 'track' ? [5] : [0]; const allHits = []; const seenId = {}; let kwUsed = '', kwTag = ''; for (const k of kws) { let foundAny = false; for (const area of areas) { const kwEncoded = encodeURIComponent(k.kw).replace(/%20/g, '+'); const url = 'https://open.cd/torrents.php?cat408=1&incldead=1&spstate=0&inclbookmarked=0&boardid=0&artwork=0&log=0&seeders=&year=&option-torrents=0&search=' + kwEncoded + '&search_area=' + area + '&search_mode=0'; console.log('[搜索求助] 站内搜索:kw=' + k.kw + ' | tag=' + k.tag + ' | area=' + area + ' | url=' + url); try { const html = await gmFetchText(url, { Referer: 'https://open.cd/torrents.php' }); const found = shParseTorrentTable(html, k.kw, area); for (const h of found) { if (seenId[h.id]) continue; seenId[h.id] = 1; allHits.push(h); foundAny = true; } } catch (e) {} } if (foundAny && !kwUsed) { kwUsed = k.kw; kwTag = k.tag; } if (foundAny) break; } results.hits = allHits; results.kwUsed = kwUsed; results.kwTag = kwTag; results.mode = mode; results.mode = mode; const ORD = { certain: 0, partial: 1, suspect: 2 }; results.hits.sort((a, b) => (ORD[a.level] - ORD[b.level]) || (b.score - a.score)); if (results.hits.length) { results.state = 'dup'; const top = results.hits[0]; const lvCN = { certain: '确认重复', partial: '高度疑似', suspect: '疑似' }[top.level] || '疑似'; results.note = lvCN + '命中 ' + results.hits.length + ' 条:' + top.title.slice(0, 60); } return results; } async function shRequestSearch(artist, track, album, year, mode) { const kws = buildShKeywords(artist, track, album, year, mode); if (!kws.length) return { state: 'clean', posts: [] }; const all = []; const seenId = {}; let kwUsed = ''; outer: for (const k of kws) { const searchKw = (year && k.tag === '年份+主词') ? (year + ' ' + (mode === 'track' ? track : album)) : (mode === 'track' ? track : album); const kwEncoded = encodeURIComponent(searchKw).replace(/%20/g, '+'); for (const f of ['all', 'no', 'pending']) { const url = 'https://open.cd/viewrequests.php?keywords=' + kwEncoded + '&finished=' + f; try { const html = await gmFetchText(url, { Referer: 'https://open.cd/viewrequests.php' }); const posts = shParseRequestTable(html); for (const p of posts) { if (seenId[p.id]) continue; seenId[p.id] = 1; all.push(p); } if (all.length) { kwUsed = searchKw; break outer; } } catch (e) {} } if (k.tag !== '仅主词' && !all.length) { const mainOnly = mode === 'track' ? track : album; const mainEnc = encodeURIComponent(mainOnly).replace(/%20/g, '+'); const url = 'https://open.cd/viewrequests.php?keywords=' + mainEnc + '&finished=all'; try { const html = await gmFetchText(url, { Referer: 'https://open.cd/viewrequests.php' }); const posts = shParseRequestTable(html); for (const p of posts) { if (seenId[p.id]) continue; seenId[p.id] = 1; all.push(p); } if (all.length) { kwUsed = mainOnly; break outer; } } catch (e) {} } } const out = { state: 'clean', posts: [], kwUsed }; for (const p of all) { if (p.status === 'completed') continue; out.posts.push(p); } const ST_ORD = { pending: 0, responded: 1, completed: 2, unknown: 3 }; out.posts.sort((a, b) => (ST_ORD[a.status] - ST_ORD[b.status]) || (b.bonus - a.bonus)); if (out.posts.length) out.state = 'found'; return out; } const shPluginCache = {}; async function shFetchPluginDetail(id) { if (shPluginCache[id]) return shPluginCache[id]; const html = await gmFetchText('https://open.cd/plugin_details.php?id=' + id + '&hit=1', { Referer: 'https://open.cd/' }); const detail = shParsePluginDetail(html); shPluginCache[id] = detail; return detail; } function shHtmlToTextKeepBr(el) { if (!el) return ''; const clone = el.cloneNode(true); clone.querySelectorAll('br').forEach(br => br.replaceWith('\n')); clone.querySelectorAll('p, div, li, h1, h2, h3, h4, h5, h6, tr').forEach(b => { b.appendChild(document.createTextNode('\n')); }); clone.querySelectorAll('hr').forEach(hr => hr.replaceWith('\n')); let t = clone.textContent || ''; t = t.replace(/\u00a0/g, ' '); t = t.split('\n') .map(l => l.replace(/[ \t]+/g, ' ').replace(/^\s+|\s+$/g, '')) .join('\n'); t = t.replace(/\n{3,}/g, '\n\n'); return t.trim(); } function shParsePluginDetail(html) { const doc = new DOMParser().parseFromString(html, 'text/html'); const out = { cover: '', intro: '', introHtml: '', tracks: [], meta: {} }; const coverImg = doc.querySelector('div.cover img'); if (coverImg) { let src = coverImg.getAttribute('src') || ''; if (src && src.indexOf('http') !== 0) src = 'https://open.cd/' + src.replace(/^\//, ''); out.cover = src; } const descr = doc.querySelector('#divdescr'); if (descr) { let t = shHtmlToTextKeepBr(descr); t = t.replace(/([^\n])\s*◎\s*/g, '$1\n◎ '); t = t.replace(/\n◎ /g, '\n◎ '); t = t.replace(/\n{3,}/g, '\n\n').trim(); out.intro = t; if (typeof shIntroHtmlOfBox === 'function') out.introHtml = shIntroHtmlOfBox(descr); } const tl = doc.querySelector('#divtracklist'); if (tl) { out.tracks = shParseTrackFromDiv(tl); out.tracksHtml = tl.innerHTML; } doc.querySelectorAll('table.resource tr').forEach(tr => { const label = tr.querySelector('td.rowtitle'); if (!label) return; const val = label.nextElementSibling; if (!val) return; const key = label.textContent.replace(/[::]\s*$/, '').trim(); const text = val.textContent.replace(/\s+/g, ' ').trim(); if (key && text) out.meta[key] = text; }); return out; } function shTracksHtmlToText(html) { if (!html) return ''; const tmp = document.createElement('div'); tmp.innerHTML = html; tmp.querySelectorAll('br').forEach(br => br.replaceWith('\n')); tmp.querySelectorAll('p, div, li, tr').forEach(b => { b.appendChild(document.createTextNode('\n')); }); let t = tmp.textContent || ''; t = t.replace(/\u00a0/g, ' '); t = t.split('\n') .map(l => l.replace(/[ \t]+/g, ' ').replace(/^\s+|\s+$/g, '')) .join('\n'); t = t.replace(/\n{3,}/g, '\n\n'); return t.trim(); } function shParseTrackFromDiv(div) { const out = []; if (!div) return out; const clone = div.cloneNode(true); clone.querySelectorAll('br').forEach(br => br.replaceWith('\n')); clone.querySelectorAll('p, div, li, h1, h2, h3, h4, h5, h6, tr').forEach(b => { b.appendChild(document.createTextNode('\n')); }); const text = (clone.textContent || '').replace(/\u00a0/g, ' '); const lines = text.split('\n').map(s => s.replace(/[ \t]+/g, ' ').trim()); let curDisc = 0; let perDiscCounter = 0; const seen = {}; for (let raw of lines) { if (!raw) continue; if (/^[◆◇●○★☆=*#\s\-—·•]+$/.test(raw)) continue; /* 纯标题行(曲目列表 / Track List …) */ if (/^(专辑)?(曲目列表|歌曲列表|曲目信息|曲目|歌曲|音轨|Track\s*List|Tracks?)\s*[::]?\s*$/i.test(raw)) continue; /* 带注解的标题行:曲目列表(封底印的时长为印刷标注,Discogs 母带实测时长附后)—— 以前会漏过并被当成第 1 首 */ if (/^(专辑)?(曲目列表|歌曲列表|曲目信息|曲目|歌曲|音轨|Track\s*List|Tracks?)\s*[::]?\s*[((【\[][^))】\]]{2,120}[))】\]]?\s*[::]?\s*$/i.test(raw)) continue; /* 说明型长句兜底:含「封底/印刷/实测/附后/资料来源/说明/备注」等说明词,且整行没有任何时长 */ if (raw.length >= 16 && /(封底|印刷|实测|附后|资料来源|来源:|说明[::]|备注[::]|版权|致谢)/.test(raw) && !/\d{1,2}\s*[::]\s*\d{2}/.test(raw)) continue; if (/^[◆◇●○★☆=*#\-—·•]{2,}[\s\S]{1,20}?[◆◇●○★☆=*#\-—·•]{2,}$/.test(raw)) continue; raw = raw.replace(/^[◆◇●○★☆=*#\s\-—]+/, '').replace(/[◆◇●○★☆=*#\s\-—]+$/, '').trim(); if (!raw) continue; const dm0 = raw.match(/^\[?(?:Disc|碟|CD)\s*(\d+)\]?\s*[::]?\s*$/i); if (dm0) { curDisc = Number(dm0[1]); perDiscCounter = 0; continue; } const dmPrefix = raw.match(/^\[?(?:Disc|碟|CD)\s*(\d+)\]?\s*[-–—::]?\s*(.+)$/i); if (dmPrefix) { curDisc = Number(dmPrefix[1]); perDiscCounter = 0; raw = dmPrefix[2].trim(); if (!raw) continue; } /* ===== A2(移植自 V10.6.1):过滤 LOG / 元信息行,避免被编号成曲目 ===== */ /* 点线标签行:Playing Time......: / Total Size...........: —— 日志与元信息,绝不是曲目 */ if (/(?:\.{3,}|…{1,})\s*[::]/.test(raw)) continue; /* 日志 / 元信息关键词行(含冒号但没有时长,或整行就是这类声明) */ if (/^(Playing Time|Total Size|More rips|Can reupload|Ripper|Label|Catalog(ue)?\s*(No|Number)?|Source|Encoder|Grabber|Exact Audio Copy|X Lossless Decoder|Test CRC|Copy CRC|AccurateRip|Peak level|Track quality|Album gain|Album peak|Range quality|Read mode|Extraction|Compression|Produced by|Thanks to|Special thanks|Discogs|UPC|Barcode|Release Date|Original Release|没有修改|可以转载|禁止转载|转载|自购|自抓)/i.test(raw)) continue; /* 兜底:含冒号、不含任何时长、且不以「编号.」或「编号 -」开头 → 视为说明行 */ if (/[::]/.test(raw) && !/\d{1,2}\s*[::]\s*\d{2}/.test(raw) && !/^\d{1,3}\s*[.、.\-–—]/.test(raw)) continue; let no = 0, name = ''; const m = raw.match(/^(\d{1,3})\s*[.、.]\s*(.+)$/) || raw.match(/^(\d{1,3})\s+(.+)$/); if (m) { no = Number(m[1]); name = m[2].trim(); } else name = raw.trim(); /* 结尾时长清理:仅当它是“裸时长”时才去掉;若前面带「实测 / 标注 / 时长」等标签则保留 (例:Children (Radio Edit) — 封底标注 4:00|实测 4:04 → 不要删掉 4:04) */ { const tailTime = /\s+\d{1,2}[.::]\d{1,2}(?:\s*[~~\-—]\s*\d{1,2}[.::]\d{1,2})?\s*$/i; const before = name.replace(tailTime, ''); if (!/(实测|标注|时长|长度|试听|duration|length|time)\s*$/i.test(before)) name = before.trim(); } name = name.replace(/\s+\d{1,3}\s*$/, '').trim(); name = name.replace(/\s+\d{1,2}\s+\d{1,2}\s*$/, '').trim(); name = name.replace(/^[◆◇●○★☆=*#\s\-—]+/, '').replace(/[◆◇●○★☆=*#\s\-—]+$/, '').trim(); /* A2(移植自 V10.6.1):纯数字曲名保留 —— 像《Honeymoon》第 12 轨「24」是真实曲目,不能丢; 只有当整行本来就没有编号分隔(name 与 raw 相同)且只是一个数字时,才视为计数行跳过 */ if (/^\d{1,3}$/.test(name) && name === raw.trim()) continue; if (/^\d{1,2}[.::]\d{1,2}$/.test(name)) continue; if (!name) continue; if (/^\d{1,3}$/.test(name) && name === raw.trim()) continue; /* A2:仅整行就是数字才跳过(保留纯数字曲名「24」) */ if (/^\d{1,2}[.::]\d{1,2}$/.test(name)) continue; if (!no) { perDiscCounter++; no = perDiscCounter; } else { perDiscCounter = Math.max(perDiscCounter, no); } const key = curDisc + '|' + name; if (seen[key]) continue; seen[key] = 1; out.push({ disc: curDisc, no, name }); } /* 编号一致性修复:同一盘内出现重复 / 缺号 / 逆序时,按出现顺序重排(避免 01、01 这类重号) */ try { const byDisc = {}; out.forEach(x => { (byDisc[x.disc] = byDisc[x.disc] || []).push(x); }); Object.keys(byDisc).forEach((d) => { const list = byDisc[d]; const nos = list.map(x => Number(x.no) || 0); const uniq = new Set(nos.filter(n => n > 0)); const ordered = nos.every((n, i) => i === 0 || n >= nos[i - 1]); if (uniq.size !== nos.filter(n => n > 0).length || !ordered || nos.some(n => !n)) { list.forEach((x, i) => { x.no = i + 1; }); console.log('[Tracks] 编号已按顺序修正(盘 ' + d + ',' + list.length + ' 首)'); } }); } catch (e) {} return out; } let _shPopEl = null; function shGetThemeCls() { const mainP = document.getElementById('ocd-helper-panel') || document.getElementById('ocd-batch-panel'); if (mainP) { const c = Array.from(mainP.classList).find(x => x.indexOf('ocd-theme-') === 0); if (c) return c; } return 'ocd-theme-' + (store.get(THEME_KEY, 'dark') || 'dark'); } function shShowPop(anchorEl, title, bodyHtml, width) { if (_shPopEl) _shPopEl.remove(); const pop = document.createElement('div'); pop.id = 'ocd-sh-pop'; pop.className = shGetThemeCls(); pop.style.cssText = 'position:fixed;z-index:' + ocdTopZ(10) + ';background:var(--bg,#242632);color:var(--fg,#e8eaf0);' + 'border:1px solid #4a69bd;border-radius:10px;box-shadow:0 10px 30px rgba(0,0,0,.5);' + 'padding:0;min-width:280px;max-width:' + (width || 400) + 'px;max-height:70vh;display:flex;flex-direction:column;' + 'font:12px/1.6 -apple-system,"Microsoft YaHei",sans-serif;'; pop.innerHTML = '
' + ' ' + title + '' + ' ✕' + '
' + '
' + bodyHtml + '
'; document.body.appendChild(pop); _shPopEl = pop; const r = anchorEl.getBoundingClientRect(); let left = r.left; let top = r.bottom + 6; if (left + pop.offsetWidth > window.innerWidth - 8) left = window.innerWidth - pop.offsetWidth - 8; if (top + pop.offsetHeight > window.innerHeight - 8) top = r.top - pop.offsetHeight - 6; if (top < 8) top = 8; if (left < 8) left = 8; pop.style.left = left + 'px'; pop.style.top = top + 'px'; pop.querySelector('.sh-pop-close').addEventListener('click', () => { if (_shPopEl) { _shPopEl.remove(); _shPopEl = null; } }); setTimeout(() => { document.addEventListener('click', function tpClose(ev) { if (!document.getElementById('ocd-sh-pop')) { document.removeEventListener('click', tpClose); return; } if (!ev.target.closest('#ocd-sh-pop') && ev.target !== anchorEl && !anchorEl.contains(ev.target)) { if (_shPopEl) { _shPopEl.remove(); _shPopEl = null; } document.removeEventListener('click', tpClose); } }); }, 0); } let _shCoverFloat = null; function shShowCoverFloat(anchorEl, url) { shHideCoverFloat(); if (!url || !anchorEl || !anchorEl.isConnected) return; const r = anchorEl.getBoundingClientRect(); if (r.width === 0 || r.height === 0) return; if (r.bottom < 0 || r.top > window.innerHeight) return; const el = document.createElement('div'); el.id = 'ocd-sh-cover-float'; el.className = shGetThemeCls(); el.style.cssText = 'position:fixed;z-index:10000069;background:var(--bg,#242632);padding:6px;' + 'border:1px solid #4a69bd;border-radius:8px;box-shadow:0 10px 30px rgba(0,0,0,.5);pointer-events:none;'; el.innerHTML = ''; document.body.appendChild(el); let left = r.left; let top = r.bottom + 8; if (left + el.offsetWidth > window.innerWidth - 8) left = window.innerWidth - el.offsetWidth - 8; if (top + el.offsetHeight > window.innerHeight - 8) top = r.top - el.offsetHeight - 8; if (top < 8) top = 8; if (left < 8) left = 8; el.style.left = left + 'px'; el.style.top = top + 'px'; _shCoverFloat = el; if (!shShowCoverFloat._bound) { shShowCoverFloat._bound = true; document.addEventListener('mouseleave', () => { shHideCoverFloat(); }); } } function shHideCoverFloat() { if (_shCoverFloat) { _shCoverFloat.remove(); _shCoverFloat = null; } } function shRenderDupResult(container, result, modeArg) { /* V9.76.18(真机 bug m14220–m14222):容器可能已被清掉 → 直接返回 ✓ 绝不抛 TypeError 把整条搜索流程带走 ✗ */ if (!container) return; if (result.state === 'error') { container.innerHTML = '
❌ 站内查重失败:' + esc(result.note || '网络错误') + '
'; return; } if (result.state === 'clean') { container.innerHTML = '
✅ 站内未收录
'; return; } const isTrack = (modeArg || result.mode) === 'track'; let html = '
' + '
' + ' ▸' + ' ⚠️ 站内已有该资源(' + result.hits.length + ' 条)' + ' 点击展开' + '
' + ''; html += '
'; container.innerHTML = html; const dupHead = container.querySelector('.sh-dup-head'); const dupBody = container.querySelector('.sh-dup-body'); const dupArrow = container.querySelector('.sh-dup-arrow'); const dupToggleTxt = container.querySelector('.sh-dup-toggle-txt'); if (dupHead && dupBody) { dupHead.addEventListener('click', () => { const open = dupBody.style.display === 'none'; shHideCoverFloat(); dupBody.style.display = open ? 'block' : 'none'; if (dupArrow) dupArrow.textContent = open ? '▾' : '▸'; if (dupToggleTxt) dupToggleTxt.textContent = open ? '点击收起' : '点击展开'; }); } if (!document.getElementById('ocd-sh-hit-css')) { const css = document.createElement('style'); css.id = 'ocd-sh-hit-css'; css.textContent = '.sh-hit-btn{flex:0 0 auto;padding:1px 6px;cursor:pointer;border:1px solid #4a69bd;border-radius:5px;' + 'background:transparent;color:#4a69bd;font-size:10px;white-space:nowrap;}' + '.sh-hit-btn:hover{background:rgba(74,105,189,.15);}' + '.sh-hit-btn:disabled{opacity:.4;cursor:not-allowed;}'; document.head.appendChild(css); } container.querySelectorAll('.sh-hit-title').forEach(a => { let hovered = false; a.addEventListener('mouseenter', async () => { hovered = true; const id = a.dataset.id; if (!id) return; try { const detail = await shFetchPluginDetail(id); if (!hovered) return; const r = a.getBoundingClientRect(); if (r.width === 0 || r.height === 0) return; if (detail.cover) shShowCoverFloat(a, detail.cover); } catch (e) {} }); a.addEventListener('mouseleave', () => { hovered = false; shHideCoverFloat(); }); }); container.querySelectorAll('.sh-btn-track').forEach(btn => { btn.addEventListener('click', async () => { const id = btn.dataset.id; const kw = btn.dataset.kw || ''; if (!id) return; btn.disabled = true; const old = btn.textContent; btn.textContent = '…'; try { const detail = await shFetchPluginDetail(id); let bodyHtml = ''; if (!detail.tracks.length) { bodyHtml = '
未解析到曲目列表
'; } else { const doMatch = isTrack && kw; const kwNorm = doMatch ? normKey(kw) : ''; const kwWords = doMatch ? String(kw).split(/\s+/).map(w => normKey(w)).filter(w => w.length >= 2) : []; /* V9.75.6(跨平台审计 ✗ 用户 m03128):旧写法只用 t.no(= 碟内轨号 ✗)且无碟标记 ✗ → 多碟专辑重号/交错 ✗(detail.tracks 里 disc 是有的 ✓ 由 shParseTrackFromDiv 写入 ✓) → 顺序按 disc + no ✓ 多碟时每行加 [DiscN] ✓ 与 src\14 shRenderTracksToText 规则一致 ✓ */ const _trs = detail.tracks.slice().sort((a, b) => ((Number(a.disc) || 0) - (Number(b.disc) || 0)) || ((Number(a.no) || 0) - (Number(b.no) || 0))); const _dk = []; for (const _t of _trs) { const _d = Number(_t.disc) || 0; if (_d > 0 && _dk.indexOf(_d) < 0) _dk.push(_d); } const _multi = _dk.length > 1; bodyHtml = _trs.map(t => { const nmN = normKey(t.name); const hit = doMatch && ((kwNorm && nmN.indexOf(kwNorm) !== -1) || kwWords.some(w => nmN.indexOf(w) !== -1)); const _pre = _multi ? ('[Disc' + ((Number(t.disc) || 0) || 1) + '] ') : ''; return '
' + _pre + String(t.no).padStart(2, '0') + '. ' + esc(t.name) + (hit ? ' ← 匹配' : '') + '
'; }).join(''); } shShowPop(btn, '曲目列表(' + detail.tracks.length + ' 首)', bodyHtml, 420); } catch (e) { toastSafe('抓取曲目失败:' + (e.message || ''), 'err'); } btn.disabled = false; btn.textContent = old; }); }); container.querySelectorAll('.sh-btn-intro').forEach(btn => { btn.addEventListener('click', async () => { const id = btn.dataset.id; if (!id) return; btn.disabled = true; const old = btn.textContent; btn.textContent = '…'; try { const detail = await shFetchPluginDetail(id); const bodyHtml = detail.intro ? '
' + esc(detail.intro) + '
' : '
该种子未提供简介
'; shShowPop(btn, '简介', bodyHtml, 400); } catch (e) { toastSafe('抓取简介失败:' + (e.message || ''), 'err'); } btn.disabled = false; btn.textContent = old; }); }); container.querySelectorAll('.sh-btn-meta').forEach(btn => { btn.addEventListener('click', async () => { const id = btn.dataset.id; if (!id) return; btn.disabled = true; const old = btn.textContent; btn.textContent = '…'; try { const detail = await shFetchPluginDetail(id); const meta = detail.meta || {}; const order = ['专辑名称', '艺术家名', '发行年份', '大小', '文件数', '类型', '类别', '格式', '媒介', '制作组', '来自', '标签列表', '转载规则']; const rows = []; const seen = {}; for (const k of order) { if (meta[k]) { rows.push([k, meta[k]]); seen[k] = 1; } } for (const k of Object.keys(meta)) { if (!seen[k]) rows.push([k, meta[k]]); } if (!rows.length) { shShowPop(btn, '详细信息', '
未解析到详细信息
', 380); btn.disabled = false; btn.textContent = old; return; } const bodyHtml = (detail.cover ? '
' : '') + '
' + rows.map(([k, v]) => '' + '' + '' + '').join('') + '
' + esc(k) + ':' + esc(v) + '
'; shShowPop(btn, '详细信息', bodyHtml, 420); } catch (e) { toastSafe('抓取详情失败:' + (e.message || ''), 'err'); } btn.disabled = false; btn.textContent = old; }); }); } function shBuildPlatformQueue(parsed, preSelected) { const isChinese = /[\u4e00-\u9fff]/.test((parsed.artist || '') + (parsed.album || '')); const intlApis = ['qobuz', 'apple', 'deezer', 'tidal', 'allmusic']; const cnApis = ['netease', 'qq', 'kugou', 'kuwo']; const all = getPlatforms(); const byApi = {}; all.forEach(p => { if (!p.manual) byApi[p.api] = p; }); if (preSelected && preSelected !== 'auto' && !byApi[preSelected]) { const def = DEFAULT_PLATFORMS.find(x => x.api === preSelected); if (def) { byApi[preSelected] = Object.assign({}, def, { enabled: true }); console.log('[SearchHelp] 优先平台未在配置中,使用内置默认构造:', preSelected); } } const order = []; const seen = {}; const push = (api) => { if (!api || seen[api]) return; seen[api] = 1; const p = byApi[api]; if (p) order.push(p); }; if (preSelected && preSelected !== 'auto') push(preSelected); if (isChinese) { cnApis.forEach(push); } else { intlApis.forEach(push); cnApis.forEach(push); } return order; } async function shPlatformSearch(parsed, preSelected, onProgress) { const queue = shBuildPlatformQueue(parsed, preSelected); const qOk = platCircuitFilter(queue); const skipped = queue.length - qOk.length; const results = []; const attempted = {}; let primary = null; for (let i = 0; i < qOk.length; i++) { const p = qOk[i]; if (onProgress) onProgress('searching', p, i, qOk.length); let r = null; try { r = await searchAlbum(p, parsed.artist, parsed.album, parsed.year); } catch (e) { r = { error: e.message }; } if (!r || r.error || !r.url) { attempted[p.api] = { ok: false, err: (r && r.error) || '无匹配' }; if (onProgress) onProgress('fail', p, i, qOk.length, r && r.error); continue; } if (onProgress) onProgress('detail', p, i, qOk.length); let detail = null; try { detail = await fetchAlbumDetail(p, r, parsed); } catch (e) { detail = { cover: '', tracks: null, intro: null }; } if (detail && !detail.cover) { try { const c = await getCoverUrl(p, r, parsed); if (c) detail.cover = c; } catch (e) {} } const rec = { platform: p, hit: r, detail, quality: { hasUrl: true, hasCover: !!(detail && detail.cover), hasTracks: !!(detail && detail.tracks && detail.tracks.length), hasIntro: !!(detail && detail.intro), }}; results.push(rec); attempted[p.api] = { ok: true, rec }; if (onProgress) onProgress('hit', p, i, qOk.length); if (!primary && rec.quality.hasCover && rec.quality.hasTracks) { primary = rec; for (let j = i + 1; j < qOk.length; j++) { attempted[qOk[j].api] = { ok: false, err: '未尝试' }; } break; } } return { queue, skipped, results, primary, attempted, preSelected: preSelected || 'auto', parsed }; } async function shPlatformSearchOne(parsed, api) { const all = getPlatforms(); const p = all.find(x => x.api === api && !x.manual); if (!p) return null; let r = null; try { r = await searchAlbum(p, parsed.artist, parsed.album, parsed.year); } catch (e) { r = { error: e.message }; } if (!r || r.error || !r.url) return { platform: p, ok: false, err: (r && r.error) || '无匹配' }; let detail = null; try { detail = await fetchAlbumDetail(p, r, parsed); } catch (e) { detail = {}; } if (detail && !detail.cover) { try { const c = await getCoverUrl(p, r, parsed); if (c) detail.cover = c; } catch (e) {} } return { platform: p, ok: true, hit: r, detail, quality: { hasUrl: true, hasCover: !!(detail && detail.cover), hasTracks: !!(detail && detail.tracks && detail.tracks.length), hasIntro: !!(detail && detail.intro), }, }; } function shRenderRequestResult(container, result) { /* V9.76.18:同上 —— 没有容器就少画一块,别抛错 ✓ */ if (!container) return; if (result.state === 'error') { container.innerHTML = '
❌ 求助区查重失败
'; return; } if (result.state === 'clean') { container.innerHTML = '
✅ 求助区无相关帖子
'; return; } const ST = { pending: ['求种中', '#9aa3b2'], responded: ['有应求', '#f59e0b'], completed: ['求种成功', '#3fd68f'], unknown: ['未知', '#9aa3b2'], }; let html = '
' + '
' + ' ▸' + ' ⚠️ 求助区已有相关帖子(' + result.posts.length + ' 条)' + ' 点击展开' + '
' + ''; html += '
'; container.innerHTML = html; const reqHead = container.querySelector('.sh-req-head'); const reqBody = container.querySelector('.sh-req-body'); const reqArrow = container.querySelector('.sh-req-arrow'); const reqToggleTxt = container.querySelector('.sh-req-toggle-txt'); if (reqHead && reqBody) { reqHead.addEventListener('click', () => { const open = reqBody.style.display === 'none'; shHideCoverFloat(); reqBody.style.display = open ? 'block' : 'none'; if (reqArrow) reqArrow.textContent = open ? '▾' : '▸'; if (reqToggleTxt) reqToggleTxt.textContent = open ? '点击收起' : '点击展开'; }); } container.querySelectorAll('.sh-resp-btn').forEach(btn => { btn.addEventListener('click', () => { shQuickRespondDialog(btn.dataset.reqid, btn.dataset.title || ''); }); }); container.querySelectorAll('.sh-append-btn').forEach(btn => { btn.addEventListener('click', () => { const cb = parseInt(btn.dataset.bonus, 10); shOpenAppendDialog( btn.dataset.reqid, btn.dataset.title || '', Number.isInteger(cb) ? cb : 0 ); }); }); container.querySelectorAll('.sh-follow-btn').forEach(btn => { const reqId = btn.dataset.reqid; btn.addEventListener('click', () => { const id = btn.dataset.reqid; if (shIsFollow(id)) { shDelFollow(id); toastSafe('已取消关注 #' + id, 'ok'); } else { shAddFollow({ id, title: btn.dataset.title || '', bonus: parseInt(btn.dataset.bonus, 10) || 0, status: btn.dataset.status || 'unknown', postTime: btn.dataset.posttime || '', pubUid: btn.dataset.pubuid || '', }); toastSafe('⭐ 已关注,可在「我的求种」→「⭐ 我关注的」查看', 'ok'); } }); }); if (typeof shSyncFollowUI === 'function') { setTimeout(shSyncFollowUI, 0); } } function shRenderPlatformResult(container, platResult, parsed, mode) { const queue = platResult.queue || []; const attempted = platResult.attempted || {}; const results = platResult.results || []; if (!results.length) { let html = '
' + '
' + ' ❌ 所有平台均未命中' + ' ' + ' ' + '
' + '
' + '可能原因:
' + '· 曲目/专辑名拼写有误
' + '· 冷门资源,各平台未收录
' + '· 部分平台被临时熔断' + (platResult.skipped ? '
· ' + platResult.skipped + ' 个平台熔断中已跳过' : '') + '
'; container.innerHTML = html; const mb = container.querySelector('.sh-manual-btn'); if (mb) { mb.addEventListener('click', () => { shOpenAskDialog({ mode, artist: parsed.artist || '', album: parsed.album || '', year: parsed.year || '', platformHit: null, }); }); } return; } const primary = platResult.primary || results[0]; const p = primary.platform; const h = primary.hit; const d = primary.detail || {}; const tracks = Array.isArray(d.tracks) ? d.tracks : []; const intro = d.intro || ''; const kw = (mode === 'track') ? (parsed.album || '') : ''; const kwNorm = kw ? normKey(kw) : ''; const kwWords = kw ? String(kw).split(/\s+/).map(w => normKey(w)).filter(w => w.length >= 2) : []; let html = '
'; html += '
' + ' ✅ 平台解析成功' + (p && p.name ? ' · ' + esc(p.name) + '' : '') + '' + ' ' + ' ' + '
'; html += '
'; html += '
'; if (d.cover) { html += ''; } else { html += '
无封面
'; } html += '
'; const meta = d.meta || {}; const dispArtist = h.artist || meta.mainArtists || parsed.artist || ''; html += '
'; html += '
' + esc(h.name || '') + '
'; if (dispArtist) { html += '
by ' + esc(dispArtist) + '
'; } if (meta.releaseText || meta.label) { html += '
' + (meta.releaseText ? 'Released on ' + esc(meta.releaseText) : '') + (meta.label ? ' by ' + esc(meta.label) : '') + '
'; } else if (h.year) { html += '
年份:' + esc(h.year) + '
'; } if (meta.mainArtists && meta.mainArtists !== dispArtist) { html += '
Main artists: ' + esc(meta.mainArtists) + '
'; } if (meta.genre) { html += '
Genre: ' + esc(meta.genre) + '
'; } if (meta.quality && meta.quality.length) { const uniqQ = []; meta.quality.forEach(x => { if (x && uniqQ.indexOf(x) === -1) uniqQ.push(x); }); html += '
' + 'Available in ' + uniqQ.map(x => esc(x)).join(' ') + '
'; } html += '
'; html += '来源:' + esc(p.name) + ' ↗'; if (intro) { html += '专辑简介'; } else { html += '专辑简介(无)'; } if (tracks.length) { html += '曲目列表(' + tracks.length + ')'; } else { html += '曲目列表(无)'; } html += '
'; html += '
'; html += '
'; html += '
'; html += '
切换平台:
'; html += '
'; queue.forEach(pp => { const att = attempted[pp.api]; const isPrimary2 = pp.api === p.api; let cls = 'sh-plat-switch-btn'; let style = ''; if (isPrimary2) { style = 'border-color:#3fd68f;color:#3fd68f;background:rgba(63,214,143,.12);font-weight:700;'; } else if (att && att.ok) { style = 'border-color:#4a69bd;color:#4a69bd;background:rgba(74,105,189,.08);'; } else if (att && att.err === '未尝试') { style = 'border-color:var(--border,#444);color:var(--fg-muted,#9aa3b2);'; } else if (att) { style = 'border-color:var(--border,#444);color:var(--fg-muted,#9aa3b2);opacity:.6;'; } else { style = 'border-color:var(--border,#444);color:var(--fg-muted,#9aa3b2);'; } const title = isPrimary2 ? '当前展示平台' : (att && att.ok) ? '点击切换到此平台' : (att && att.err === '未尝试') ? '点击搜索此平台' : att ? '已尝试,未命中' : '点击搜索此平台'; html += ''; }); html += '
'; html += '
'; container.innerHTML = html; const introLink = container.querySelector('.sh-plat-intro'); if (introLink) introLink.addEventListener('click', () => { shShowPop(introLink, '专辑简介', '
' + esc(intro) + '
', 420); }); const trackLink = container.querySelector('.sh-plat-tracks'); if (trackLink) trackLink.addEventListener('click', () => { const bodyHtml = shRenderTracksToHtml(tracks); shShowPop(trackLink, '曲目列表(' + tracks.length + ' 首)', bodyHtml, 420); }); container.querySelectorAll('.sh-plat-switch-btn').forEach(btn => { btn.addEventListener('click', async () => { const api = btn.dataset.api; const att = attempted[api]; if (api === p.api) { toastSafe('已是当前展示平台', 'ok'); return; } if (att && att.ok) { const newResult = Object.assign({}, platResult, { primary: att.rec }); shRenderPlatformResult(container, newResult, parsed, mode); toastSafe('已切换到 ' + att.rec.platform.name, 'ok'); return; } if (att && att.err && att.err !== '未尝试') { toastSafe('该平台已尝试,未命中:' + att.err.slice(0, 40), 'err'); return; } btn.disabled = true; const old = btn.textContent; btn.textContent = '搜索中…'; try { const rec = await shPlatformSearchOne(parsed, api); if (rec && rec.ok) { attempted[api] = { ok: true, rec }; const newResult = Object.assign({}, platResult, { primary: rec, attempted }); shRenderPlatformResult(container, newResult, parsed, mode); toastSafe('已切换到 ' + rec.platform.name, 'ok'); } else { attempted[api] = { ok: false, err: (rec && rec.err) || '无匹配' }; const newResult = Object.assign({}, platResult, { attempted }); shRenderPlatformResult(container, newResult, parsed, mode); toastSafe(rec && rec.platform ? rec.platform.name : api + ' 未命中', 'err'); } } catch (e) { btn.disabled = false; btn.textContent = old; toastSafe('搜索失败:' + (e.message || ''), 'err'); } }); }); const askBtn = container.querySelector('.sh-final-ask'); if (askBtn) { askBtn.addEventListener('click', () => { shOpenAskDialog({ mode, artist: parsed.artist || '', album: parsed.album || '', year: parsed.year || '', platformHit: primary, }); }); } } /* ================================================================ * §37 一键求助:构建载荷 / 发新帖 / 追加悬赏 * ================================================================ */ function shBuildRequestPayload(opt) { const { mode, artist, album, year, platformHit, bonus } = opt; const prefix = [year, artist].filter(Boolean).join(' '); const title = '求种:' + (prefix ? prefix + ' - ' : '') + album; const lines = []; lines.push('【需求说明】'); if (mode === 'track') { lines.push('求 ' + (artist || '(未知艺人)') + ' 演唱的曲目《' + album + '》。'); } else { lines.push('求 ' + (artist || '(未知艺人)') + ' 的专辑《' + album + '》。'); } lines.push(''); lines.push('【参考来源】'); if (platformHit && platformHit.hit && platformHit.hit.url) { const pName = (platformHit.platform && platformHit.platform.name) || ''; const h = platformHit.hit; lines.push('· ' + pName + ':' + h.url); if (h.artist) lines.push('· 平台登记艺人:' + h.artist); if (h.year) lines.push('· 平台登记年份:' + h.year); } else { lines.push('(未在主流平台匹配到,请人工核实)'); } const tracks = (platformHit && platformHit.detail && platformHit.detail.tracks) || []; if (tracks.length) { lines.push(''); lines.push('【参考曲目】'); /* V9.75.3:曲目改走 disc-aware 渲染 ✓(多碟不再串列 ✓)*/ const _tt = shRenderTracksToText(tracks); if (_tt) _tt.split('\n').forEach(l => lines.push(l)); } return { title, body: lines.join('\n'), bonus }; } function shOpenAskDialog(opt) { console.log('[shAsk] 收到参数:', JSON.stringify({ mode: opt.mode, artist: opt.artist, album: opt.album, year: opt.year, hasPlatformHit: !!opt.platformHit, hitArtist: opt.platformHit && opt.platformHit.hit && opt.platformHit.hit.artist, hitName: opt.platformHit && opt.platformHit.hit && opt.platformHit.hit.name, hitYear: opt.platformHit && opt.platformHit.hit && opt.platformHit.hit.year, metaMainArtists: opt.platformHit && opt.platformHit.detail && opt.platformHit.detail.meta && opt.platformHit.detail.meta.mainArtists, metaYear: opt.platformHit && opt.platformHit.detail && opt.platformHit.detail.meta && opt.platformHit.detail.meta.year, })); const old = document.getElementById('ocd-ask-pop'); if (old) old.remove(); const cfg = shGetCfg(); const hit = opt.platformHit && opt.platformHit.hit; let _artist = (hit && hit.artist) || opt.artist || ''; let _year = (hit && hit.year) || opt.year || ''; let _album = (hit && hit.name) || opt.album || ''; if (_artist && /[\/,,、]/.test(_artist)) { const first = _artist.split(/[\/,,、]/)[0].trim(); if (first) _artist = first; } if (!_artist && _album && _album.indexOf(' - ') !== -1) { const parts = _album.split(' - '); if (parts.length === 2) { _artist = parts[0].trim(); _album = parts[1].trim(); } } if (!_year && _album) { const ym = _album.match(/\s+((?:19|20)\d{2})\s*$/); if (ym) { _year = ym[1]; _album = _album.slice(0, ym.index).trim(); } } const draft = shBuildRequestPayload({ mode: opt.mode, artist: _artist, album: _album, year: _year, platformHit: opt.platformHit || null, bonus: cfg.defaultBonus, }); if (opt._prefill) { if (typeof opt._prefill.title === 'string') draft.title = opt._prefill.title; if (typeof opt._prefill.body === 'string') draft.body = opt._prefill.body; if (Number.isInteger(opt._prefill.bonus)) draft.bonus = opt._prefill.bonus; } const mask = document.createElement('div'); mask.id = 'ocd-ask-pop'; mask.className = shGetThemeCls(); mask.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:' + ocdTopZ(20) + ';display:flex;align-items:center;justify-content:center;'; const win = document.createElement('div'); win.style.cssText = 'width:min(640px,94vw);max-height:90vh;display:flex;flex-direction:column;background:var(--bg,#242632);color:var(--fg,#e8eaf0);border:1px solid #4a69bd;border-radius:12px;padding:14px 16px;font:13px/1.6 -apple-system,"Microsoft YaHei",sans-serif;box-shadow:0 20px 60px rgba(0,0,0,.55);'; win.innerHTML = '
' + ' 📝 一键发布求种帖' + ' ✕' + '
' + '
' + ' 悬赏:' + ' ' + ' (≥100,另扣 100 手续费)' + '
' + '
' + ' 标题:' + ' ' + '
' + '
' + ' 正文:' + ' ' + '
' + '
' + ' ' + ' ' + ' ' + '
'; mask.appendChild(win); document.body.appendChild(mask); const $ = (id) => win.querySelector('#' + id); const close = () => mask.remove(); mask.addEventListener('click', (e) => { if (e.target === mask) close(); }); try { const _bEl = $('ocd-ask-bonus'); const _defReward = Math.max(100, Number((advGroup('ask') || {}).reward) || 100); if (_bEl && !(parseInt(_bEl.value, 10) >= 100)) { _bEl.value = String(_defReward); const tipEl0 = win.querySelector('#ocd-ask-bonus-tip'); if (tipEl0) tipEl0.textContent = '(≥100,另扣 100 手续费)· 默认 ' + _defReward + '(高级设置可改)'; } } catch (e) {} $('ocd-ask-close').addEventListener('click', close); $('ocd-ask-cancel').addEventListener('click', close); $('ocd-ask-save').addEventListener('click', () => { const bonus = parseInt($('ocd-ask-bonus').value, 10) || 0; const title = $('ocd-ask-title').value.trim(); const body = $('ocd-ask-body').value; if (!title && !body.trim()) { toastSafe('标题和正文都为空,无需保存', 'err'); return; } shAddDraft({ mode: opt.mode, title, body, bonus, artist: _artist, album: _album, year: _year, }); toastSafe('💾 草稿已保存', 'ok'); if (window.__shUpdateDraftCount) window.__shUpdateDraftCount(); if (typeof shRefreshDraftBox === 'function') shRefreshDraftBox(); }); const updateBonusTip = () => { const tipEl = win.querySelector('#ocd-ask-bonus-tip'); if (!tipEl) return; const n = parseInt($('ocd-ask-bonus').value, 10); if (!Number.isInteger(n) || n < 100) { tipEl.style.color = '#ff6b6b'; tipEl.textContent = '❌ 必须 ≥ 100'; } else { tipEl.style.color = 'var(--fg-muted,#9aa3b2)'; tipEl.textContent = '(≥100,另扣 100 手续费)'; } }; updateBonusTip(); let bodyEdited = !!(opt._prefill && typeof opt._prefill.body === 'string'); $('ocd-ask-body').addEventListener('input', () => { bodyEdited = true; }); const refreshDraft = () => { if (bodyEdited) return; const b = parseInt($('ocd-ask-bonus').value, 10); const nd = shBuildRequestPayload({ mode: opt.mode, artist: _artist, album: _album, year: _year, platformHit: opt.platformHit || null, bonus: Number.isInteger(b) ? b : 0, }); $('ocd-ask-body').value = nd.body; $('ocd-ask-title').value = nd.title; }; const bonusEl = $('ocd-ask-bonus'); bonusEl.addEventListener('input', () => { const raw = bonusEl.value; const cleaned = raw.replace(/[^\d]/g, ''); if (cleaned !== raw) { const pos = bonusEl.selectionStart - (raw.length - cleaned.length); bonusEl.value = cleaned; try { bonusEl.setSelectionRange(pos, pos); } catch (e) {} } updateBonusTip(); refreshDraft(); }); bonusEl.addEventListener('blur', () => { const n = parseInt(bonusEl.value, 10); if (bonusEl.value === '' || !Number.isInteger(n) || n < 100) { bonusEl.value = '100'; updateBonusTip(); refreshDraft(); } }); $('ocd-ask-submit').addEventListener('click', () => { const bonus = parseInt($('ocd-ask-bonus').value, 10); const title = $('ocd-ask-title').value.trim(); const body = $('ocd-ask-body').value; if (!title) { toastSafe('标题不能为空', 'err'); return; } if (!body.trim()) { toastSafe('正文不能为空', 'err'); return; } if (!Number.isInteger(bonus) || bonus < 100) { toastSafe('悬赏必须 ≥ 100 魔力值', 'err'); $('ocd-ask-bonus').focus(); return; } const snap = { mode: opt.mode, title, body, bonus, artist: _artist, album: _album, year: _year, platformHit: opt.platformHit || null, draftId: opt._draftId || null, }; close(); shConfirmPublish(snap); }); } async function shSubmitRequest(payload) { const formUrl = 'https://open.cd/viewrequests.php?action=new'; const postUrl = 'https://open.cd/viewrequests.php'; let token = null; try { const html = await gmFetchText(formUrl, { Referer: 'https://open.cd/viewrequests.php' }); const m = html.match(/]+name=["'](csrf|token|formhash|auth)["'][^>]*value=["']([^"']+)["']/i) || html.match(/]+value=["']([^"']+)["'][^>]*name=["'](csrf|token|formhash|auth)["']/i); if (m) { token = { name: m[1] || m[2], value: m[2] || m[1] }; } console.log('[ShRequest] 表单页已拉取,csrf:', token ? (token.name + '=' + token.value.slice(0, 8) + '…') : '无'); } catch (e) { console.warn('[ShRequest] 表单页拉取失败(继续尝试裸 POST):', e.message); } const fd = new URLSearchParams(); if (token) fd.append(token.name, token.value); fd.append('action', 'takeadded'); fd.append('request', payload.title); fd.append('amount', String(payload.bonus || 0)); fd.append('descr', payload.body); return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'POST', url: postUrl, headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Referer': formUrl, 'Origin': 'https://open.cd', }, data: fd.toString(), timeout: 20000, onload: (r) => { const resp = r.responseText || ''; if (r.status !== 200) return resolve({ ok: false, err: 'HTTP ' + r.status }); if (/]*>\s*成功\s*<\/h2>/i.test(resp)) { const idM = resp.match(/viewrequests\.php\?action=view&(?:amp;)?id=(\d+)/); let reqId = idM ? idM[1] : ''; if (reqId) { return resolve({ ok: true, reqId }); } shResolveReqIdAfterPost().then((rid) => { resolve({ ok: true, reqId: rid || '' }); }).catch(() => { resolve({ ok: true, reqId: '' }); }); return; } const errM = resp.match(/]*>\s*出错[了!]*\s*<\/h2>[\s\S]{0,800}?]*class=["']text["'][^>]*>([\s\S]{2,300}?)<\/td>/i); if (errM) return resolve({ ok: false, err: stripHtmlTags(errM[1]).slice(0, 200) || '发帖被拒绝' }); resolve({ ok: false, err: '未知响应(请 F12 查看)' }); }, onerror: () => resolve({ ok: false, err: '网络错误' }), ontimeout: () => resolve({ ok: false, err: '请求超时' }), }); }); } async function shAppendBonus(reqId, amount) { const n = parseInt(amount, 10); if (!reqId || !Number.isInteger(n) || n < 100) { return { ok: false, err: '追加悬赏金额需为 ≥100 的整数' }; } const myMagic = shGetMyMagic(); if (myMagic && n > myMagic) { return { ok: false, err: '魔力值不足(当前 ' + dpFmtNum(myMagic, 0) + ')' }; } const fd = new URLSearchParams(); fd.append('action', 'addamount'); fd.append('reqid', String(reqId)); fd.append('amount', String(n)); return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'POST', url: 'https://open.cd/viewrequests.php', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Referer': 'https://open.cd/viewrequests.php?action=view&id=' + reqId, 'Origin': 'https://open.cd', }, data: fd.toString(), timeout: 20000, onload: (r) => { const resp = r.responseText || ''; if (r.status !== 200) return resolve({ ok: false, err: 'HTTP ' + r.status }); if (/]*>\s*成功\s*<\/h2>/i.test(resp)) return resolve({ ok: true }); const errM = resp.match(/]*>\s*出错[了!]*\s*<\/h2>[\s\S]{0,800}?]*class=["']text["'][^>]*>([\s\S]{2,300}?)<\/td>/i); if (errM) return resolve({ ok: false, err: stripHtmlTags(errM[1]).slice(0, 200) }); resolve({ ok: false, err: '未知响应' }); }, onerror: () => resolve({ ok: false, err: '网络错误' }), ontimeout: () => resolve({ ok: false, err: '请求超时' }), }); }); } async function shFetchEditForm(reqId) { const url = 'https://open.cd/viewrequests.php?action=edit&id=' + reqId; const html = await gmFetchText(url, { Referer: 'https://open.cd/viewrequests.php' }); const doc = new DOMParser().parseFromString(html, 'text/html'); let form = null; for (const f of doc.querySelectorAll('form')) { if (f.querySelector('[name="descr"], [name="amount"], [name="request"]')) { form = f; break; } } if (!form) throw new Error('未找到编辑表单(页面结构可能变化)'); const hidden = []; for (const el of form.querySelectorAll('input[type="hidden"]')) { if (el.name) hidden.push({ name: el.name, value: el.value || '' }); } const fields = {}; for (const name of ['request', 'amount', 'descr']) { const el = form.querySelector('[name="' + name + '"]'); if (el) fields[name] = el.value || ''; } console.log('[Edit] 编辑页已拉取 | action:', form.getAttribute('action'), '| 隐藏字段:', hidden.length, '| 字段:', Object.keys(fields).join(',')); return { formAction: form.getAttribute('action') || '', hidden, fields, rawHtml: html }; } async function shSubmitEdit(reqId, formAction, hidden, data) { const fd = new URLSearchParams(); for (const h of hidden) fd.append(h.name, h.value); if (!fd.has('action')) fd.append('action', 'takeedit'); fd.set('request', data.title || ''); fd.set('descr', data.body || ''); if (data.hasAmount) fd.set('amount', String(data.bonus || 0)); console.log('[Edit] POST body:', [...fd.entries()].map(([k, v]) => k + '=' + String(v).slice(0, 30)).join(' & ')); const postUrl = 'https://open.cd/' + (formAction || 'viewrequests.php?action=edit&id=' + reqId).replace(/^\//, ''); return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'POST', url: postUrl, headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Referer': 'https://open.cd/viewrequests.php?action=edit&id=' + reqId, 'Origin': 'https://open.cd', }, data: fd.toString(), timeout: 20000, onload: (r) => { const resp = r.responseText || ''; if (r.status !== 200) return resolve({ ok: false, err: 'HTTP ' + r.status }); if (/]*>\s*成功\s*<\/h2>/i.test(resp)) return resolve({ ok: true }); const errM = resp.match(/]*>\s*出错[了!]*\s*<\/h2>[\s\S]{0,800}?]*class=["']text["'][^>]*>([\s\S]{2,300}?)<\/td>/i); if (errM) return resolve({ ok: false, err: stripHtmlTags(errM[1]).slice(0, 200) }); if (/action=edit/i.test(resp) && /name=["']?descr/.test(resp)) { return resolve({ ok: false, err: '提交未生效(页面结构可能变化)' }); } resolve({ ok: false, err: '未知响应' }); }, onerror: () => resolve({ ok: false, err: '网络错误' }), ontimeout: () => resolve({ ok: false, err: '请求超时' }), }); }); } async function shOpenEditDialog(reqId, localTitle) { const old = document.getElementById('ocd-edit-pop'); if (old) old.remove(); const mask = document.createElement('div'); mask.id = 'ocd-edit-pop'; mask.className = shGetThemeCls(); mask.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:' + ocdTopZ(20) + ';display:flex;align-items:center;justify-content:center;'; const win = document.createElement('div'); win.style.cssText = 'width:min(680px,94vw);max-height:90vh;display:flex;flex-direction:column;background:var(--bg,#242632);color:var(--fg,#e8eaf0);border:1px solid #4a69bd;border-radius:12px;padding:14px 16px;font:13px/1.6 -apple-system,"Microsoft YaHei",sans-serif;box-shadow:0 20px 60px rgba(0,0,0,.55);'; win.innerHTML = '
' + ' ✏️ 编辑求种帖 #' + esc(reqId) + '' + ' ✕' + '
' + '
' + '
' + '
⏳
' + ' 正在加载编辑表单…' + '
' + '
'; mask.appendChild(win); document.body.appendChild(mask); const $ = (id) => win.querySelector('#' + id); const close = () => mask.remove(); mask.addEventListener('click', (e) => { if (e.target === mask) close(); }); $('ocd-edit-close').addEventListener('click', close); try { const info = await shFetchEditForm(reqId); const f = info.fields; const hasAmount = (f.amount !== undefined && f.amount !== null && f.amount !== ''); const bonusRowHtml = hasAmount ? '
' + ' 悬赏:' + ' ' + ' 魔力值(≥100)' + '
' : '
ⓘ 本站编辑页不支持直接改悬赏,请使用「💰 追加悬赏」
'; $('ocd-edit-body').innerHTML = '
' + bonusRowHtml + '
' + ' 标题:' + ' ' + '
' + '
' + ' 正文:' + ' ' + '
' + '
' + '
' + ' ' + ' ' + '
'; $('ocd-edit-body').style.cssText = 'flex:1;min-height:260px;overflow-y:auto;display:flex;flex-direction:column;'; $('ocd-edit-cancel').addEventListener('click', close); $('ocd-edit-submit').addEventListener('click', async () => { const btn = $('ocd-edit-submit'); const title = $('ocd-edit-title').value.trim(); const body = $('ocd-edit-body-text').value; let bonus = 0; if (hasAmount) { bonus = parseInt($('ocd-edit-bonus').value, 10); if (!Number.isInteger(bonus) || bonus < 100) { toastSafe('悬赏必须 ≥ 100', 'err'); return; } } if (!title) { toastSafe('标题不能为空', 'err'); return; } if (!body.trim()) { toastSafe('正文不能为空', 'err'); return; } console.log('[Edit] 提交 | title:', title.slice(0, 30), '| body 长度:', body.length, '| bonus:', bonus, '| hasAmount:', hasAmount); btn.disabled = true; btn.textContent = '⏳ 保存中…'; const r = await shSubmitEdit(reqId, info.formAction, info.hidden, { title, body, bonus, hasAmount }); if (r.ok) { const patch = { title, _ts: Date.now() }; if (hasAmount) patch.bonus = bonus; shUpdateMyPost(reqId, patch); if (hasAmount) shUpdateBonusInDOM(reqId, bonus); close(); toastSafe('✅ 帖子已更新', 'ok'); } else { btn.disabled = false; btn.textContent = '✓ 保存修改'; toastSafe('❌ 保存失败:' + r.err, 'err'); } }); } catch (e) { $('ocd-edit-body').innerHTML = '
' + '❌ 加载失败:' + esc(e.message || '未知错误') + '
'; } } function shOpenAppendDialog(reqId, reqTitle, currentBonus) { const old = document.getElementById('ocd-append-pop'); if (old) old.remove(); const cfg = shGetCfg(); const myMagic = shGetMyMagic(); const mask = document.createElement('div'); mask.id = 'ocd-append-pop'; mask.className = shGetThemeCls(); mask.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:' + ocdTopZ(20) + ';display:flex;align-items:center;justify-content:center;'; const win = document.createElement('div'); win.style.cssText = 'width:min(420px,92vw);background:var(--bg,#242632);color:var(--fg,#e8eaf0);border:1px solid #f59e0b;border-radius:12px;padding:14px 16px;font:13px/1.6 -apple-system,"Microsoft YaHei",sans-serif;box-shadow:0 20px 60px rgba(0,0,0,.55);'; win.innerHTML = '
' + ' 💰 追加悬赏' + ' ✕' + '
' + '
帖子:' + esc(reqTitle || ('#' + reqId)) + '
' + (Number.isFinite(currentBonus) && currentBonus > 0 ? '
当前悬赏值:' + esc(String(currentBonus)) + ' 魔力值
' : '
当前悬赏值:未获取
') + '
' + ' 追加金额:' + ' ' + ' 魔力值' + '
' + '
' + '
' + ' · 每次追加不少于 100 魔力值
' + ' · 每次追加额外扣 50 魔力值手续费
' + (myMagic ? ' · 你的魔力值:' + esc(dpFmtNum(myMagic, 0)) + '' : '') + '
' + '
' + ' ' + ' ' + '
'; mask.appendChild(win); document.body.appendChild(mask); const $ = (id) => win.querySelector('#' + id); const close = () => mask.remove(); mask.addEventListener('click', (e) => { if (e.target === mask) close(); }); const updateAmtTip = () => { const tipEl = win.querySelector('#ocd-append-amt-tip'); if (!tipEl) return; const n = parseInt($('ocd-append-amt').value, 10); if (!Number.isInteger(n) || n < 100) { tipEl.style.color = '#ff6b6b'; tipEl.textContent = '❌ 最低 100 魔力值'; } else { tipEl.style.color = 'var(--fg-muted,#9aa3b2)'; tipEl.textContent = '✓ 合计扣减 ' + (n + 50) + ' 魔力值(含 50 手续费)'; } }; const amtEl = $('ocd-append-amt'); amtEl.addEventListener('input', () => { const raw = amtEl.value; const cleaned = raw.replace(/[^\d]/g, ''); if (cleaned !== raw) { const pos = amtEl.selectionStart - (raw.length - cleaned.length); amtEl.value = cleaned; try { amtEl.setSelectionRange(pos, pos); } catch (e) {} } updateAmtTip(); }); amtEl.addEventListener('blur', () => { const n = parseInt(amtEl.value, 10); if (amtEl.value === '' || !Number.isInteger(n) || n < 100) { amtEl.value = '100'; updateAmtTip(); } }); updateAmtTip(); $('ocd-append-close').addEventListener('click', close); $('ocd-append-cancel').addEventListener('click', close); $('ocd-append-submit').addEventListener('click', () => { const amt = parseInt($('ocd-append-amt').value, 10); if (!Number.isInteger(amt) || amt < 100) { const el = $('ocd-append-amt'); el.focus(); el.style.transition = 'none'; el.style.borderColor = '#ff6b6b'; el.style.transform = 'translateX(-4px)'; setTimeout(() => { el.style.transition = 'transform .08s, border-color .3s'; el.style.transform = 'translateX(4px)'; }, 60); setTimeout(() => { el.style.transform = 'translateX(0)'; }, 140); setTimeout(() => { el.style.borderColor = ''; }, 800); toastSafe('⚠️ 追加金额需 ≥ 100 魔力值', 'err'); return; } close(); shConfirmAppend(reqId, reqTitle, amt); }); } /* ================================================================ * §38 我的求种:辅助函数 * ================================================================ */ function shGetMyUid() { const a = document.querySelector('.infos-bar a[href*="userdetails.php?id="]'); if (a) { const m = (a.getAttribute('href') || '').match(/id=(\d+)/); if (m) return m[1]; } return String(store.get('ocd_helper_dp_uid_v1', '') || ''); } function shParseRequestList(html) { const doc = new DOMParser().parseFromString(html, 'text/html'); const out = []; const seenId = {}; for (const tr of doc.querySelectorAll('tr')) { let title = '', reqId = ''; const anchors = tr.querySelectorAll('a'); for (const a of anchors) { const href = a.getAttribute('href') || ''; const m = href.match(/viewrequests\.php\?action=view&(?:amp;)?id=(\d+)/); if (m) { reqId = m[1]; title = (a.textContent || '').trim(); break; } } if (!reqId || !title) continue; if (seenId[reqId]) continue; let pubUid = ''; for (const a of anchors) { const m = (a.getAttribute('href') || '').match(/userdetails\.php\?id=(\d+)/); if (m) { pubUid = m[1]; break; } } let bonus = 0; const tds = tr.querySelectorAll('td'); const bonusHit = shFindBonusEl(tds); if (bonusHit && bonusHit.bonus) bonus = bonusHit.bonus; let postTime = ''; for (const td of tds) { const t = (td.textContent || '').trim(); if (/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}$/.test(t)) { postTime = t; break; } } const lastTd = tds[tds.length - 1]; const statusTxt = lastTd ? (lastTd.textContent || '').trim() : ''; let status = 'unknown'; if (/求种成功/.test(statusTxt)) status = 'completed'; else if (/有应求/.test(statusTxt)) status = 'responded'; else if (/求种中/.test(statusTxt)) status = 'pending'; seenId[reqId] = 1; out.push({ id: reqId, title, bonus, postTime, status, pubUid }); } return out; } async function shResolveReqIdAfterPost() { try { const myUid = shGetMyUid(); if (!myUid) { console.warn('[Ask] 无 UID,无法兜底解析 reqId'); return ''; } console.log('[Ask] 抓取列表页解析 reqId…'); const html = await gmFetchText('https://open.cd/viewrequests.php?finished=all', { Referer: 'https://open.cd/viewrequests.php', }); const all = shParseRequestList(html); const mine = all.filter(r => r.pubUid === myUid); if (!mine.length) { console.warn('[Ask] 列表页未找到我的帖子'); return ''; } mine.sort((a, b) => (b.postTime || '').localeCompare(a.postTime || '')); console.log('[Ask] 兜底解析到最新帖子:', mine[0].id, mine[0].title.slice(0, 30)); return mine[0].id; } catch (e) { console.warn('[Ask] 兜底解析异常:', e.message); return ''; } } let _autoSyncBusy = false; async function shAutoSyncReqLists(force) { const cfg = shGetSyncCfg(); if (!cfg.enabled) { if (force) toastSafe('自动同步已关闭(点 ⚙ 开启)', 'err'); return { skipped: 'disabled' }; } if (!force && cfg.lastAuto && (Date.now() - cfg.lastAuto) < cfg.gapMin * 60000) return { skipped: 'gap' }; if (_autoSyncBusy) return { skipped: 'busy' }; _autoSyncBusy = true; const out = { mine: 0, myres: 0, pages: 0, added: 0, err: '' }; try { if (cfg.autoMine) { const before = shGetMyPosts().length; const r = await shFetchSiteReqList('mine', cfg.maxPages, cfg.limit); out.mine = r.rows.length; out.pages += r.pages || 0; out.added += Math.max(0, r.rows.length - before); shSaveMyPosts(r.rows); } if (cfg.autoMyres) { const before = shGetMyRes().length; const r = await shFetchSiteReqList('myres', cfg.maxPages, cfg.limit); out.myres = r.rows.length; out.pages += r.pages || 0; out.added += Math.max(0, r.rows.length - before); shSaveMyRes(r.rows); } shSaveSyncCfg(Object.assign({}, cfg, { lastAuto: Date.now() })); console.log('[AutoSync] 我的求种 ' + out.mine + ' 条 · 我的应求 ' + out.myres + ' 条 · 新增 ' + out.added + '(每条上限 ' + cfg.maxPages + ' 页)'); if (out.added > 0) { try { const np = shNotifyPrefs(); np.items = [{ id: 'reqsync-' + Date.now(), kind: 'req', ts: Date.now(), title: '求种列表自动同步:新增 ' + out.added + ' 条', detail: '我的求种 ' + out.mine + ' · 我的应求 ' + out.myres, url: 'https://open.cd/viewrequests.php?finished=mine' }].concat(np.items || []).slice(0, 60); shNotifySave({ items: np.items }); } catch (e) {} toastSafe('🔄 自动同步完成:新增 ' + out.added + ' 条(我的求种 ' + out.mine + ' / 我的应求 ' + out.myres + ')', 'ok'); } } catch (e) { out.err = (e && e.message) || String(e); console.warn('[AutoSync] 失败:', out.err); if (force) toastSafe('自动同步失败:' + out.err, 'err'); } _autoSyncBusy = false; return out; } function shOpenSyncSettings(onSaved) { const old = document.getElementById('ocd-sync-cfg'); if (old) { old.remove(); return; } const cfg = shGetSyncCfg(); const mask = document.createElement('div'); mask.id = 'ocd-sync-cfg'; mask.className = shGetThemeCls(); mask.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:' + ocdTopZ(20) + ';display:flex;align-items:center;justify-content:center;'; const win = document.createElement('div'); win.style.cssText = 'width:min(420px,92vw);background:var(--bg,#242632);color:var(--fg,#e8eaf0);border:1px solid #4a69bd;border-radius:12px;padding:16px 18px;font:13px/1.7 -apple-system,"Microsoft YaHei",sans-serif;box-shadow:0 20px 60px rgba(0,0,0,.55);'; win.innerHTML = '
' + ' ⚙ 自动同步设置' + '
' + '' + '
同步哪些列表:
' + '
' + ' ' + ' ' + '
' + '
' + ' 自动同步页数:' + ' ' + ' 页/列表(手动刷新不受限,最多 20 页)' + '
' + '
' + ' · 关闭后,仅在点「⟳ 刷新状态」时手动更新
' + ' · 开启后:打开面板会后台同步状态;页面打开 60 秒后与每 5 分钟检查一次列表(未到「间隔」会跳过)
' + ' · 自动同步默认只抓前几页,避免频繁请求;手动「⟳ 刷新状态」会抓全(最多 20 页)
' + ' · 自动同步发现新增会写入通知中心' + '
' + '
' + ' 更新间隔:' + ' ' + ' 分钟' + '
' + '
' + ' 单次上限:' + ' ' + ' 条帖子(0 = 不限)' + '
' + '
' + ' ' + ' ' + '
'; mask.appendChild(win); document.body.appendChild(mask); const $ = (id) => win.querySelector('#' + id); const close = () => mask.remove(); mask.addEventListener('click', (e) => { if (e.target === mask) close(); }); $('ocd-sync-cancel').addEventListener('click', close); $('ocd-sync-save').addEventListener('click', () => { const newCfg = { enabled: $('ocd-sync-en').checked, gapMin: parseInt($('ocd-sync-gap').value, 10) || 5, limit: parseInt($('ocd-sync-limit').value, 10) || 20, autoMine: $('ocd-sync-mine').checked, autoMyres: $('ocd-sync-myres').checked, maxPages: parseInt($('ocd-sync-pages').value, 10) || 3, }; shSaveSyncCfg(newCfg); close(); toastSafe('✅ 设置已保存' + (newCfg.enabled ? '(已开启自动同步)' : '(已关闭自动同步)'), 'ok'); if (typeof onSaved === 'function') onSaved(); }); } function shFindBonusEl(tds) { if (!tds || !tds.length) return null; const pick = (el) => { const digits = (el.textContent || '').replace(/\D/g, ''); if (!digits || digits.length > 7) return null; return { el: el, bonus: parseInt(digits, 10) || 0 }; }; if (tds[1]) { for (const f of tds[1].querySelectorAll('font')) { const c = (f.getAttribute('color') || '').toLowerCase(); if (c === '#ff0000' || c === 'red') { const r = pick(f); if (r) return r; } } } for (const td of tds) { for (const el of td.querySelectorAll('font, span, b, strong')) { const c = (el.getAttribute('color') || '').toLowerCase(); const st = (el.getAttribute('style') || '').toLowerCase(); if (c !== '#ff0000' && c !== 'red' && !/color\s*:\s*(red|#ff0000|#f00)\b/.test(st)) continue; const r = pick(el); if (r) return r; } } for (let i = 1; i < Math.min(tds.length, 5); i++) { const td = tds[i]; const txt = (td.textContent || '').replace(/\s+/g, ' ').trim(); const m = txt.match(/^([\d,,.]{1,7})\s*(?:魔力值|金币|魔力|个)?$/); if (!m) continue; const digits = m[1].replace(/\D/g, ''); if (!digits || digits.length > 7) continue; return { el: td, bonus: parseInt(digits, 10) || 0, fallback: 'number-cell:' + i }; } if (tds[1]) return { el: tds[1], bonus: 0, fallback: 'second-cell' }; return null; } function shInjectAppendButtonsOnList() { if (!/viewrequests\.php/i.test(location.pathname)) return; if (/[?&]action=view/i.test(location.search)) return; const rows = document.querySelectorAll('tr'); let added = 0; for (const tr of rows) { const titleLink = tr.querySelector('a[href*="viewrequests.php?action=view&id="]'); if (!titleLink) continue; const tds = tr.querySelectorAll('td'); if (tds.length < 4) continue; const rowStatusTxt = String(tr.textContent || '').replace(/s+/g, ''); const completedRow = /求种成功/.test(rowStatusTxt); if (completedRow) { const stale = tr.querySelectorAll('.ocd-site-append'); if (stale.length) { stale.forEach(el => el.remove()); console.log('[ReqList] 该行已求种成功,移除追加按钮'); } continue; } const bonusEl = shFindBonusEl(tds); if (!bonusEl) continue; const redFont = bonusEl.el; if (bonusEl.fallback) console.log('[ReqList] #' + (titleLink.getAttribute('href') || '').replace(/\D/g, '') + ' 未识别到红色出价字体,按兜底方式注入追加按钮(' + bonusEl.fallback + ',当前值 ' + bonusEl.bonus + ')'); const allViewLinks = tr.querySelectorAll('a[href*="viewrequests.php?action=view&id="]'); if (allViewLinks[0] !== titleLink) continue; const m = (titleLink.getAttribute('href') || '').match(/id=(\d+)/); if (!m) continue; const reqId = m[1]; if (tr.querySelector('.ocd-site-append[data-reqid="' + reqId + '"]')) continue; const title = (titleLink.textContent || '').trim(); const bonus = bonusEl.bonus; const btn = document.createElement('button'); btn.className = 'ocd-site-append'; btn.dataset.reqid = reqId; btn.textContent = '💰 追加'; btn.title = '为该求种帖追加悬赏(≥100 魔力值,额外扣 50 手续费)' + (bonusEl.fallback && !bonusEl.bonus ? '(未识别到当前出价,按实际填写值追加)' : ''); btn.style.cssText = 'display:inline-block;flex:0 0 auto;padding:0 6px;cursor:pointer;border:1px solid #f59e0b;border-radius:4px;background:transparent;color:#f59e0b;font-size:11px;line-height:1.5;vertical-align:middle;user-select:none;white-space:nowrap;'; btn.addEventListener('mouseenter', () => { btn.style.background = 'rgba(245,158,11,.12)'; }); btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent'; }); btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); shOpenAppendDialog(reqId, title, bonus); }); if (redFont && redFont.tagName === 'TD') { const td = redFont; td.style.whiteSpace = 'nowrap'; let wrap = td.querySelector('.ocd-bid-wrap'); if (!wrap) { const inner = document.createElement('span'); inner.style.cssText = 'min-width:0;'; while (td.firstChild) inner.appendChild(td.firstChild); wrap = document.createElement('span'); wrap.className = 'ocd-bid-wrap'; wrap.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:6px;width:100%;line-height:1;'; wrap.appendChild(btn); wrap.appendChild(inner); td.appendChild(wrap); } else if (btn.parentNode !== wrap) { wrap.insertBefore(btn, wrap.firstChild); } btn.style.marginLeft = '0'; btn.style.verticalAlign = 'middle'; added++; continue; } const numTd = redFont.closest('td'); const host = numTd || redFont.parentNode; if (numTd) numTd.style.whiteSpace = 'nowrap'; if (host) { let hasExtra = false; for (const el of host.children) { if (el !== redFont && !(el.classList && el.classList.contains('ocd-bid-wrap'))) { hasExtra = true; break; } } if (!hasExtra) { const rest = (host.textContent || '').replace(/\s+/g, ' ').replace((redFont.textContent || '').trim(), '').trim(); if (rest) hasExtra = true; } let wrap = null; for (const ch of host.children) { if (ch.classList && ch.classList.contains('ocd-bid-wrap')) { wrap = ch; break; } } if (!wrap) { wrap = document.createElement('span'); wrap.className = 'ocd-bid-wrap'; wrap.style.cssText = hasExtra ? 'display:inline-flex;align-items:center;gap:6px;vertical-align:middle;line-height:1;' : 'display:flex;align-items:center;justify-content:space-between;gap:6px;width:100%;line-height:1;'; host.insertBefore(wrap, redFont); wrap.appendChild(btn); wrap.appendChild(redFont); } else if (btn.parentNode !== wrap) { wrap.insertBefore(btn, wrap.firstChild); } btn.style.marginLeft = '0'; btn.style.verticalAlign = 'middle'; } added++; } if (added) console.log('[SiteAppend] 已注入 ' + added + ' 个悬赏追加按钮'); } function shInjectFollowButtonsOnSite() { if (!/viewrequests\.php/i.test(location.pathname)) return; if (/[?&]action=view/i.test(location.search)) return; const links = document.querySelectorAll('a[href*="viewrequests.php?action=view&id="]'); let added = 0; for (const link of links) { if (link.dataset.shFollowInjected === '1') continue; const tr = link.closest('tr'); if (!tr) continue; const tds = tr.querySelectorAll('td'); if (tds.length < 6) continue; let bonus = 0, isListRow = false; for (const f of tds[1].querySelectorAll('font')) { const c = (f.getAttribute('color') || '').toLowerCase(); if (c === '#ff0000') { isListRow = true; const n = parseInt((f.textContent || '').replace(/\D/g, ''), 10); if (n) bonus = n; break; } } if (!isListRow) continue; const allViewLinks = tr.querySelectorAll('a[href*="viewrequests.php?action=view&id="]'); if (allViewLinks[0] !== link) continue; const m = (link.getAttribute('href') || '').match(/id=(\d+)/); if (!m) continue; const reqId = m[1]; link.dataset.shFollowInjected = '1'; const btn = document.createElement('span'); btn.className = 'sh-site-follow'; btn.dataset.reqid = reqId; btn.style.cssText = 'display:inline-block;margin-left:6px;padding:0 6px;border:1px solid #a259ff;border-radius:4px;color:#a259ff;font-size:11px;cursor:pointer;user-select:none;vertical-align:middle;line-height:1.5;white-space:nowrap;'; const refresh = () => { if (shIsFollow(reqId)) { btn.textContent = '⭐ 已关注'; btn.title = '点击取消关注'; btn.style.background = 'rgba(162,89,255,.15)'; btn.style.borderColor = '#a259ff'; btn.style.color = '#a259ff'; } else { btn.textContent = '☆ 关注'; btn.title = '关注该求种帖,可在「我的求种」→「⭐ 我关注的」查看'; btn.style.background = 'transparent'; btn.style.borderColor = '#a259ff'; btn.style.color = '#a259ff'; } }; refresh(); btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); if (shIsFollow(reqId)) { shDelFollow(reqId); toastSafe('已取消关注 #' + reqId, 'ok'); } else { const title = (link.textContent || '').trim(); const lastTd = tds[tds.length - 1]; const stTxt = lastTd ? (lastTd.textContent || '').trim() : ''; const status = shReqStatusOf(stTxt); let postTime = ''; for (const td of tds) { const t = (td.textContent || '').trim(); if (/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}$/.test(t)) { postTime = t; break; } } let pubUid = ''; for (const a of tr.querySelectorAll('a')) { const mm = (a.getAttribute('href') || '').match(/userdetails\.php\?id=(\d+)/); if (mm) { pubUid = mm[1]; break; } } shAddFollow({ id: reqId, title, bonus, status, postTime, pubUid }); toastSafe('⭐ 已关注 #' + reqId + ',可在「我的求种」→「⭐ 我关注的」查看', 'ok'); } }); const nameTd = link.closest('td'); if (nameTd) { let wrap = nameTd.querySelector('.ocd-nameflex'); if (!wrap) { wrap = document.createElement('div'); wrap.className = 'ocd-nameflex'; wrap.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%;'; while (nameTd.firstChild) wrap.appendChild(nameTd.firstChild); nameTd.appendChild(wrap); } wrap.appendChild(btn); } else { link.after(btn); } added++; } if (added) console.log('[SiteFollow] 已注入', added, '个关注按钮'); } /* ================================================================ * 高级设置(用户主权层):限制 / 缓存时长 / 并发 / 界面 / 通知 * · 所有硬限制改为可配,0 或 -1 = 不限 * · 达到推荐阈值只软提醒(可「不再提醒」24 小时),不阻止 * ================================================================ */ const ADV_KEY = 'ocd_helper_advanced_v1'; const ADV_DEFAULTS = { subPages: 6, subHits: 300, subSoftPages: 6, myPostsCap: 600, myResCap: 600, draftCap: 50, followCap: 200, batchCap: 200, ttl: { torrentInfo: 180, fileList: 180, spectrogram: 180, failover: 1, dupUrl: 5, preview: 5, seed: 10, pubarch: 10, subs: 1440 }, concurrency: { batch: 3, siteSearch: 350, platformSearch: 300, download: 500 }, ui: { fontPx: 13, lineHeight: 1.6, radius: 14, fabSize: 44, themeAuto: false, customCss: '' }, notify: { magicMin: 0, quietFrom: 22, quietTo: 8, retain: 60, hrMin: 1, deadMin: 1, pmKeyword: '', clickMode: 'open', webhook: '', mute: {}, }, dup: { levels: { full: true, noArtist: true, noYear: true, albumBody: true, trad: true, fallback: true } }, ask: { reward: 100, perPage: 20 }, batch: { retry: 0, continueOnFail: true }, a11y: { autoLabel: true }, timeouts: { publish: 45, fetch: 120 }, chart: { colors: {}, keep: 0 }, preview: { maxH: 30, autoThanks: true, commentsPerPage: 20, autoAllComments: false }, export: { redact: true }, nameRules: { mustInclude: '', forbid: '', allowRe: '', forbidRe: '' }, cmd: { recent: [], aliases: {}, hotkeys: {} }, log: { level: 'info' }, cacheCap: 300, data: { snapLimit: 500 }, perf: { worker: true, workerMinKB: 256 }, kwRules: { variants: false, stripParen: true, splitArtist: true, tradMap: true, maxVariants: 4 }, seed: { pageCap: 6, deadMax: 0 }, snooze: {}, }; function advDeepMerge(base, patch) { const out = Array.isArray(base) ? base.slice() : Object.assign({}, base); Object.keys(patch || {}).forEach((k) => { const v = patch[k]; if (v && typeof v === 'object' && !Array.isArray(v) && base && typeof base[k] === 'object' && !Array.isArray(base[k])) out[k] = advDeepMerge(base[k], v); else out[k] = v; }); return out; } function advPrefs() { let raw = {}; try { raw = store.get(ADV_KEY, {}) || {}; } catch (e) { raw = {}; } return advDeepMerge(ADV_DEFAULTS, raw); } function advSave(patch) { const next = advDeepMerge(advPrefs(), patch || {}); try { if (patch && Object.keys(patch).length) ocdAudit('settings', '高级设置变更:' + Object.keys(patch).join('、')); } catch (e) {} try { store.set(ADV_KEY, next); } catch (e) {} try { applyAdvUi(); } catch (e) {} return next; } /* 0 / -1 = 不限;非法值回退默认 */ function advNum(key, def) { const v = Number(advPrefs()[key]); if (v === 0 || v === -1) return 0; return (isFinite(v) && v > 0) ? v : def; } function advGroup(key) { return advPrefs()[key] || {}; } /* 分钟 → 毫秒;0 = 不缓存 */ /* 查重关键词降级层级(3.4):可在高级设置里逐级开关 */ function advDupLevel(key) { try { return (advGroup('dup').levels || {})[key] !== false; } catch (e) { return true; } } /* 请求超时(秒 → 毫秒) */ function advTimeoutMs(key, defMs) { const sec = Number((advGroup('timeouts') || {})[key]); const s2 = (isFinite(sec) && sec >= 5 && sec <= 600) ? sec : Math.round(defMs / 1000); return Math.round(s2 * 1000); } /* 导出脱敏(10.1):键名命中敏感词时替换值 */ const ADV_SENSITIVE_RE = /(mail|token|cookie|pass|secret|auth|session)/i; function advRedactMap(obj) { try { if (!(advGroup('export') || {}).redact) return { out: obj, n: 0 }; const out = {}; let n = 0; Object.keys(obj || {}).forEach((k) => { if (ADV_SENSITIVE_RE.test(k)) { out[k] = '[已脱敏]'; n++; } else out[k] = obj[k]; }); return { out: out, n: n }; } catch (e) { return { out: obj, n: 0 }; } } function advTtlMs(key, defMin) { const raw = Number(advGroup('ttl')[key]); const m = isFinite(raw) ? raw : defMin; return m <= 0 ? 0 : m * 60000; } function advSnooze(key, minutes) { const sn = Object.assign({}, advPrefs().snooze || {}); sn[key] = Date.now() + (Number(minutes) || 1440) * 60000; advSave({ snooze: sn }); } function advSnoozed(key) { return (Number((advPrefs().snooze || {})[key]) || 0) > Date.now(); } function advWarnOnce(key, title, detail) { if (advSnoozed(key)) return false; try { toastSafe('⚠️ ' + title + (detail ? ':' + detail : ''), 'warn'); } catch (e) {} return true; } function advInQuietHours() { try { const n = advGroup('notify'); const from = Number(n.quietFrom), to = Number(n.quietTo); if (!isFinite(from) || !isFinite(to) || from === to) return false; const h = new Date().getHours(); return from < to ? (h >= from && h < to) : (h >= from || h < to); } catch (e) { return false; } } /* 把界面类参数写成 CSS 变量(含自定义 CSS,优先级最高) */ function applyAdvUi() { try { const ui = advGroup('ui'); let st = document.getElementById('ocd-adv-ui'); if (!st) { st = document.createElement('style'); st.id = 'ocd-adv-ui'; document.head.appendChild(st); } const fontPx = Math.max(10, Math.min(20, Number(ui.fontPx) || 13)); const lh = Math.max(1.1, Math.min(2.4, Number(ui.lineHeight) || 1.6)); const radius = Math.max(0, Math.min(28, Number(ui.radius) || 14)); const fab = Math.max(28, Math.min(96, Number(ui.fabSize) || 44)); const pvH = Math.max(10, Math.min(90, Number((advGroup('preview') || {}).maxH) || 30)); const contrast = !!(advGroup('ui') || {}).contrast; st.textContent = ':root{--ocd-btn-radius:' + Math.min(radius, 16) + 'px;--ocd-panel-radius:' + radius + 'px;--ocd-preview-maxh:' + pvH + 'vh;}\n' + (contrast ? ('[data-ocd-panel],[data-ocd-view],#ocd-settings-center{--border:rgba(255,255,255,.42);--fg-muted:#c8cfdd;}\n' + '[data-ocd-panel] button,[data-ocd-view] button,#ocd-settings-center button{outline:1px solid rgba(255,255,255,.22);}\n' + '[data-ocd-panel] td,[data-ocd-view] td,[data-ocd-panel] th,[data-ocd-view] th{border-color:rgba(255,255,255,.28);}\n') : '') + '[data-ocd-panel],[data-ocd-view],#ocd-settings-center{font-size:' + fontPx + 'px;line-height:' + lh + ';}\n' + '#ocd-fab{width:' + fab + 'px;height:' + fab + 'px;}\n' + (ui.customCss ? ('/* 用户自定义 CSS */\n' + String(ui.customCss) + '\n') : ''); applyAdvThemeAuto(); return true; } catch (e) { console.warn('[Adv] 应用界面参数失败:', e && e.message); return false; } } function applyAdvThemeAuto() { try { if (!advGroup('ui').themeAuto || !window.matchMedia) return; const dark = window.matchMedia('(prefers-color-scheme: dark)').matches; const cur = String(store.get(THEME_KEY, 'dark')); const isLight = /light|亮/i.test(cur); if (dark && isLight) applyTheme('dark'); else if (!dark && !isLight) { const light = (THEMES || []).find(t => /light|亮/i.test(String(t))); if (light) applyTheme(light); } } catch (e) {} } /* 统一确认框(Promise)—— 软限制/危险操作复用 */ function shConfirm(opts) { const o = (typeof opts === 'string') ? { text: opts } : (opts || {}); return new Promise((resolve) => { const mask = document.createElement('div'); mask.id = 'ocd-confirm-mask'; mask.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:' + OCD_Z.CONFIRM + ';display:flex;align-items:center;justify-content:center;'; const win = document.createElement('div'); win.setAttribute('role', 'dialog'); win.setAttribute('aria-modal', 'true'); win.style.cssText = 'width:min(440px,92vw);background:var(--bg,#242632);color:var(--fg,#e8eaf0);border:1px solid var(--border,#444);border-radius:var(--ocd-panel-radius,12px);padding:16px 18px;font:13px/1.7 -apple-system,"Microsoft YaHei",sans-serif;box-shadow:0 20px 60px rgba(0,0,0,.55);'; const buttons = Array.isArray(o.buttons) && o.buttons.length ? o.buttons : [{ text: '取消', value: false }, { text: '确定', value: true, primary: true }]; win.innerHTML = '
' + esc(o.title || '请确认') + '
' + (o.html ? ('
' + o.html + '
') : '') + '
'; const box = win.querySelector('#ocd-confirm-btns'); const done = (v) => { try { mask.remove(); } catch (e) {} resolve(v); }; buttons.forEach((b) => { const el = document.createElement('button'); el.textContent = b.text; el.style.cssText = 'padding:6px 14px;cursor:pointer;font-size:12px;border-radius:var(--ocd-btn-radius,8px);' + (b.primary ? 'border:none;background:linear-gradient(135deg,#4a69bd,#6a8cd8);color:#fff;font-weight:700;' : 'border:1px solid var(--border,#444);background:transparent;color:var(--fg-muted);'); el.addEventListener('click', () => done(b.value)); box.appendChild(el); }); mask.appendChild(win); mask.addEventListener('click', (e) => { if (e.target === mask) done(o.maskValue === undefined ? false : o.maskValue); }); document.body.appendChild(mask); const first = box.querySelector('button'); if (first && o.focus !== false) setTimeout(() => { try { first.focus(); } catch (e) {} }, 0); }); } /* 消息/输入类统一弹窗(Promise 版),与 shConfirm 同一套外观 */ function shAlert(opts) { const o = (typeof opts === 'string') ? { html: opts } : (opts || {}); return shConfirm({ title: o.title || '提示', html: o.html || o.text || '', buttons: [{ text: o.okText || '知道了', value: true, primary: true }], maskValue: true, }); } function shPrompt(opts) { const o = (typeof opts === 'string') ? { title: opts } : (opts || {}); return new Promise((resolve) => { const mask = document.createElement('div'); mask.id = 'ocd-prompt-mask'; mask.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:' + OCD_Z.CONFIRM + ';display:flex;align-items:center;justify-content:center;'; const win = document.createElement('div'); win.setAttribute('role', 'dialog'); win.setAttribute('aria-modal', 'true'); win.style.cssText = 'width:min(460px,92vw);background:var(--bg,#242632);color:var(--fg,#e8eaf0);border:1px solid var(--border,#444);border-radius:var(--ocd-panel-radius,12px);padding:16px 18px;font:13px/1.7 -apple-system,"Microsoft YaHei",sans-serif;box-shadow:0 20px 60px rgba(0,0,0,.55);'; const val = o.value === undefined ? '' : String(o.value); win.innerHTML = '
' + esc(o.title || '请输入') + '
' + (o.html ? ('
' + o.html + '
') : '') + '' + (o.hint ? ('
' + o.hint + '
') : '') + '
'; const box = win.querySelector('#ocd-prompt-btns'); const done = (v) => { try { mask.remove(); } catch (e) {} resolve(v); }; [{ text: o.cancelText || '取消', value: null }, { text: o.okText || '确定', value: '__OK__', primary: true }].forEach((b) => { const el = document.createElement('button'); el.textContent = b.text; el.style.cssText = 'padding:6px 14px;cursor:pointer;font-size:12px;border-radius:var(--ocd-btn-radius,8px);' + (b.primary ? 'border:none;background:linear-gradient(135deg,#4a69bd,#6a8cd8);color:#fff;font-weight:700;' : 'border:1px solid var(--border,#444);background:transparent;color:var(--fg-muted);'); el.addEventListener('click', () => done(b.value === '__OK__' ? String(win.querySelector('#ocd-prompt-input').value || '') : null)); box.appendChild(el); }); const inp = win.querySelector('#ocd-prompt-input'); inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); done(String(inp.value || '')); } else if (e.key === 'Escape') { e.preventDefault(); done(null); } }); mask.appendChild(win); mask.addEventListener('click', (e) => { if (e.target === mask) done(null); }); document.body.appendChild(mask); setTimeout(() => { try { inp.focus(); inp.select(); } catch (e) {} }, 0); }); } /* 带「不再询问」的确认框:勾选后写入 snooze,24 小时内不再询问 */ function shConfirmOnce(key, opts) { const o = (typeof opts === 'string') ? { title: opts } : (opts || {}); if (advSnoozed('confirm:' + key)) return Promise.resolve(true); let once = false; const p = shConfirm({ title: o.title, html: (o.html || '') + '', buttons: o.buttons || [{ text: '取消', value: false }, { text: '确定', value: true, primary: true }], }); setTimeout(() => { try { const cb = document.getElementById('ocd-confirm-once'); if (cb) cb.addEventListener('change', () => { once = cb.checked; }); const box = document.getElementById('ocd-confirm-btns'); if (box) box.addEventListener('click', () => { const c = document.getElementById('ocd-confirm-once'); if (c) once = c.checked; }, true); } catch (e) {} }, 0); return p.then((v) => { if (once) advSnooze('confirm:' + key, 1440); return v; }); } /* 高级设置面板(挂到设置中心) */ function buildAdvSettingsBox() { const p = advPrefs(); const tt = p.ttl || {}; const cc = p.concurrency || {}; const ui = p.ui || {}; const box = document.createElement('details'); box.id = 'ocd-adv-box'; box.style.cssText = 'margin:4px 0;border:1px solid #a259ff;border-radius:var(--ocd-panel-radius,8px);padding:4px 8px;'; box.innerHTML = '🎚️ 高级设置(限制 / 缓存 / 并发 / 界面)'; const row = (label, id, val, title, unit, noPin) => ''; const sec = (t) => '
' + t + '
'; const wrap = document.createElement('div'); wrap.innerHTML = sec('🎛️ 限制与阈值(0 = 不限)') + row('订阅页数上限', 'adv-subpages', p.subPages, '站内搜索最多抓多少页;0 = 不限', '页') + row('订阅命中保留', 'adv-subhits', p.subHits, '每条订阅保留多少条命中;0 = 不限', '条') + row('订阅软提醒阈值', 'adv-subsoft', p.subSoftPages, '超过该页数时弹确认框提醒风险(不阻止)', '页') + row('我的求种上限', 'adv-myposts', p.myPostsCap, '本地缓存条数;0 = 不限', '条') + row('我的应求上限', 'adv-myres', p.myResCap, '本地缓存条数;0 = 不限', '条') + row('批量队列上限', 'adv-batchcap', p.batchCap, '任务条数;0 = 不限', '条') + row('关注列表上限', 'adv-followcap', p.followCap, '条;0 = 不限', '条') + sec('⏱️ 缓存时长(分钟,0 = 每次都重新取)') + row('种子详情', 'adv-ttl-torrent', tt.torrentInfo, '详情页/种子信息缓存', '分钟') + row('文件列表', 'adv-ttl-files', tt.fileList, '文件清单缓存', '分钟') + row('频谱图', 'adv-ttl-spec', tt.spectrogram, '频谱缓存', '分钟') + row('失败换源', 'adv-ttl-failover', tt.failover, '失败换源结果缓存', '分钟') + row('查重结果', 'adv-ttl-dup', tt.dupUrl, '同 URL 查重结果缓存', '分钟') + row('求种帖预览', 'adv-ttl-preview', tt.preview, '预览缓存', '分钟') + sec('⚡ 并发与限速') + row('批量并发', 'adv-conc-batch', cc.batch, '1~5;大于 3 可能触发站点风控', '并发') + row('站内搜索间隔', 'adv-int-site', cc.siteSearch, '两次请求间隔(毫秒)', 'ms') + row('平台搜索间隔', 'adv-int-plat', cc.platformSearch, '毫秒', 'ms') + sec('🎨 界面') + row('字体大小', 'adv-ui-font', ui.fontPx, '模块面板字号(10~20)', 'px') + row('行高', 'adv-ui-lh', ui.lineHeight, '1.1~2.4', '') + row('面板圆角', 'adv-ui-radius', ui.radius, '0~28', 'px') + row('悬浮球大小', 'adv-ui-fab', ui.fabSize, '28~96', 'px') + '' + '
自定义 CSS(优先级最高,直接注入样式表)
' + '' + sec('👁️‍🗨️ 发种查重(关键词降级层级)') + '
' + [['full', '全词'], ['noArtist', '去艺人'], ['noYear', '去年份'], ['albumBody', '专辑主体'], ['trad', '繁体降级']] .map(([k, cn]) => '').join('') + '
' + '
关闭某级可减少查重请求量(例如只保留「全词」);网络失败重试次数在查重面板的 ⚙ 里设置(已放宽到 0~5 次)
' + sec('⚡ 批量发种(重试与失败处理)') + row('失败自动重试', 'adv-batch-retry', (p.batch || {}).retry || 0, '一轮结束后自动重试失败任务的次数(0 = 不自动重试,最大 5)', '次') + '' + sec('🔍 一键求助 / ♿ 无障碍') + row('默认悬赏', 'adv-ask-reward', (p.ask || {}).reward || 100, '打开发布求种帖面板时预填的悬赏值(≥100)', '魔力') + row('列表每页条数', 'adv-ask-perpage', (p.ask || {}).perPage === undefined ? 20 : (p.ask || {}).perPage, '「我的求种 / 我的应求」列表分页大小(0 = 不分页)', '条') + '' + sec('⏱️ 请求超时(秒)') + row('发布步骤', 'adv-to-pub', (p.timeouts || {}).publish || 45, '静默发布单步超时(5~600 秒)', '秒') + row('抓取请求', 'adv-to-fetch', (p.timeouts || {}).fetch || 120, '详情页 / 感谢等请求超时(5~600 秒)', '秒') + sec('📈 趋势图配色(留空 = 主题默认)') + TREND_METRICS.map(m => '').join('') + sec('👁 求种帖预览') + row('正文最大高度', 'adv-pv-h', (p.preview || {}).maxH || 30, '预览正文区域高度(10~90,单位 vh)', 'vh') + row('评论每页条数', 'adv-pv-cm', (p.preview || {}).commentsPerPage || 20, '求种帖预览里评论分页大小(5~200)', '条') + '' + '' + sec('🖥 界面增强') + '' + '' + sec('🧾 日志与缓存(工程化)') + '
' + '' + row('缓存容量上限', 'adv-cache-cap', p.cacheCap || 300, '缓存最多保留条数(0 = 不限)', '条') + row('快照读取上限', 'adv-data-snaplimit', (p.data || {}).snapLimit || 500, '趋势图 / 快照对比一次最多读取多少个快照(10~5000,越大越吃内存)', '个') + '' + row('线程阈值', 'adv-perf-workermin', (p.perf || {}).workerMinKB || 256, '超过该大小的种子才走后台线程(16~4096)', 'KB') + sec('⌨️ 命令面板(最近使用 / 别名 / 自定义快捷键)') + '
别名(每行一条:别名=命令ID,例如 cd=act:dup)
' + '' + '
自定义快捷键(每行一条:组合=命令ID,例如 alt+u=tool:subs)
' + '' + '
可用命令 ID 见命令面板(如 tool:subs / mod:batch / act:dup);保存后刷新页面生效
' + sec('📁 命名规则(目录规范校验用)') + '
必含关键词 / 禁止关键词(逗号或空格分隔)
' + '' + '' + '
允许 / 禁止正则(可留空)
' + '' + '' + sec('🔤 关键词模板(订阅扫描时追加变体)') + '
' + ' ' + ' ' + ' ' + ' ' + ' ' + '
' + sec('🌱 做种看板') + row('每次加载页数', 'adv-seed-pages', (p.seed || {}).pageCap || 6, '点「加载更多」每次追加的页数(每页 100 条)', '页') + row('断种阈值', 'adv-seed-dead', (p.seed || {}).deadMax || 0, '做种数 ≤ 该值即视为断种(默认 0)', '个') + sec('🔔 通知') + '
' + ' 做种守护' + ' ' + ' 每 分钟检查一次,做种下降 / 新增 H&R 时提醒' + '
' + row('免打扰开始', 'adv-nt-from', p.notify.quietFrom, '该时刻起不弹桌面通知(0~23)', '时') + row('免打扰结束', 'adv-nt-to', p.notify.quietTo, '该时刻止(0~23)', '时') + row('通知保留条数', 'adv-nt-retain', (p.notify.retain === 0 ? 0 : (p.notify.retain || 60)), '0 = 不限', '条') + row('H&R 提醒阈值', 'adv-nt-hr', p.notify.hrMin || 1, 'H&R 数量达到该值才提醒', '项') + row('断种提醒阈值', 'adv-nt-dead', p.notify.deadMin || 1, '断种数量达到该值才提醒', '颗') + row('低魔力提醒', 'adv-nt-magic', p.notify.magicMin || 0, '采集快照时魔力值低于该值时提醒充能(0 = 关闭,6 小时最多一次)', '魔力') + '
站内信只推标题含该关键字(留空 = 全部)
' + '' + '
Webhook(订阅命中 / H&R / 断种 / 批量失败 → POST JSON)
' + '' + '
' + ' 通知静音类别' + ['pm','hr','seed','batch','req','sub','system'].map(k => '').join('') + '
' + '
' + ' ' + ' ✅ 改动即自动保存(无需手动点保存)' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + '
' + '
💾 备份与恢复
' + '
' + ' ' + ' ' + ' ' + ' ' + '
' + '
· 完整备份含全部配置 + 快照/档案/账本等数据库记录;自动备份每天一次、仅配置、保留最近 3 份
' + '
· 0 或 -1 表示不限;界面/缓存类改动保存后立即生效,其余在下次使用该功能时生效
· 🚀 激进档会显著增加请求量,请自行评估站点风控风险
'; box.appendChild(wrap); const g = (id) => wrap.querySelector('#' + id); const num = (id, def) => { const v = Number(String(g(id).value).trim()); return isFinite(v) ? v : def; }; const applyPreset = (pages, hits, conc, siteInt) => { g('adv-subpages').value = pages; g('adv-subhits').value = hits; g('adv-conc-batch').value = conc; g('adv-int-site').value = siteInt; toastSafe('预设值已填入,将自动保存生效', 'info'); }; box.querySelector('#adv-preset-conservative').addEventListener('click', () => applyPreset(3, 300, 1, 500)); box.querySelector('#adv-preset-standard').addEventListener('click', () => applyPreset(6, 1000, 3, 350)); box.querySelector('#adv-preset-aggressive').addEventListener('click', () => applyPreset(20, 5000, 5, 200)); box.querySelector('#adv-preset-unlimited').addEventListener('click', () => applyPreset(0, 0, 3, 350)); box.querySelector('#adv-snooze-clear').addEventListener('click', () => { advSave({ snooze: {} }); toastSafe('已清除全部「不再提醒」标记', 'ok'); }); box.querySelector('#adv-notify-rss').addEventListener('click', () => shOpenNotifyRss()); try { const gOn = box.querySelector('#adv-guard-on'); const gMin = box.querySelector('#adv-guard-min'); const saveGuard = () => shSeedGuardSave({ on: !!gOn.checked, min: Math.max(5, Math.min(720, Number(gMin.value) || 30)) }); gOn.addEventListener('change', () => { saveGuard(); toastSafe('做种守护:' + (gOn.checked ? '已开启' : '已关闭'), 'ok'); }); gMin.addEventListener('change', () => { saveGuard(); toastSafe('做种守护间隔:' + shSeedGuardPrefs().min + ' 分钟', 'ok'); }); } catch (e) {} box.querySelector('#adv-backup-full').addEventListener('click', () => shExportFullBackup()); box.querySelector('#adv-backup-restore').addEventListener('click', () => shRestoreFullBackup()); box.querySelector('#adv-backup-now').addEventListener('click', () => { store.set('ocd_helper_lastbackup_v1', 0); const okB = shAutoBackupTick(); toastSafe(okB ? '⚡ 已生成一份自动备份' : '自动备份失败', okB ? 'ok' : 'err'); const nEl = box.querySelector('#adv-backup-n'); if (nEl) nEl.textContent = String(shListAutoBackups().length); }); box.querySelector('#adv-backup-auto').addEventListener('click', async () => { const list = shListAutoBackups(); if (!list.length) { toastSafe('还没有自动备份', 'warn'); return; } const pick = await shConfirm({ title: '选择要恢复的自动备份', html: list.map(x => '槽 ' + x.slot + ':' + new Date(x.ts).toLocaleString('zh-CN') + '(' + x.keys + ' 项)').join('
'), buttons: list.slice(0, 3).map(x => ({ text: '槽 ' + x.slot, value: x.slot })).concat([{ text: '取消', value: false }]), }); if (pick) await shRestoreAutoBackup(pick); }); try { const nEl = box.querySelector('#adv-backup-n'); if (nEl) nEl.textContent = String(shListAutoBackups().length); } catch (e) {} /* 收集当前所有高级设置控件的值(保存按钮与自动保存共用) */ function collectAdv() { return { subPages: num('adv-subpages', 6), subHits: num('adv-subhits', 300), subSoftPages: num('adv-subsoft', 6), myPostsCap: num('adv-myposts', 600), myResCap: num('adv-myres', 600), batchCap: num('adv-batchcap', 200), followCap: num('adv-followcap', 200), ttl: { torrentInfo: num('adv-ttl-torrent', 180), fileList: num('adv-ttl-files', 180), spectrogram: num('adv-ttl-spec', 180), failover: num('adv-ttl-failover', 1), dupUrl: num('adv-ttl-dup', 5), preview: num('adv-ttl-preview', 5), }, concurrency: { batch: Math.max(1, Math.min(5, num('adv-conc-batch', 3))), siteSearch: Math.max(0, Math.min(5000, num('adv-int-site', 350))), platformSearch: Math.max(0, Math.min(5000, num('adv-int-plat', 300))), }, ui: { fontPx: Math.max(10, Math.min(20, num('adv-ui-font', 13))), lineHeight: Math.max(1.1, Math.min(2.4, num('adv-ui-lh', 1.6))), radius: Math.max(0, Math.min(28, num('adv-ui-radius', 14))), fabSize: Math.max(28, Math.min(96, num('adv-ui-fab', 44))), themeAuto: !!g('adv-ui-themeauto').checked, customCss: String(g('adv-ui-css').value || ''), }, dup: (function () { const lv = {}; ['full', 'noArtist', 'noYear', 'albumBody', 'trad'].forEach((k) => { const el2 = wrap.querySelector('.adv-dup-lv[value="' + k + '"]'); lv[k] = el2 ? !!el2.checked : true; }); return { levels: lv }; })(), ask: { reward: Math.max(100, Math.min(1000000, num('adv-ask-reward', 100))), perPage: Math.max(0, Math.min(500, num('adv-ask-perpage', 20))), }, batch: { retry: Math.max(0, Math.min(5, num('adv-batch-retry', 0))), continueOnFail: !!(wrap.querySelector('#adv-batch-cont') || {}).checked, }, a11y: { autoLabel: !!(wrap.querySelector('#adv-a11y-label') || {}).checked }, timeouts: { publish: Math.max(5, Math.min(600, num('adv-to-pub', 45))), fetch: Math.max(5, Math.min(600, num('adv-to-fetch', 120))), }, chart: (function () { const colors = {}; wrap.querySelectorAll('.adv-chart-c').forEach((el) => { const k = el.dataset.k, def = (TREND_METRICS.find(m => m.k === k) || {}).c; if (el.value && String(el.value).toLowerCase() !== String(def || '').toLowerCase()) colors[k] = el.value; }); return { colors: colors }; })(), preview: { maxH: Math.max(10, Math.min(90, num('adv-pv-h', 30))), autoThanks: !!(wrap.querySelector('#adv-pv-thanks') || {}).checked, commentsPerPage: Math.max(5, Math.min(200, num('adv-pv-cm', 20))), autoAllComments: !!(wrap.querySelector('#adv-pv-cmall') || {}).checked, }, export: { redact: !!(wrap.querySelector('#adv-exp-redact') || {}).checked }, ui: Object.assign({}, (function () { const base = (advPrefs().ui || {}); return base; })(), { contrast: !!(wrap.querySelector('#adv-ui-contrast') || {}).checked, }), log: { level: String(((wrap.querySelector('#adv-log-level') || {}).value) || 'info') }, cacheCap: Math.max(50, Math.min(5000, num('adv-cache-cap', 300))), data: { snapLimit: Math.max(10, Math.min(5000, num('adv-data-snaplimit', 500))) }, perf: { worker: !!(wrap.querySelector('#adv-perf-worker') || {}).checked, workerMinKB: Math.max(16, Math.min(4096, num('adv-perf-workermin', 256))), }, cmd: (function () { const parseMap = (sel) => { const out = {}; String((wrap.querySelector(sel) || {}).value || '').split('\n').forEach((line) => { const s2 = String(line || '').trim(); if (!s2 || s2.indexOf('=') === -1) return; const i = s2.indexOf('='); const k = s2.slice(0, i).trim().toLowerCase(); const v = s2.slice(i + 1).trim(); if (k && v) out[k] = v; }); return out; }; const cur = advCmdPrefs(); return { recent: cur.recent, aliases: parseMap('#adv-cmd-alias'), hotkeys: parseMap('#adv-cmd-hotkeys-placeholder') }; })(), nameRules: { mustInclude: String((wrap.querySelector('#adv-nr-must') || {}).value || '').trim(), forbid: String((wrap.querySelector('#adv-nr-forbid') || {}).value || '').trim(), allowRe: String((wrap.querySelector('#adv-nr-allow') || {}).value || '').trim(), forbidRe: String((wrap.querySelector('#adv-nr-forbidre') || {}).value || '').trim(), }, kwRules: { variants: !!(wrap.querySelector('#adv-kw-on') || {}).checked, stripParen: !!(wrap.querySelector('#adv-kw-paren') || {}).checked, splitArtist: !!(wrap.querySelector('#adv-kw-artist') || {}).checked, tradMap: !!(wrap.querySelector('#adv-kw-trad') || {}).checked, maxVariants: Math.max(0, Math.min(8, num('adv-kw-max', 4))), }, seed: { pageCap: Math.max(1, Math.min(60, num('adv-seed-pages', 6))), deadMax: Math.max(0, Math.min(999, num('adv-seed-dead', 0))), }, notify: { magicMin: Math.max(0, num('adv-nt-magic', 0)), quietFrom: Math.max(0, Math.min(23, num('adv-nt-from', 22))), quietTo: Math.max(0, Math.min(23, num('adv-nt-to', 8))), retain: Math.max(0, num('adv-nt-retain', 60)), hrMin: Math.max(1, num('adv-nt-hr', 1)), deadMin: Math.max(1, num('adv-nt-dead', 1)), pmKeyword: String((wrap.querySelector('#adv-nt-pmk') || {}).value || '').trim(), webhook: String((wrap.querySelector('#adv-nt-hook') || {}).value || '').trim(), mute: (function () { const m = {}; wrap.querySelectorAll('.adv-nt-mute').forEach(c => { if (c.checked) m[c.value] = 1; }); return m; })(), }, }; } /* 📌 固定按钮:把该设置引用到快捷设置 */ try { wrap.querySelectorAll('.ocd-adv-pin').forEach((b) => { const fid = b.dataset.field; if (quickPinsGet().some(x => x.id === fid)) { b.textContent = '📌'; b.style.borderColor = '#ffd54a'; b.style.color = '#ffd54a'; } b.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const on = quickPinsGet().some(x => x.id === fid); if (on) quickPinsRemove(fid); else quickPinsAdd(fid); const still = quickPinsGet().some(x => x.id === fid); b.textContent = still ? '📌' : '📍'; b.style.borderColor = still ? '#ffd54a' : 'var(--border,#444)'; b.style.color = still ? '#ffd54a' : 'var(--fg-muted)'; const host = document.getElementById('ocd-quickpins-host'); if (host) renderQuickPins(host); }); }); } catch (e) {} /* 变化即自动保存(防抖 600ms),保存按钮保留作为手动兜底 */ let _advAutoT = null; let _advAutoSaved = 0; const advAutoSave = (label) => { if (_advAutoT) clearTimeout(_advAutoT); _advAutoT = setTimeout(() => { _advAutoT = null; try { advSave(collectAdv()); _advAutoSaved++; const st = wrap.querySelector('#adv-autostat'); if (st) { st.textContent = '✓ 已自动保存 ' + new Date().toLocaleTimeString('zh-CN', { hour12: false }) + '(共 ' + _advAutoSaved + ' 次)'; st.style.color = '#3fd68f'; } if (_advAutoSaved === 1) toastSafe('✅ 高级设置已开启「改动即自动保存」,无需再点保存按钮', 'ok'); } catch (e) { const st = wrap.querySelector('#adv-autostat'); if (st) { st.textContent = '✗ 自动保存失败:' + ((e && e.message) || e); st.style.color = '#ff6b6b'; } } }, 600); }; /* 委派监听整个高级设置面板:输入框、下拉、复选框都覆盖 */ ['input', 'change'].forEach((ev) => { wrap.addEventListener(ev, (e) => { const el = e.target; if (!el || !el.id && !el.className) return; if (el.id === 'adv-autostat') return; advAutoSave(el.id || el.className); }, true); }); box.querySelector('#adv-save').addEventListener('click', () => { advSave(collectAdv()); const st = wrap.querySelector('#adv-autostat'); if (st) { st.textContent = '✓ 已手动保存 ' + new Date().toLocaleTimeString('zh-CN', { hour12: false }); st.style.color = '#3fd68f'; } toastSafe('✅ 高级设置已保存', 'ok'); }); return box; } /* ================================================================ * §39 快速应求 * ================================================================ */ const SH_TORRENTS_KEY = 'ocd_helper_sh_torrents_v1'; const SH_TORRENTS_TTL = 10 * 60 * 1000; function shLoadTorrentsCache() { try { const raw = store.get(SH_TORRENTS_KEY, null); if (!raw || typeof raw !== 'object') return null; if (!raw.ts || !Array.isArray(raw.list)) return null; if (Date.now() - raw.ts > SH_TORRENTS_TTL) { console.log('[Respond] 缓存已过期(', Math.round((Date.now() - raw.ts) / 60000), '分钟前)'); return null; } return raw; } catch (e) { return null; } } function shSaveTorrentsCache(list) { try { store.set(SH_TORRENTS_KEY, { ts: Date.now(), list }); console.log('[Respond] 已落盘缓存', list.length, '条(约', Math.round(JSON.stringify(list).length / 1024), 'KB)'); } catch (e) { console.warn('[Respond] 缓存保存失败:', e.message); } } async function shFetchMyTorrents(force) { if (!force) { const cached = shLoadTorrentsCache(); if (cached) { console.log('[Respond] 使用缓存,', cached.list.length, '条 · 缓存于', new Date(cached.ts).toLocaleTimeString('zh-CN', { hour12: false })); return { list: cached.list, cached: true, ts: cached.ts }; } } const uid = shGetMyUid(); if (!uid) throw new Error('无法识别用户 ID'); const url = 'https://open.cd/getusertorrentlistajax.php?userid=' + uid + '&type=uploaded'; console.log('[Respond] 抓取种子列表…', url); const html = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, headers: { Referer: 'https://open.cd/' }, timeout: 30000, onload: r => resolve(r.responseText || ''), onerror: () => reject(new Error('网络错误')), ontimeout: () => reject(new Error('请求超时(30s)')), }); }); console.log('[Respond] 响应长度:', html.length); const list = shParseTorrentList(html); console.log('[Respond] 解析出', list.length, '条种子'); shSaveTorrentsCache(list); return { list, cached: false, ts: Date.now() }; } function shParseTorrentList(html) { const out = []; if (!html) return out; const trRe = /]*>([\s\S]*?)<\/tr>/gi; let m; while ((m = trRe.exec(html)) !== null) { const tr = m[1]; const aM = tr.match(/]+href="details\.php\?id=\s*(\d+)[^"]*"[^>]*title="([^"]{1,500})"[^>]*>/i); if (!aM) continue; const id = aM[1]; const title = aM[2] .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') .replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, ' ') .replace(/\s+/g, ' ').trim(); let size = ''; const szM = tr.match(/]*align="center"[^>]*>\s*([\d.,]+)\s*(TB|GB|MB|KB)\s*<\/td>/i); if (szM) size = szM[1] + ' ' + szM[2]; const nums = [...tr.matchAll(/]*align="center"[^>]*>\s*(\d+)\s*<\/td>/gi)].map(x => Number(x[1])); const seeders = nums[0] || 0; const leechers = nums[1] || 0; out.push({ id, title, size, seeders, leechers }); } return out; } function shExtractPostKeyword(postTitle) { let t = String(postTitle || '').trim(); t = t.replace(/^求种\s*[-–—::]\s*/i, ''); t = t.replace(/^求\s*/, ''); t = t.trim(); if (!t) t = String(postTitle || '').trim(); return t; } function shNormForMatch(s) { return String(s || '').toLowerCase() .replace(/[((【\[][^))】\]]*[))】\]]/g, ' ') .replace(/[\s\-–—_.,,。::;;!!??'"“”‘’·、\/\\|&+*#@()()\[\]【】]+/g, ''); } function shMatchTorrents(postTitle, torrents) { const kw = shExtractPostKeyword(postTitle); const kwNorm = shNormForMatch(kw); const scored = []; for (const t of torrents) { const tnNorm = shNormForMatch(t.title); let score = 0; if (!kwNorm) { score = 0; } else if (tnNorm === kwNorm) { score = 10; } else if (tnNorm.includes(kwNorm)) { score = 8; } else if (kwNorm.includes(tnNorm)) { score = 7; } else { const longer = Math.max(kwNorm.length, tnNorm.length); if (longer >= 6) { const short = kwNorm.length < tnNorm.length ? kwNorm : tnNorm; const long = kwNorm.length < tnNorm.length ? tnNorm : kwNorm; let common = 0; const longSet = new Set(long.split('')); for (const ch of short) if (longSet.has(ch)) common++; const ratio = common / short.length; if (ratio >= 0.9) score = 6; else if (ratio >= 0.75) score = 4; else if (ratio >= 0.6) score = 2; } } scored.push({ t, score }); } scored.sort((a, b) => b.score - a.score); const recommended = scored.filter(s => s.score >= 4).slice(0, 10).map(s => s.t); const recIds = new Set(recommended.map(t => t.id)); const others = torrents.filter(t => !recIds.has(t.id)); return { recommended, others, all: torrents }; } async function shSubmitRespond(reqid, torrentid) { const fd = new URLSearchParams(); fd.append('action', 'takeres'); fd.append('reqid', String(reqid)); fd.append('torrentid', String(torrentid)); return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'POST', url: 'https://open.cd/viewrequests.php', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Referer': 'https://open.cd/viewrequests.php?action=res&id=' + reqid, 'Origin': 'https://open.cd', }, data: fd.toString(), timeout: 20000, onload: (r) => { const resp = r.responseText || ''; if (r.status !== 200) return resolve({ ok: false, err: 'HTTP ' + r.status }); if (/]*>\s*成功\s*<\/h2>/i.test(resp)) return resolve({ ok: true }); const errM = resp.match(/]*>\s*出错[了!]*\s*<\/h2>[\s\S]{0,800}?]*class=["']text["'][^>]*>([\s\S]{2,300}?)<\/td>/i); if (errM) return resolve({ ok: false, err: stripHtmlTags(errM[1]).slice(0, 200) }); resolve({ ok: false, err: '未知响应' }); }, onerror: () => resolve({ ok: false, err: '网络错误' }), ontimeout: () => resolve({ ok: false, err: '请求超时' }), }); }); } function shRenderTorrentRow(t, highlight) { const borderColor = highlight ? '#3fd68f' : 'var(--border,#444)'; const bg = highlight ? 'rgba(63,214,143,.06)' : 'transparent'; return '
' + '
' + '
' + esc(t.title) + '
' + '
' + ' #' + esc(t.id) + '' + (t.size ? ' | ' + esc(t.size) + '' : '') + (t.seeders !== undefined ? ' | ⬆' + t.seeders + ' / ⬇' + t.leechers + '' : '') + '
' + '
' + '' + '
'; } async function shQuickRespondDialog(reqid, postTitle) { const old = document.getElementById('ocd-respond-pop'); if (old) old.remove(); const mask = document.createElement('div'); mask.id = 'ocd-respond-pop'; mask.className = shGetThemeCls(); mask.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:' + ocdTopZ(20) + ';display:flex;align-items:center;justify-content:center;'; const win = document.createElement('div'); win.style.cssText = 'width:min(720px,94vw);max-height:92vh;display:flex;flex-direction:column;background:var(--bg,#242632);color:var(--fg,#e8eaf0);border:1px solid #4a69bd;border-radius:12px;padding:14px 16px;font:13px/1.6 -apple-system,"Microsoft YaHei",sans-serif;box-shadow:0 20px 60px rgba(0,0,0,.55);'; win.innerHTML = '
' + ' ⚡ 快速应求 · #' + esc(reqid) + '' + ' ✕' + '
' + '
' + '
' + '
⏳
' + ' 正在获取你的种子列表…(首次约 2-3 秒)' + '
' + '
'; mask.appendChild(win); document.body.appendChild(mask); const $ = (id) => win.querySelector('#' + id); const close = () => mask.remove(); mask.addEventListener('click', (e) => { if (e.target === mask) close(); }); $('ocd-respond-close').addEventListener('click', close); $('ocd-respond-body').addEventListener('click', (e) => { const pickBtn = e.target.closest('.ocd-resp-pick'); if (pickBtn) { shConfirmRespond(reqid, postTitle, pickBtn.dataset.tid, pickBtn.dataset.title, pickBtn.dataset.size, () => { setTimeout(close, 200); }); return; } const openBtn = e.target.closest('.ocd-resp-openall'); if (openBtn) { state.showOthers = true; renderAll(); return; } const othTgl = e.target.closest('.ocd-resp-others-toggle'); if (othTgl) { state.showOthers = !state.showOthers; renderAll(); return; } const rfr = e.target.closest('.ocd-resp-refresh'); if (rfr) { doRefresh(rfr); return; } }); $('ocd-respond-body').addEventListener('input', (e) => { const inp = e.target.closest('.ocd-resp-filter-input'); if (!inp) return; state.othersFilter = inp.value.trim().toLowerCase(); renderOthersList(); }); const state = { torrents: [], matched: { recommended: [], others: [], all: [] }, cacheInfo: { cached: false, ts: 0 }, showOthers: false, othersFilter: '', loading: true, }; const doRefresh = async (btn) => { if (btn) { btn.disabled = true; btn.textContent = '⏳ 加载中…'; } try { const r = await shFetchMyTorrents(true); state.torrents = r.list; state.cacheInfo = { cached: r.cached, ts: r.ts }; state.matched = shMatchTorrents(postTitle, state.torrents); renderAll(); } catch (e) { toastSafe('❌ 刷新失败:' + e.message, 'err'); if (btn) { btn.disabled = false; btn.textContent = '🔄 刷新列表'; } } }; const renderOthersList = () => { const host = $('ocd-resp-others-list'); if (!host) return; const kw = state.othersFilter; let arr = state.matched.others; if (kw) arr = arr.filter(t => t.title.toLowerCase().indexOf(kw) !== -1); const total = state.matched.others.length; const shown = arr.length; const maxShow = 300; const slice = arr.slice(0, maxShow); host.innerHTML = slice.map(t => shRenderTorrentRow(t, false)).join('') + (shown > maxShow ? '
… 仅显示前 ' + maxShow + ' 条,请用关键词缩小范围
' : ''); const cntEl = $('ocd-resp-others-count'); if (cntEl) cntEl.textContent = kw ? ('匹配 ' + shown + ' / 共 ' + total + ' 条') : ('共 ' + total + ' 条'); }; const renderAll = () => { if (state.loading) { $('ocd-respond-body').innerHTML = '
⏳
加载中…
'; return; } const t1 = new Date(state.cacheInfo.ts).toLocaleTimeString('zh-CN', { hour12: false }); const cacheText = state.cacheInfo.cached ? '📦 使用缓存数据 · 缓存于 ' + t1 : '🔄 已刷新 · 更新于 ' + t1; const cacheColor = state.cacheInfo.cached ? '#f59e0b' : '#3fd68f'; const kw = shExtractPostKeyword(postTitle); let html = ''; html += '
' + ' ' + esc(cacheText) + '' + '
'; html += '
' + '
帖子:' + esc(postTitle) + '
' + '
匹配关键词:' + esc(kw) + '
' + '
'; if (state.matched.recommended.length) { html += '
🔍 推荐种子(自动匹配 ' + state.matched.recommended.length + ' 条)
'; html += state.matched.recommended.map(t => shRenderTorrentRow(t, true)).join(''); } else { html += '
' + ' ⚠️ 未自动匹配到种子
' + ' ' + '
'; } if (state.matched.others.length || state.matched.recommended.length) { const othersTotal = state.matched.others.length; html += '
'; html += '
' + ' ' + (state.showOthers ? '▾' : '▸') + ' 其他种子(' + othersTotal + ' 条)' + ' ' + '
'; if (state.showOthers) { html += '
'; html += ' '; html += '
'; html += '
'; } html += '
'; } html += '
' + ' 共 ' + state.torrents.length + ' 条种子' + ' ' + ' ' + '
'; $('ocd-respond-body').innerHTML = html; $('ocd-respond-body').style.cssText = 'flex:1;min-height:300px;max-height:75vh;overflow-y:auto;padding-right:4px;'; if (state.showOthers) renderOthersList(); }; try { const r = await shFetchMyTorrents(); state.torrents = r.list; state.cacheInfo = { cached: r.cached, ts: r.ts }; state.matched = shMatchTorrents(postTitle, state.torrents); state.loading = false; renderAll(); } catch (e) { state.loading = false; $('ocd-respond-body').innerHTML = '
❌ 加载失败:' + esc(e.message || '未知') + '
'; } } const _shPostRespondsCache = {}; async function shFetchPostResponds(reqid) { const now = Date.now(); const c = _shPostRespondsCache[reqid]; if (c && now - c.ts < 5 * 60 * 1000) return c.data; const url = 'https://open.cd/viewrequests.php?action=view&id=' + reqid; const html = await gmFetchText(url, { Referer: 'https://open.cd/viewrequests.php' }); const doc = new DOMParser().parseFromString(html, 'text/html'); const result = { hasResponds: false, list: [] }; for (const tr of doc.querySelectorAll('tr')) { const head = tr.querySelector('td.rowhead'); if (!head) continue; const headText = (head.textContent || '').replace(/\s+/g, '').trim(); if (headText !== '应求') continue; const body = tr.querySelector('td.rowfollow'); if (!body) continue; const bodyText = (body.textContent || '').replace(/\s+/g, ''); if (/还没有应求|无应求/.test(bodyText)) { result.hasResponds = false; break; } for (const a of body.querySelectorAll('a[href*="details.php?id="]')) { const href = a.getAttribute('href') || ''; const m = href.match(/details\.php\?id=\s*(\d+)/); if (!m) continue; result.list.push({ id: m[1], title: (a.textContent || '').replace(/\s+/g, ' ').trim(), }); } result.hasResponds = result.list.length > 0; break; } _shPostRespondsCache[reqid] = { ts: now, data: result }; console.log('[Respond] 帖 #' + reqid + ' 应求列表:', result.list.length, '条'); return result; } function shShowRespondSuccess(reqid, postTitle, torrentid, torrentTitle, onClose) { const old = document.getElementById('ocd-resp-success'); if (old) old.remove(); const mask = document.createElement('div'); mask.id = 'ocd-resp-success'; mask.className = shGetThemeCls(); mask.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:' + ocdTopZ(20) + ';display:flex;align-items:center;justify-content:center;'; const win = document.createElement('div'); win.style.cssText = 'width:min(480px,92vw);max-height:90vh;display:flex;flex-direction:column;background:var(--bg,#242632);color:var(--fg,#e8eaf0);border:1px solid #3fd68f;border-radius:12px;padding:18px 20px;font:13px/1.8 -apple-system,"Microsoft YaHei",sans-serif;box-shadow:0 20px 60px rgba(0,0,0,.6);animation:ocd-pop .25s ease;'; win.innerHTML = '
' + '
✅
' + '
应求成功
' + '
' + '
' + '
目标帖子:#' + esc(reqid) + '
' + '
' + esc(postTitle) + '
' + '
使用种子:#' + esc(torrentid) + '
' + '
' + esc(torrentTitle) + '
' + '
' + '
' + ' 💡 悬赏由发帖人确认后支付
' + ' 你可以在帖子详情页查看应求记录' + '
' + '
' + ' 查看帖子 ↗' + ' ' + '
'; mask.appendChild(win); document.body.appendChild(mask); const close = () => mask.remove(); mask.addEventListener('click', (e) => { if (e.target === mask) close(); }); win.querySelector('#ocd-resp-succ-ok').addEventListener('click', () => { close(); if (typeof onClose === 'function') onClose(); }); toastSafe('✅ 应求成功:#' + torrentid + ' → #' + reqid, 'ok'); } function shConfirmRespond(reqid, postTitle, torrentid, torrentTitle, torrentSize, onSuccess) { const old = document.getElementById('ocd-resp-confirm'); if (old) old.remove(); const mask = document.createElement('div'); mask.id = 'ocd-resp-confirm'; mask.className = shGetThemeCls(); mask.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:' + ocdTopZ(20) + ';display:flex;align-items:center;justify-content:center;'; const win = document.createElement('div'); win.style.cssText = 'width:min(460px,92vw);background:var(--bg,#242632);color:var(--fg,#e8eaf0);border:1px solid #3fd68f;border-radius:12px;padding:16px 18px;font:13px/1.7 -apple-system,"Microsoft YaHei",sans-serif;box-shadow:0 20px 60px rgba(0,0,0,.6);animation:ocd-pop .2s ease;'; win.innerHTML = '
' + ' ⚠️ 确认应求' + '
' + '
' + '
目标帖子:#' + esc(reqid) + '
' + '
' + esc(postTitle) + '
' + '
' + '
📋 该帖应求:⏳ 加载中…
' + '
' + '
' + '
使用种子:#' + esc(torrentid) + '
' + '
' + esc(torrentTitle) + '
' + (torrentSize ? '
' + esc(torrentSize) + '
' : '') + '
' + '
' + ' · 同一帖可重复应求
' + ' · 悬赏由发帖人确认后支付
' + ' · 应求后无法撤回' + '
' + '
' + '
' + ' ' + ' ' + '
'; mask.appendChild(win); document.body.appendChild(mask); const $ = (id) => win.querySelector('#' + id); const close = () => mask.remove(); mask.addEventListener('click', (e) => { if (e.target === mask) close(); }); $('ocd-resp-cfm-cancel').addEventListener('click', close); (async () => { const statusEl = $('ocd-resp-existing-status'); const listEl = $('ocd-resp-existing-list'); if (!statusEl || !listEl) return; try { const r = await shFetchPostResponds(reqid); if (!r.hasResponds) { statusEl.innerHTML = '当前无应求'; listEl.innerHTML = ''; return; } statusEl.innerHTML = '已有 ' + r.list.length + ' 条'; listEl.innerHTML = r.list.map(item => { const isSelf = String(item.id) === String(torrentid); const color = isSelf ? '#3fd68f' : '#4a69bd'; const tag = isSelf ? ' 你正在选这个' : ''; return ''; }).join(''); } catch (e) { statusEl.innerHTML = '加载失败'; listEl.innerHTML = '
' + esc(e.message || '未知错误') + '
'; } })(); $('ocd-resp-cfm-ok').addEventListener('click', async () => { const btn = $('ocd-resp-cfm-ok'); btn.disabled = true; btn.textContent = '⏳ 提交中…'; $('ocd-resp-cfm-err').innerHTML = ''; const r = await shSubmitRespond(reqid, torrentid); if (r.ok) { close(); try { if (shIsFollow(reqid)) shUpdateFollow(reqid, { status: 'responded', lastCheck: Date.now() }); else shUpdateMyPost(reqid, { status: 'responded', lastCheck: Date.now() }); shSyncFollowUI(); } catch (e) {} if (typeof onSuccess === 'function') onSuccess(); shShowRespondSuccess(reqid, postTitle, torrentid, torrentTitle, () => { if (/viewrequests\.php/i.test(location.pathname) && /action=view/i.test(location.search)) { try { location.reload(); } catch (e) {} } }); } else { btn.disabled = false; btn.textContent = '✓ 确认应求'; $('ocd-resp-cfm-err').innerHTML = '
' + '❌ ' + esc(r.err) + '
'; } }); } function shInjectRespondButtonOnDetail() { if (!/viewrequests\.php/i.test(location.pathname)) return; if (!/[?&]action=view/i.test(location.search)) return; if (document.getElementById('ocd-resp-injected')) return; const m = location.search.match(/[?&]id=(\d+)/); if (!m) return; const reqid = m[1]; const h1 = document.querySelector('h1#top'); let postTitle = h1 ? (h1.textContent || '').trim() : ('#' + reqid); postTitle = postTitle.replace(/^求种\s*[-–—::]\s*/, '').trim() || postTitle; let resLink = null; for (const a of document.querySelectorAll('a[href*="viewrequests.php?action=res"]')) { const href = a.getAttribute('href') || ''; if (href.indexOf('id=' + reqid) !== -1) { resLink = a; break; } } if (!resLink) { console.warn('[Respond] 未找到应求链接,跳过注入'); return; } const btn = document.createElement('button'); btn.id = 'ocd-resp-injected'; btn.textContent = '⚡ 快速应求'; btn.style.cssText = 'margin-left:8px;padding:2px 12px;cursor:pointer;border:1px solid #3fd68f;border-radius:6px;background:rgba(63,214,143,.1);color:#3fd68f;font-size:12px;font-weight:600;vertical-align:middle;'; btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); shQuickRespondDialog(reqid, postTitle); }); resLink.parentNode.insertBefore(btn, resLink.nextSibling); console.log('[Respond] 详情页已注入按钮 | #' + reqid); } function shInjectTimeSortOnList() { if (!/viewrequests\.php/i.test(location.pathname)) return; if (/[?&]action=view/i.test(location.search)) return; if (document.getElementById('ocd-time-sort')) return; let timeHeader = null; for (const td of document.querySelectorAll('td.colhead')) { if ((td.textContent || '').trim() === '时间') { timeHeader = td; break; } } if (!timeHeader) return; const headerRow = timeHeader.closest('tr'); if (!headerRow) return; const tbody = headerRow.parentElement; if (!tbody) return; const headerTds = Array.from(headerRow.querySelectorAll('td')); const timeColIdx = headerTds.indexOf(timeHeader); if (timeColIdx === -1) return; timeHeader.style.cursor = 'pointer'; timeHeader.style.userSelect = 'none'; timeHeader.title = '点击切换时间排序(升序 / 降序)'; const arrow = document.createElement('span'); arrow.id = 'ocd-time-sort'; arrow.textContent = ' ↕'; arrow.style.cssText = 'color:#4a69bd;font-size:11px;margin-left:2px;'; timeHeader.appendChild(arrow); let sortDir = 'none'; timeHeader.addEventListener('click', () => { sortDir = sortDir === 'asc' ? 'desc' : 'asc'; arrow.textContent = sortDir === 'asc' ? ' ↑' : ' ↓'; arrow.style.color = '#3fd68f'; const allRows = Array.from(tbody.children).filter(r => r.tagName === 'TR' && r !== headerRow); const dataRows = allRows.filter(r => { if (r.querySelector('td.colhead')) return false; return r.querySelectorAll('td').length >= 6; }); const withTime = dataRows.map(r => { const tds = r.querySelectorAll('td'); const timeTd = tds[timeColIdx]; const text = timeTd ? (timeTd.textContent || '').trim() : ''; const m = text.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})/); let ts = 0; if (m) ts = new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]).getTime(); return { row: r, ts }; }); withTime.sort((a, b) => sortDir === 'asc' ? a.ts - b.ts : b.ts - a.ts); withTime.forEach(x => tbody.appendChild(x.row)); console.log('[SiteTimeSort] 按时间 ' + sortDir + ' 排序 ' + withTime.length + ' 行'); }); console.log('[SiteTimeSort] 已注入时间排序表头'); } /* ================================================================ * §40 种子列表页:详情弹窗 * ================================================================ */ const TORRENT_INFO_CACHE_KEY = 'ocd_helper_torrentinfo_v1'; const TORRENT_INFO_TTL = advTtlMs('torrentInfo', 180); const TORRENT_INFO_MAX = 100; /* V9.76.7:缓存条目新增 introHtml(带链接的简介 HTML);旧条目没有 _v,视为过期重取一次 */ const TORRENT_INFO_CACHE_VER = 2; function shLoadTorrentInfoCache() { try { const raw = store.get(TORRENT_INFO_CACHE_KEY, null); if (!raw || typeof raw !== 'object') return {}; const now = Date.now(); let cleaned = 0; for (const k of Object.keys(raw)) { if (!raw[k] || !raw[k]._ts || now - raw[k]._ts > TORRENT_INFO_TTL) { delete raw[k]; cleaned++; } } if (cleaned) console.log('[TorrentInfo] 缓存清理过期', cleaned, '条'); return raw; } catch (e) { return {}; } } function shSaveTorrentInfoCache(cache) { try { const keys = Object.keys(cache); if (keys.length > TORRENT_INFO_MAX) { keys.sort((a, b) => (cache[a]._ts || 0) - (cache[b]._ts || 0)); for (let i = 0; i < keys.length - TORRENT_INFO_MAX; i++) delete cache[keys[i]]; } const json = JSON.stringify(cache); if (json.length > 1.8 * 1024 * 1024) { console.warn('[TorrentInfo] 缓存过大', Math.round(json.length / 1024), 'KB,跳过保存'); return; } store.set(TORRENT_INFO_CACHE_KEY, cache); console.log('[TorrentInfo] 缓存已保存,', keys.length, '条 ·', Math.round(json.length / 1024), 'KB'); } catch (e) { console.warn('[TorrentInfo] 缓存保存失败:', e.message); } } const FILE_LIST_CACHE_KEY = 'ocd_helper_filelist_v1'; const FILE_LIST_TTL = advTtlMs('fileList', 180); const FILE_LIST_MAX = 100; function shLoadFileListCache() { try { const raw = store.get(FILE_LIST_CACHE_KEY, null); if (!raw || typeof raw !== 'object') return {}; const now = Date.now(); for (const k of Object.keys(raw)) { if (!raw[k] || !raw[k]._ts || now - raw[k]._ts > FILE_LIST_TTL) delete raw[k]; } return raw; } catch (e) { return {}; } } function shSaveFileListCache(cache) { try { const keys = Object.keys(cache); if (keys.length > FILE_LIST_MAX) { keys.sort((a, b) => (cache[a]._ts || 0) - (cache[b]._ts || 0)); for (let i = 0; i < keys.length - FILE_LIST_MAX; i++) delete cache[keys[i]]; } const json = JSON.stringify(cache); if (json.length > 1.8 * 1024 * 1024) { console.warn('[FileList] 缓存过大,跳过保存'); return; } store.set(FILE_LIST_CACHE_KEY, cache); console.log('[FileList] 缓存已保存,', keys.length, '条'); } catch (e) { console.warn('[FileList] 缓存保存失败:', e.message); } } const SPECTROGRAM_CACHE_KEY = 'ocd_helper_spectrogram_v1'; const SPECTROGRAM_TTL = advTtlMs('spectrogram', 180); const SPECTROGRAM_MAX = 100; function shLoadSpectrogramCache() { try { const raw = store.get(SPECTROGRAM_CACHE_KEY, null); if (!raw || typeof raw !== 'object') return {}; const now = Date.now(); for (const k of Object.keys(raw)) { if (!raw[k] || !raw[k]._ts || now - raw[k]._ts > SPECTROGRAM_TTL) delete raw[k]; } return raw; } catch (e) { return {}; } } function shSaveSpectrogramCache(cache) { try { const keys = Object.keys(cache); if (keys.length > SPECTROGRAM_MAX) { keys.sort((a, b) => (cache[a]._ts || 0) - (cache[b]._ts || 0)); for (let i = 0; i < keys.length - SPECTROGRAM_MAX; i++) delete cache[keys[i]]; } store.set(SPECTROGRAM_CACHE_KEY, cache); } catch (e) {} } function shFetchImageAsBlobUrl(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, responseType: 'blob', headers: { Referer: 'https://open.cd/' }, timeout: 30000, onload: (r) => { if (r.status === 200 && r.response) { try { resolve(URL.createObjectURL(r.response)); } catch (e) { reject(new Error('blob 创建失败')); } } else { reject(new Error('HTTP ' + r.status)); } }, onerror: () => reject(new Error('网络错误')), ontimeout: () => reject(new Error('请求超时')), }); }); } async function shShowSpectrogramPop(id, url) { const old = document.getElementById('ocd-spec-pop'); if (old) old.remove(); const mask = document.createElement('div'); mask.id = 'ocd-spec-pop'; mask.className = shGetThemeCls(); mask.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:' + ocdTopZ(20) + ';display:flex;align-items:center;justify-content:center;'; const win = document.createElement('div'); win.style.cssText = 'width:min(1100px,96vw);height:min(800px,92vh);display:flex;flex-direction:column;background:var(--bg,#242632);color:var(--fg,#e8eaf0);border:1px solid #4a69bd;border-radius:12px;padding:0;font:13px/1.6 -apple-system,"Microsoft YaHei",sans-serif;box-shadow:0 20px 60px rgba(0,0,0,.7);overflow:hidden;'; win.innerHTML = '
' + ' 📊 频谱图 · #' + esc(id) + '' + ' 100%' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ✕' + '
' + '
' + '
' + '
⏳
正在加载原图…
' + '
' + '
'; mask.appendChild(win); document.body.appendChild(mask); const $ = (i) => win.querySelector('#' + i); const stage = $('ocd-spec-stage'); const hint = $('ocd-spec-hint'); const scaleLabel = $('ocd-spec-scale'); let blobUrl = ''; let img = null; let scale = 1, tx = 0, ty = 0; let dragging = false, dragStartX = 0, dragStartY = 0, dragTx = 0, dragTy = 0; let naturalW = 0, naturalH = 0; const applyTransform = () => { if (!img) return; img.style.transform = 'translate(' + tx + 'px,' + ty + 'px) scale(' + scale + ')'; scaleLabel.textContent = Math.round(scale * 100) + '%'; }; const fitToStage = () => { if (!img || !naturalW || !naturalH) return; const sw = stage.clientWidth, sh = stage.clientHeight; const s = Math.min(sw / naturalW, sh / naturalH, 1); scale = s; tx = (sw - naturalW * s) / 2; ty = (sh - naturalH * s) / 2; applyTransform(); }; const cleanup = () => { if (blobUrl) { try { URL.revokeObjectURL(blobUrl); } catch (e) {} } mask.remove(); document.removeEventListener('keydown', escH); window.removeEventListener('mousemove', onSpecMove); window.removeEventListener('mouseup', onSpecUp); window.removeEventListener('resize', fitToStage); }; const escH = (e) => { if (e.key === 'Escape') cleanup(); }; $('ocd-spec-close').addEventListener('click', cleanup); mask.addEventListener('click', (e) => { if (e.target === mask) cleanup(); }); document.addEventListener('keydown', escH); const zoomBy = (factor, cx, cy) => { if (!img) return; const oldScale = scale; scale = Math.max(0.05, Math.min(20, scale * factor)); if (cx != null && cy != null) { tx = cx - (cx - tx) * (scale / oldScale); ty = cy - (cy - ty) * (scale / oldScale); } applyTransform(); }; $('ocd-spec-zoomin').addEventListener('click', () => zoomBy(1.25)); $('ocd-spec-zoomout').addEventListener('click', () => zoomBy(1 / 1.25)); $('ocd-spec-reset').addEventListener('click', () => { if (!img) return; scale = 1; tx = (stage.clientWidth - naturalW) / 2; ty = (stage.clientHeight - naturalH) / 2; applyTransform(); }); $('ocd-spec-fit').addEventListener('click', fitToStage); window.addEventListener('resize', fitToStage); $('ocd-spec-open').addEventListener('click', () => { if (blobUrl) window.open(blobUrl, '_blank'); }); stage.addEventListener('wheel', (e) => { e.preventDefault(); const rect = stage.getBoundingClientRect(); const cx = e.clientX - rect.left; const cy = e.clientY - rect.top; const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12; zoomBy(factor, cx, cy); }, { passive: false }); stage.addEventListener('mousedown', (e) => { if (!img || e.button !== 0) return; dragging = true; dragStartX = e.clientX; dragStartY = e.clientY; dragTx = tx; dragTy = ty; stage.style.cursor = 'grabbing'; e.preventDefault(); }); const onSpecMove = (e) => { if (!dragging) return; tx = dragTx + (e.clientX - dragStartX); ty = dragTy + (e.clientY - dragStartY); applyTransform(); }; const onSpecUp = () => { if (!dragging) return; dragging = false; stage.style.cursor = 'grab'; }; window.addEventListener('mousemove', onSpecMove); window.addEventListener('mouseup', onSpecUp); try { blobUrl = await shFetchImageAsBlobUrl(url); img = document.createElement('img'); img.src = blobUrl; img.style.cssText = 'position:absolute;left:0;top:0;transform-origin:0 0;will-change:transform;pointer-events:none;image-rendering:auto;'; img.addEventListener('load', () => { naturalW = img.naturalWidth; naturalH = img.naturalHeight; hint.style.display = 'none'; fitToStage(); }); img.addEventListener('error', () => { hint.innerHTML = '
⚠️ 图片加载失败
点击原图链接
'; }); stage.appendChild(img); } catch (e) { hint.innerHTML = '
❌ 加载失败:' + esc(e.message || '未知') + '

点击原图链接
'; } } const THANKS_DONE_KEY = 'ocd_helper_thanks_done_v1'; const STORAGE_KEYS = [ { k: PRESET_KEY, n: '发布页预设' }, { k: SEL_KEY, n: '自定义字段选择器' }, { k: PLATFORM_KEY, n: '音乐平台配置' }, { k: FROM_KEY, n: '自动填充顺序' }, { k: ODESLI_KEY, n: 'song.link API Key' }, { k: LAYOUT_KEY, n: '面板布局' }, { k: DUP_KEY, n: '发种查重选项' }, { k: FSP_KEY, n: '种子/频谱文件设置' }, { k: HIST_KEY, n: '发布历史' }, { k: DRAFT_KEY, n: '发布草稿' }, { k: PROF_KEY, n: '字段方案' }, { k: BP_KEY, n: '批量预设' }, { k: BSPEED_KEY, n: '批量并发速度' }, { k: POPT_KEY, n: '预设快照' }, { k: PUBMODE_KEY, n: '发布模式(manual/silent)' }, { k: MODULE_KEY, n: '模块启停与排序' }, /* V9.76.0:第三方模块生态(声明式模块本体 + 模块排序槽位)✓ */ { k: 'ocd_helper_moduleeco_v1', n: '第三方模块(声明式)' }, { k: 'ocd_helper_modorder_v1', n: '第三方模块排序' }, { k: SHELL_KEY, n: '悬浮球位置' }, { k: BATCH_KEY, n: '批量任务队列' }, { k: PUBLISHING_KEY, n: '跨页发布标记' }, { k: MAIL_KEY, n: '待发送站内信' }, { k: THEME_KEY, n: '主题' }, /* V9.76.17(用户 m13487 ②):颜色微调已下线 → 不再登记 ocd_helper_themetweak_v1 ✓ */ { k: FABDIY_KEY, n: '悬浮球 DIY' }, { k: MULTIUI_KEY, n: '多面板共存' }, { k: 'ocd_helper_dp_uid_v1', n: '数据面板 UID' }, { k: 'ocd_helper_statshist_v1', n: '发布统计' }, { k: 'ocd_helper_keeppub_v1', n: '已发布保留数' }, { k: DCP_WIDTH_KEY, n: '查重面板宽度' }, { k: DCP_POS_KEY, n: '查重面板位置' }, { k: 'ocd_helper_animpool_v1', n: '入场动画池' }, { k: DPT_KEY, n: '数据面板设置' }, { k: ALH_KEY, n: '专辑助手设置' }, { k: SH_KEY, n: '搜索求助设置' }, { k: SH_HIST_KEY, n: '搜索求助历史' }, { k: TAGPRESET_KEY, n: '分类标签预置' }, { k: 'ocd_helper_qobuz_appid_v1', n: 'Qobuz App ID' }, { k: TIDAL_AUTH_KEY, n: 'Tidal 凭据缓存' }, { k: SESSION_KEY, n: '发布会话' }, { k: BSILENT_KEY, n: '批量静默会话' }, { k: SILENT_ORDER_KEY, n: '静默发布顺序' }, { k: QUOTA_CACHE_KEY, n: '配额缓存' }, { k: QUOTA_THRESHOLD_KEY, n: '配额阈值' }, { k: QUOTA_ENABLE_KEY, n: '配额提醒开关' }, { k: SH_MYPOSTS_KEY, n: '我的求种' }, { k: SH_FOLLOWS_KEY, n: '我关注的求种' }, { k: shKeyMyRes(), n: '我的应求(站点筛选)' }, { k: SH_DRAFT_KEY, n: '求种草稿' }, { k: SH_SYNCCFG_KEY, n: '求种同步设置' }, { k: SH_TORRENTS_KEY, n: '我的种子缓存' }, { k: TORRENT_INFO_CACHE_KEY, n: '种子详情缓存' }, { k: FILE_LIST_CACHE_KEY, n: '文件列表缓存' }, { k: SPECTROGRAM_CACHE_KEY, n: '频谱图缓存' }, { k: THANKS_DONE_KEY, n: '已感谢记录' }, ]; function shGetThanksDone() { try { const raw = store.get(THANKS_DONE_KEY, null); return (raw && typeof raw === 'object') ? raw : {}; } catch (e) { return {}; } } function shMarkThanksDone(id) { const m = shGetThanksDone(); m[id] = Date.now(); const keys = Object.keys(m); if (keys.length > 500) { keys.sort((a, b) => m[a] - m[b]); for (let i = 0; i < keys.length - 500; i++) delete m[keys[i]]; } store.set(THANKS_DONE_KEY, m); } function shIsThanksDone(id) { return !!shGetThanksDone()[id]; } async function shAutoThanks(id) { if (!id) return { ok: false, skipped: true, reason: '无 ID' }; if ((advGroup('preview') || {}).autoThanks === false) return { ok: false, skipped: true, reason: '已按设置关闭自动感谢' }; if (shIsThanksDone(id)) { console.log('[Thanks] #' + id + ' 本地已记录,跳过'); return { ok: false, skipped: true, reason: '本地已记录' }; } let html = ''; try { html = await gmFetchText('https://open.cd/plugin_details.php?id=' + id, { Referer: 'https://open.cd/' }); } catch (e) { console.warn('[Thanks] 详情页抓取失败:', e.message); return { ok: false, reason: '详情页获取失败' }; } if (!html) return { ok: false, reason: '详情页为空' }; const doc = new DOMParser().parseFromString(html, 'text/html'); const btn = doc.getElementById('saythanks'); const addedEl = doc.getElementById('thanksadded'); if (!btn) { shMarkThanksDone(id); console.log('[Thanks] #' + id + ' 无感谢按钮,跳过'); return { ok: false, skipped: true, reason: '无感谢按钮' }; } if (addedEl) { const style = (addedEl.getAttribute('style') || '').toLowerCase(); if (style.indexOf('display:none') === -1 && style.indexOf('display: none') === -1) { shMarkThanksDone(id); console.log('[Thanks] #' + id + ' 已感谢过,跳过'); return { ok: false, skipped: true, reason: '已感谢过' }; } } return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'POST', url: 'https://open.cd/thanks.php', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Referer': 'https://open.cd/plugin_details.php?id=' + id, 'Origin': 'https://open.cd', }, data: 'id=' + id, timeout: advTimeoutMs('fetch', 15000), onload: (r) => { const okStatus = r.status >= 200 && r.status < 400; console.log('[Thanks] POST 响应 #' + id, '| status:', r.status, '| len:', (r.responseText || '').length, '| head:', (r.responseText || '').slice(0, 80).replace(/\s+/g, ' ')); if (okStatus) { shMarkThanksDone(id); console.log('[Thanks] ✅ #' + id + ' 感谢已发送'); try { toastSafe('✅ 已自动感谢发布者', 'ok'); if (window.__ocdShowFeedback) window.__ocdShowFeedback('✅ 已自动感谢发布者', 'ok'); } catch (e) { console.warn('[Thanks] toast 异常:', e.message); } resolve({ ok: true }); } else if (r.status === 403 || r.status === 404) { shMarkThanksDone(id); console.log('[Thanks] ⏭ #' + id + ' HTTP ' + r.status + '(无权限或不存在),标记跳过'); resolve({ ok: false, skipped: true, reason: 'HTTP ' + r.status }); } else { console.warn('[Thanks] ❌ #' + id + ' HTTP ' + r.status); resolve({ ok: false, reason: 'HTTP ' + r.status }); } }, onerror: () => resolve({ ok: false, reason: '网络错误' }), ontimeout: () => resolve({ ok: false, reason: '请求超时' }), }); }); } async function shFetchSpectrogram(id, force) { if (!force) { const cache = shLoadSpectrogramCache(); const c = cache[id]; if (c && c._ts && Date.now() - c._ts < SPECTROGRAM_TTL) { console.log('[Spectrogram] 缓存命中 #' + id); return c; } } const html = await gmFetchText('https://open.cd/details.php?id=' + id, { Referer: 'https://open.cd/', }); if (!html || html.length < 500) throw new Error('详情页为空'); /* A4-2:记住最近一次详情页 HTML,供简介富文本渲染使用 */ try { _ocdLastDetailHtml = String(html || ''); } catch (e) {} const url = shExtractSpectrogramUrl(html); const rec = { id, url, _ts: Date.now() }; const cache = shLoadSpectrogramCache(); cache[id] = rec; shSaveSpectrogramCache(cache); return rec; } function shExtractSpectrogramUrl(html) { const doc = new DOMParser().parseFromString(html, 'text/html'); const box = doc.querySelector('#divspectrogram'); if (!box) return ''; const img = box.querySelector('img'); if (!img) return ''; let src = img.getAttribute('src') || ''; if (!src) return ''; src = src.replace(/\.thumb\.jpg$/i, '').replace(/\.thumb\.png$/i, '.png'); if (src.indexOf('http') !== 0) { src = 'https://open.cd/' + src.replace(/^\//, ''); } return src; } /* A4 (ported from V10.5.0): sanitize the torrent description HTML so links stay clickable (target=_blank, relative URLs completed) and images are responsive, while script/style/iframe/form and on* handlers are stripped. */ /* ===== C (ported from V10.1.0#3): feedback pack ===== Collects version / page / browser / viewport / modules / schema / timers / request stats / recent logs into Markdown and copies it. Every dependency is existence-checked so a missing helper never throws "is not defined". */ function shBuildFeedbackPack() { const L = []; const hasFn = function (nm) { try { return typeof window[nm] === "function" || eval("typeof " + nm) === "function"; } catch (e) { return false; } }; L.push("## OpenCD 工具箱 反馈包"); L.push(""); try { L.push("- 脚本版本:v" + (typeof VERSION !== "undefined" ? VERSION : "?")); } catch (e) {} try { L.push("- 页面:" + location.origin + location.pathname + location.search); } catch (e) {} try { L.push("- 时间:" + new Date().toLocaleString("zh-CN", { hour12: false })); } catch (e) {} try { L.push("- 浏览器:" + String(navigator.userAgent || "").slice(0, 120)); } catch (e) {} try { L.push("- 视口:" + window.innerWidth + "×" + window.innerHeight + " DPR" + (window.devicePixelRatio || 1)); } catch (e) {} try { if (hasFn("getModuleRegistry")) { const reg = getModuleRegistry() || []; let enabled = reg.length; try { if (hasFn("getModuleStates")) { const st = getModuleStates() || {}; enabled = reg.filter(function (m) { return st[m.id] ? st[m.id].enabled !== false : true; }).length; } } catch (e) {} L.push("- 模块:" + reg.length + " 个(启用 " + enabled + ")"); } } catch (e) {} try { if (typeof SCHEMA_KEY !== "undefined") { const cur = Number(store.get(SCHEMA_KEY, 0)) || 0; let latest = "?"; try { latest = (typeof MIGRATIONS !== "undefined" && MIGRATIONS.length) ? MIGRATIONS[MIGRATIONS.length - 1].v : "?"; } catch (e) {} L.push("- 数据版本:" + cur + " / 最新 " + latest); } } catch (e) {} try { if (hasFn("ocdEveryList")) { const timers = ocdEveryList() || []; L.push("- 周期任务:" + timers.length + " 个 → " + timers.map(function (x) { return x && x.name; }).filter(Boolean).slice(0, 12).join(", ")); } } catch (e) {} try { if (hasFn("ocdReqStatsSnapshot")) { const rs = ocdReqStatsSnapshot(); if (rs && rs.byHost) { L.push("- 请求统计(域名 / 次数 / 失败 / 平均耗时):"); Object.keys(rs.byHost).slice(0, 8).forEach(function (h) { const v = rs.byHost[h] || {}; L.push(" - " + h + ":" + (v.n || 0) + " 次 / 失败 " + (v.fail || 0) + " / 平均 " + Math.round(v.avg || 0) + "ms"); }); } } } catch (e) {} try { if (hasFn("ocdLogList")) { const logs = (ocdLogList() || []).slice(-8); if (logs.length) { L.push("- 最近日志:"); logs.forEach(function (x) { L.push(" - [" + String(x.lv || x.level || "") + "] " + String(x.msg || x.text || "").slice(0, 160)); }); } } } catch (e) {} L.push(""); L.push("### 问题描述"); L.push("(请补充:做了什么操作、期望结果、实际结果、能否复现)"); return L.join("\n"); } async function shShowFeedbackPack() { const md = shBuildFeedbackPack(); try { GM_setClipboard(md); toastSafe("📋 反馈包已复制到剪贴板(" + md.length + " 字),可直接粘贴", "ok"); } catch (e) { try { navigator.clipboard.writeText(md); toastSafe("📋 反馈包已复制", "ok"); } catch (e2) { toastSafe("复制失败:请手动复制窗口内容", "err"); } } try { shUtilityModal("📦 反馈包(已复制)", String(esc(md)), { copyText: md }); } catch (e) {} return md; } function shSanitizeIntroHtml(html) { let h = String(html || ""); if (!h) return ""; h = h.replace(//gi, "") .replace(//gi, "") .replace(/<(iframe|object|embed|form|input|button|select|textarea|link|meta)[\s\S]*?>/gi, "") .replace(/<\/(iframe|object|embed|form|input|button|select|textarea)>/gi, ""); h = h.replace(/\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, ""); h = h.replace(/]*)>/gi, function (m0, attrs) { let a = String(attrs).replace(/\starget\s*=\s*("[^"]*"|'[^']*')/gi, ""); a = a.replace(/href\s*=\s*("|')([^"']*)\1/i, function (m1, q, url) { let u = String(url); if (u && u.indexOf("http") !== 0 && u.indexOf("//") !== 0 && u.indexOf("#") !== 0) u = "https://open.cd/" + u.replace(/^\//, ""); return 'href="' + u + '" target="_blank" rel="noopener noreferrer"'; }); return ""; }); h = h.replace(/]*)>/gi, function (m0, attrs) { let a = String(attrs); a = a.replace(/src\s*=\s*("|')([^"']*)\1/i, function (m1, q, url) { let u = String(url); if (u && u.indexOf("http") !== 0 && u.indexOf("//") !== 0 && u.indexOf("data:") !== 0) u = "https://open.cd/" + u.replace(/^\//, ""); return 'src="' + u + '"'; }); if (!/style\s*=/.test(a)) a += ' style="max-width:100%;height:auto;border-radius:6px;cursor:zoom-in;"'; if (!/loading\s*=/.test(a)) a += ' loading="lazy"'; return ""; }); return h; } /* V9.76.7(用户 m09091):把描述容器转成「带链接的干净 HTML」;没有 时返回空串,调用方回退纯文本 依据真机取证:details.php / plugin_details.php 的 #divdescr 里是 FROM:Qobuz
而 shHtmlToTextKeepBr 只取 textContent,链接被丢掉 → 简介弹窗里 FROM 只能看不能点 */ function shIntroHtmlOfBox(box) { try { if (!box) return ''; const h = shSanitizeIntroHtml(String(box.innerHTML || '')); return /;无
 时从描述文本按标记截取 */
  function shExtractNfoLog(html) {
    try {
      const doc = new DOMParser().parseFromString(String(html || ''), 'text/html');
      let best = '';
      let bestLen = 0;
      doc.querySelectorAll('pre').forEach((pre) => {
        const tx = String(pre.textContent || '').trim();
        if (tx.length > bestLen) { bestLen = tx.length; best = tx; }
      });
      if (!best || best.length < 60) {
        const raw = String((doc.querySelector('#ktable, .torrent_info, body') || {}).textContent || '');
        const marks = ['******************** Score', 'Score :', 'Exact Audio Copy', 'X Lossless Decoder', 'EAC extraction log', 'EAC 抓取日志', '检测模式', '抓取模式'];
        let at = -1;
        for (const mk of marks) { const i = raw.indexOf(mk); if (i !== -1 && (at === -1 || i < at)) at = i; }
        if (at !== -1) best = raw.slice(at).trim();
      }
      return (best && best.length > 8) ? best.slice(0, 30000) : '';
    } catch (e) { return ''; }
  }
  function shHasSpectrogram(html) {
    try {
      const doc = new DOMParser().parseFromString(String(html || ''), 'text/html');
      const box = doc.querySelector('#divspectrogram, div[id*="spectrogram" i]');
      if (!box) return false;
      /* 注意:不能用 textContent.length —— 图片没有文字,会恒为空 */
      return !!(box.querySelectorAll('img').length > 0 || String(box.innerHTML || '').trim().length > 0);
    } catch (e) { return false; }
  }

  /* A4-2:把最近一次详情页里的描述容器清洗成安全富文本(无则返回空串) */
  let _ocdLastDetailHtml = "";
  function shIntroRichHtml() {
    try {
      if (!_ocdLastDetailHtml) return "";
      const doc = new DOMParser().parseFromString(_ocdLastDetailHtml, "text/html");
      const cands = ["#ktable", ".torrent_info", "#torrent_info", "#description", ".description", "td.rowfollow", "div.rowfollow"];
      let box = null;
      for (const sel of cands) { try { const el = doc.querySelector(sel); if (el && String(el.textContent || "").trim().length > 40) { box = el; break; } } catch (e) {} }
      if (!box) return "";
      return shSanitizeIntroHtml(String(box.innerHTML || ""));
    } catch (e) { return ""; }
  }
  /* A1(V9.66.0):详情页抓取完成后,实时刷新 频谱图 / NFO-LOG 两个按钮的可用状态 */
  function shRefreshTinfoAvail(body, id) {
    try {
      if (!body) return;
      let spectroOk = false, nfoText = "";
      try { const c = shLoadSpectrogramCache(); const rec = c && c[id]; spectroOk = !!(rec && rec.url); } catch (e) {}
      try { if (_ocdLastDetailHtml) { nfoText = shExtractNfoLog(_ocdLastDetailHtml); if (!spectroOk) spectroOk = shHasSpectrogram(_ocdLastDetailHtml); } } catch (e) {}
      const sb = body.querySelector(".ocd-tinfo-act-spectro");
      const nb = body.querySelector(".ocd-tinfo-act-nfo");
      if (sb) { sb.disabled = !spectroOk; sb.style.opacity = spectroOk ? "1" : ".5"; sb.style.cursor = spectroOk ? "pointer" : "not-allowed"; sb.dataset.ocdOk = spectroOk ? "1" : "0"; }
      if (nb) { nb.disabled = !nfoText; nb.style.opacity = nfoText ? "1" : ".5"; nb.style.cursor = nfoText ? "pointer" : "not-allowed"; nb.dataset.ocdOk = nfoText ? "1" : "0"; }
      console.log("[TInfo] 可用性刷新:频谱图=" + (spectroOk ? "有" : "无") + " / NFO-LOG=" + (nfoText ? "有" : "无"));
    } catch (e) {}
  }

  /* V9.71.0:弹窗出现后按多次重试判定可用性(不依赖时序),必要时主动触发一次详情页抓取 */
  /* V9.72.0:弹窗出现时**直接预载详情页**,据实设置两个按钮的亮/灰(确定性,不依赖时序) */
  function shArmTinfoAvail(body, id) {
    try {
      if (!body) return;
      if (body.dataset && body.dataset.ocdArmed === "1") return;
      if (body.dataset) body.dataset.ocdArmed = "1";
      const apply = function (html) {
        try {
          const spectroOk = html ? !!(shHasSpectrogram(html) || shExtractSpectrogramUrl(html)) : false;
          const nfoText = html ? (shExtractNfoLog(html) || "") : "";
          if (html) { try { _ocdLastDetailHtml = String(html); } catch (e) {} }
          const sb = body.querySelector(".ocd-tinfo-act-spectro");
          const nb = body.querySelector(".ocd-tinfo-act-nfo");
          if (sb) { sb.disabled = !spectroOk; sb.style.opacity = spectroOk ? "1" : ".5"; sb.style.cursor = spectroOk ? "pointer" : "not-allowed"; sb.dataset.ocdOk = spectroOk ? "1" : "0"; }
          if (nb) { nb.disabled = !nfoText; nb.style.opacity = nfoText ? "1" : ".5"; nb.style.cursor = nfoText ? "pointer" : "not-allowed"; nb.dataset.ocdOk = nfoText ? "1" : "0"; }
          console.log("[TInfo] 详情页预载判定:频谱=" + (spectroOk ? "有" : "无") + " / NFO-LOG=" + (nfoText ? ("有 " + nfoText.length + " 字") : "无"));
        } catch (e) {}
      };
      (async function () {
        let html = "";
        try { html = await gmFetchText("https://open.cd/details.php?id=" + id, { Referer: "https://open.cd/" }); } catch (e) {}
        if (!html || String(html).length < 500) { try { html = _ocdLastDetailHtml || ""; } catch (e) {} }
        if (!html || String(html).length < 500) {
          try { if (typeof shFetchSpectrogram === "function") { await shFetchSpectrogram(id, true); html = _ocdLastDetailHtml || ""; } } catch (e) {}
        }
        apply(html);
        /* 若首轮没拿到,1.2s 后再试一次(网络慢时兜底) */
        if (!html || String(html).length < 500) {
          setTimeout(async function () {
            let h2 = "";
            try { h2 = await gmFetchText("https://open.cd/details.php?id=" + id, { Referer: "https://open.cd/" }); } catch (e) {}
            if (h2 && String(h2).length >= 500) apply(h2);
          }, 1200);
        }
      })();
    } catch (e) {}
  }

  /* V9.73.0:种子信息弹窗的看门狗 —— 自动发现弹窗、按 id 判定并持续纠正按钮状态 */
  const _ocdTinfoAvailCache = {};   /* id -> { spectro, nfo } */
  function ocdTinfoFindPopup() {
    try {
      const btns = document.querySelectorAll(".ocd-tinfo-act-spectro, .ocd-tinfo-act-nfo");
      for (const b of btns) {
        let p = b;
        for (let i = 0; i < 10 && p; i++) {
          if (p.textContent && /种子信息/.test(p.textContent) && /#\d{3,}/.test(p.textContent)) return p;
          p = p.parentElement;
        }
      }
    } catch (e) {}
    return null;
  }
  function ocdTinfoIdOf(pop) {
    try {
      const m = String(pop.textContent || "").match(/#(\d{3,})/);
      if (m) return m[1];
    } catch (e) {}
    try { if (window.__ocdTinfoId) return String(window.__ocdTinfoId); } catch (e) {}
    return "";
  }
  function ocdTinfoApply(pop, id, res) {
    try {
      const sb = pop.querySelector(".ocd-tinfo-act-spectro");
      const nb = pop.querySelector(".ocd-tinfo-act-nfo");
      if (sb) { sb.disabled = !res.spectro; sb.style.opacity = res.spectro ? "1" : ".5"; sb.style.cursor = res.spectro ? "pointer" : "not-allowed"; sb.dataset.ocdOk = res.spectro ? "1" : "0"; }
      if (nb) { nb.disabled = !res.nfo; nb.style.opacity = res.nfo ? "1" : ".5"; nb.style.cursor = res.nfo ? "pointer" : "not-allowed"; nb.dataset.ocdOk = res.nfo ? "1" : "0"; }
    } catch (e) {}
  }
  function ocdTinfoWatchdog() {
    try {
      const pop = ocdTinfoFindPopup();
      if (!pop) return;
      const id = ocdTinfoIdOf(pop);
      if (!id) return;
      const hit = _ocdTinfoAvailCache[id];
      if (hit) { ocdTinfoApply(pop, id, hit); return; }
      _ocdTinfoAvailCache[id] = { spectro: false, nfo: false, pending: true };
      (async function () {
        let html = "";
        try { html = await gmFetchText("https://open.cd/details.php?id=" + id, { Referer: "https://open.cd/" }); } catch (e) {}
        if (!html || String(html).length < 500) { try { html = _ocdLastDetailHtml || ""; } catch (e) {} }
        const res = {
          spectro: html ? !!(shHasSpectrogram(html) || shExtractSpectrogramUrl(html)) : false,
          nfo: html ? !!(shExtractNfoLog(html) || "") : false,
        };
        if (html) { try { _ocdLastDetailHtml = String(html); } catch (e) {} }
        _ocdTinfoAvailCache[id] = res;
        try { console.log("[TInfo] 看门狗判定 #" + id + ":频谱=" + (res.spectro ? "有" : "无") + " / NFO=" + (res.nfo ? "有" : "无") + "(详情页 " + String(html || "").length + " 字符)"); } catch (e) {}
        try { const p2 = ocdTinfoFindPopup(); if (p2) ocdTinfoApply(p2, id, res); } catch (e) {}
      })();
    } catch (e) {}
  }
  try { setInterval(ocdTinfoWatchdog, 600); setTimeout(ocdTinfoWatchdog, 200); } catch (e) {}

  function shShowIntroRich() {
    const rich = shIntroRichHtml();
    if (!rich) { try { toastSafe("本篇没有可用的富文本简介(将显示纯文本)", "warn"); } catch (e) {} return ""; }
    try {
      if (typeof shUtilityModal === "function") shUtilityModal("📝 简介(富文本)", '
' + rich + "
"); else shAlert(String(rich).replace(/<[^>]+>/g, "")); } catch (e) {} return rich; } async function shFetchFileList(id, force) { if (!force) { const cache = shLoadFileListCache(); const c = cache[id]; if (c && c._ts && Date.now() - c._ts < FILE_LIST_TTL) { console.log('[FileList] 缓存命中 #' + id); return c; } } const html = await gmFetchText( 'https://open.cd/viewfilelist.php?id=' + id, { Referer: 'https://open.cd/details.php?id=' + id } ); if (!html || html.length < 100) throw new Error('文件列表为空'); const files = shParseFileList(html); const rec = { id, files, total: files.length, _ts: Date.now() }; const cache = shLoadFileListCache(); cache[id] = rec; shSaveFileListCache(cache); return rec; } function shParseFileList(html) { const out = []; const doc = new DOMParser().parseFromString(html, 'text/html'); doc.querySelectorAll('tr').forEach(tr => { const tds = tr.querySelectorAll('td'); if (tds.length < 2) return; const firstTxt = (tds[0].textContent || '').trim(); if (/^(路径|Path)$/i.test(firstTxt)) return; const name = firstTxt; const size = (tds[1].textContent || '').trim(); if (!name) return; out.push({ name, size }); }); return out; } async function shFetchTorrentInfo(id, force) { if (!force) { const cache = shLoadTorrentInfoCache(); const c = cache[id]; if (c && c._v === TORRENT_INFO_CACHE_VER && c._ts && Date.now() - c._ts < TORRENT_INFO_TTL) { console.log('[TorrentInfo] 缓存命中 #' + id, '· 缓存于', new Date(c._ts).toLocaleTimeString('zh-CN', { hour12: false })); return c; } } let html = ''; let source = 'plugin'; try { html = await gmFetchText('https://open.cd/plugin_details.php?id=' + id, { Referer: 'https://open.cd/' }); if (!html || html.length < 500) throw new Error('plugin 页面为空'); if (/]*>\s*出错/i.test(html) || /页面不存在/.test(html)) { throw new Error('plugin 页面错误'); } } catch (e) { console.warn('[TorrentInfo] plugin_details 失败,回退 details.php:', e.message); source = 'details'; html = await gmFetchText('https://open.cd/details.php?id=' + id, { Referer: 'https://open.cd/' }); if (!html || html.length < 500) throw new Error('无法获取详情'); } const parsed = (source === 'plugin') ? shParseTorrentInfoFromPlugin(html, id) : shParseTorrentInfoFromDetails(html, id); parsed._source = source; parsed._ts = Date.now(); parsed._v = TORRENT_INFO_CACHE_VER; const cache = shLoadTorrentInfoCache(); cache[id] = parsed; shSaveTorrentInfoCache(cache); return parsed; } function shParseTorrentInfoFromPlugin(html, id) { const basic = shParsePluginDetail(html); return { id, cover: basic.cover || '', intro: basic.intro || '', introHtml: basic.introHtml || '', tracks: basic.tracks || [], meta: basic.meta || {}, }; } function shParseTorrentInfoFromDetails(html, id) { const doc = new DOMParser().parseFromString(html, 'text/html'); const out = { id, cover: '', intro: '', introHtml: '', tracks: [], meta: {} }; const cover = doc.querySelector('#imagelist img, .torrent_img img, #kparams + div img'); if (cover) { let src = cover.getAttribute('src') || ''; if (src && src.indexOf('http') !== 0) src = 'https://open.cd/' + src.replace(/^\//, ''); out.cover = src; } const descr = doc.querySelector('#divdescr'); if (descr) { let t = shHtmlToTextKeepBr(descr); t = t.replace(/([^\n])\s*◎\s*/g, '$1\n◎ '); t = t.replace(/\n◎ /g, '\n◎ '); t = t.replace(/\n{3,}/g, '\n\n').trim(); out.intro = t; out.introHtml = shIntroHtmlOfBox(descr); } const tl = doc.querySelector('#divtracklist'); if (tl && typeof shParseTrackFromDiv === 'function') { out.tracks = shParseTrackFromDiv(tl); out.tracksHtml = tl.innerHTML; } doc.querySelectorAll('table.resource tr').forEach(tr => { const label = tr.querySelector('td.rowtitle, td.rowhead'); if (!label) return; const val = label.nextElementSibling; if (!val) return; const k = label.textContent.replace(/[::]\s*$/, '').trim(); const v = val.textContent.replace(/\s+/g, ' ').trim(); if (k && v) out.meta[k] = v; }); return out; } function shShowSubPop(title, bodyHtml) { const old = document.getElementById('ocd-tinfo-subpop'); if (old) old.remove(); const mask = document.createElement('div'); mask.id = 'ocd-tinfo-subpop'; mask.className = shGetThemeCls(); mask.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:' + ocdTopZ(20) + ';display:flex;align-items:center;justify-content:center;'; const win = document.createElement('div'); win.style.cssText = 'width:min(680px,92vw);max-height:80vh;display:flex;flex-direction:column;background:var(--bg,#242632);color:var(--fg,#e8eaf0);border:1px solid #4a69bd;border-radius:12px;padding:14px 16px;font:13px/1.6 -apple-system,"Microsoft YaHei",sans-serif;box-shadow:0 20px 60px rgba(0,0,0,.6);animation:ocd-pop .2s ease;'; win.innerHTML = '
' + ' ' + esc(title) + '' + ' ✕' + '
' + '
' + bodyHtml + '
'; mask.appendChild(win); document.body.appendChild(mask); const close = () => mask.remove(); mask.addEventListener('click', (e) => { if (e.target === mask) close(); }); win.querySelector('#ocd-tinfo-subpop-close').addEventListener('click', close); } async function shShowTorrentInfoPop(id, title) { const old = document.getElementById('ocd-tinfo-pop'); if (old) old.remove(); const mask = document.createElement('div'); mask.id = 'ocd-tinfo-pop'; mask.className = shGetThemeCls(); mask.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:' + ocdTopZ(20) + ';display:flex;align-items:center;justify-content:center;'; const win = document.createElement('div'); win.style.cssText = 'width:min(760px,94vw);max-height:90vh;display:flex;flex-direction:column;background:var(--bg,#242632);color:var(--fg,#e8eaf0);border:1px solid #4a69bd;border-radius:12px;padding:14px 16px;font:13px/1.6 -apple-system,"Microsoft YaHei",sans-serif;box-shadow:0 20px 60px rgba(0,0,0,.55);'; win.innerHTML = '
' + ' ℹ️ 种子信息 · #' + esc(id) + '' + ' ' + ' ✕' + '
' + '
' + '
' + '
⏳
' + ' 正在加载详情…' + '
' + '
'; mask.appendChild(win); document.body.appendChild(mask); const $ = (i) => win.querySelector('#' + i); const feedbackBar = document.createElement('div'); feedbackBar.id = 'ocd-tinfo-feedback'; feedbackBar.style.cssText = 'display:none;margin:0 0 8px;padding:6px 12px;font-size:12px;border-radius:6px;line-height:1.6;'; win.insertBefore(feedbackBar, win.firstChild.nextSibling); const showFeedback = (msg, type) => { const colors = { ok: ['rgba(63,214,143,.15)', '#3fd68f'], err: ['rgba(255,107,107,.15)', '#ff6b6b'], warn: ['rgba(245,158,11,.15)', '#f59e0b'], }; const [bg, color] = colors[type] || colors.ok; feedbackBar.style.display = 'block'; feedbackBar.style.background = bg; feedbackBar.style.color = color; feedbackBar.style.border = '1px solid ' + color; feedbackBar.textContent = msg; clearTimeout(feedbackBar._timer); feedbackBar._timer = setTimeout(() => { feedbackBar.style.display = 'none'; }, 5000); }; window.__ocdShowFeedback = showFeedback; const close = () => { if (window.__ocdShowFeedback === showFeedback) { try { delete window.__ocdShowFeedback; } catch (e) { window.__ocdShowFeedback = null; } } mask.remove(); }; mask.addEventListener('click', (e) => { if (e.target === mask) close(); }); $('ocd-tinfo-close').addEventListener('click', close); $('ocd-tinfo-refresh').addEventListener('click', () => load(true)); const renderContent = (info) => { const meta = info.meta || {}; let html = ''; html += '
' + esc(title) + '
'; html += '
'; html += '
'; if (info.cover) { html += ''; } else { html += '
无封面
'; } const hasIntro = !!info.intro; const hasTracks = info.tracks && info.tracks.length > 0; const fileCount = (() => { try { const c = shLoadFileListCache(); return (c[id] && c[id].total) || 0; } catch (e) { return 0; } })(); /* A1(V9.66.0 修正):频谱图 / NFO-LOG 可用性取自**真实数据源** —— 频谱图读已缓存记录 cache[id].url;NFO/LOG 读最近抓取的详情页 HTML。 详情页尚未抓过时先置灰,抓取完成后由 shRefreshTinfoAvail() 实时刷新。 */ let shTinfoSpectroOk = false, shTinfoNfoText = ''; try { try { const _c = shLoadSpectrogramCache(); const _rec = _c && _c[id]; shTinfoSpectroOk = !!(_rec && _rec.url); } catch (e) {} try { if (_ocdLastDetailHtml) { shTinfoNfoText = shExtractNfoLog(_ocdLastDetailHtml); if (!shTinfoSpectroOk) shTinfoSpectroOk = shHasSpectrogram(_ocdLastDetailHtml); } } catch (e) {} } catch (e) {} const btnBaseStyle = 'padding:6px 8px;cursor:pointer;border:1px solid #4a69bd;border-radius:6px;background:rgba(74,105,189,.1);color:#4a69bd;font-size:11px;font-weight:600;white-space:nowrap;box-sizing:border-box;'; if (hasIntro || hasTracks) { html += '
'; if (hasIntro) { html += ''; } if (hasTracks) { html += ''; } html += '
'; } html += '
'; html += ''; html += '
'; html += '
'; html += ''; html += ''; html += '
'; html += '
'; html += '
'; const order = ['专辑名称', '艺术家名', '发行年份', '大小', '文件数', '类型', '类别', '格式', '媒介', '制作组', '来自', '标签列表', '转载规则']; const seen = {}; for (const k of order) { if (meta[k]) { html += '
' + esc(k) + ':' + esc(meta[k]) + '
'; seen[k] = 1; } } for (const k of Object.keys(meta)) { if (!seen[k]) { html += '
' + esc(k) + ':' + esc(meta[k]) + '
'; } } html += '
'; html += '
'; html += '
'; html += ' 数据源:' + (info._source === 'plugin' ? 'plugin' : 'details') + ' · 缓存于 ' + new Date(info._ts).toLocaleTimeString('zh-CN', { hour12: false }) + ''; html += ' '; html += ' '; html += ' 查看完整详情 ↗'; html += '
'; return html; }; const load = async (force) => { const body = $('ocd-tinfo-body'); body.style.cssText = 'flex:1;min-height:280px;max-height:72vh;overflow-y:auto;display:flex;align-items:center;justify-content:center;color:var(--fg-muted);font-size:12px;'; body.innerHTML = '
⏳
正在加载详情…
'; try { const info = await shFetchTorrentInfo(id, force); body.style.cssText = 'flex:1;min-height:280px;max-height:72vh;overflow-y:auto;padding-right:4px;'; body.innerHTML = renderContent(info); shMountTorrentActions(body, id); const introBtn = body.querySelector('.ocd-tinfo-act-intro'); try { if (introBtn) introBtn.dataset.ocdRich = "1"; } catch (e) {} if (introBtn) { introBtn.addEventListener('click', () => { /* V9.76.7(用户 m09091):站点描述里的 (例如 FROM:Qobuz → Qobuz 专辑页)保留下来做成可点链接 */ const rich = String(info.introHtml || ''); shShowSubPop('📝 简介 · #' + id, rich ? '' + '
' + rich + '
' : '
' + esc(info.intro || '') + '
'); }); } const tracksBtn = body.querySelector('.ocd-tinfo-act-tracks'); if (tracksBtn) { tracksBtn.addEventListener('click', () => { let content = ''; if (info.tracksHtml) { content = '
' + info.tracksHtml + '
'; } else { content = shRenderTracksToHtml(info.tracks); } const cnt = (info.tracks || []).length; shShowSubPop('🎵 曲目' + (cnt ? '(' + cnt + ' 首)' : '') + ' · #' + id, content); }); } const filesBtn = body.querySelector('.ocd-tinfo-act-files'); if (filesBtn) { filesBtn.addEventListener('click', async () => { filesBtn.disabled = true; const oldText = filesBtn.textContent; filesBtn.textContent = '⏳ 加载中…'; try { const rec = await shFetchFileList(id); const files = rec.files || []; /* V9.67.0:抓取完成后**实时同步**按钮上的数量(此前是构建时写死的旧值) */ try { filesBtn.textContent = '📦 文件(' + files.length + ')'; filesBtn.disabled = false; filesBtn.dataset.ocdCount = String(files.length); } catch (e) {} if (!files.length) { /* 空列表也要恢复按钮(否则会卡在"⏳ 加载中…") */ try { filesBtn.textContent = '📦 文件(0)'; filesBtn.disabled = false; } catch (e) {} shShowSubPop('📦 文件列表 · #' + id, '
文件列表为空
'); return; } const rowsHtml = files.map(f => '
' + ' ' + esc(f.name) + '' + ' ' + esc(f.size) + '' + '
' ).join(''); shShowSubPop('📦 文件列表(' + files.length + ' 个文件)· #' + id, rowsHtml); } catch (e) { shShowSubPop('📦 文件列表 · #' + id, '
❌ 加载失败:' + esc(e.message || '未知') + '
'); } finally { filesBtn.disabled = false; filesBtn.textContent = filesBtn.dataset.ocdCount ? ("📦 文件(" + filesBtn.dataset.ocdCount + ")") : oldText; /* V9.68.0: 还原时保留真实数量 */ } }); } const nfoBtn = body.querySelector('.ocd-tinfo-act-nfo'); if (nfoBtn) { nfoBtn.addEventListener('click', () => { try { /* V9.68.0: resolve lazily at click time (a grey button that does nothing is worse than none) */ (async () => { try { if (!_ocdLastDetailHtml) { try { await shFetchSpectrogram(id); } catch (e) {} } const _txt = (_ocdLastDetailHtml ? shExtractNfoLog(_ocdLastDetailHtml) : "") || shTinfoNfoText; if (!_txt) { shToast("暂无 NFO / LOG 内容", "warn"); return; } try { if (typeof shUtilityModal === "function") { (function () { const wid = "ocd-nfo-view-" + Date.now(); const htmlv = '
' + esc(_txt) + "
"; const w = shUtilityModal("📄 NFO / LOG · #" + id, htmlv, { copyText: _txt }); try { const box = w && w.querySelector ? w.querySelector("#" + wid) : null; if (box) { let down = false, sx0 = 0, sy0 = 0, sl0 = 0, st0 = 0; box.addEventListener("mousedown", function (ev) { down = true; sx0 = ev.clientX; sy0 = ev.clientY; sl0 = box.scrollLeft; st0 = box.scrollTop; box.style.cursor = "grabbing"; ev.preventDefault(); }); window.addEventListener("mousemove", function (ev) { if (!down) return; box.scrollLeft = sl0 - (ev.clientX - sx0); box.scrollTop = st0 - (ev.clientY - sy0); }); window.addEventListener("mouseup", function () { if (down) { down = false; box.style.cursor = "grab"; } }); } } catch (e) {} })(); } else shAlert(_txt); } catch (e) { shAlert(_txt); } } catch (e) { shToast("读取 NFO/LOG 失败:" + ((e && e.message) || e), "err"); } })(); } catch (e) {} }); } const spectroBtn = body.querySelector('.ocd-tinfo-act-spectro'); try { shRefreshTinfoAvail(body, id); } catch (e) {} /* V9.69.0: 弹窗内任意点击后自动重算并刷新两个按钮(覆盖异步抓取完成时刻,修"点击后按钮不更新") */ try { if (!body.dataset.ocdClickBind) { body.dataset.ocdClickBind = "1"; body.addEventListener("click", function () { [200, 500, 1200].forEach(function (ms) { setTimeout(function () { shRefreshTinfoAvail(body, id); }, ms); }); }, true); } } catch (e) {} /* V9.67.0:若缓存里已有文件列表,打开时就用真实数量刷新「文件(N)」标签 */ try { const _fb = body.querySelector('.ocd-tinfo-act-files'); const _cache = (typeof shLoadFileListCache === 'function') ? shLoadFileListCache() : null; const _recF = _cache && _cache[id]; if (_fb && _recF && Array.isArray(_recF.files)) { _fb.textContent = '📦 文件(' + _recF.files.length + ')'; _fb.dataset.ocdCount = String(_recF.files.length); } } catch (e) {} if (spectroBtn) { spectroBtn.addEventListener('click', async () => { spectroBtn.disabled = true; const oldText = spectroBtn.textContent; spectroBtn.textContent = '⏳ 加载中…'; try { const rec = await shFetchSpectrogram(id); try { shArmTinfoAvail(body, id); } catch (e) {} /* V9.67.0:若缓存里已有文件列表,打开时就用真实数量刷新「文件(N)」标签 */ try { const _fb = body.querySelector('.ocd-tinfo-act-files'); const _cache = (typeof shLoadFileListCache === 'function') ? shLoadFileListCache() : null; const _recF = _cache && _cache[id]; if (_fb && _recF && Array.isArray(_recF.files)) { _fb.textContent = '📦 文件(' + _recF.files.length + ')'; _fb.dataset.ocdCount = String(_recF.files.length); } } catch (e) {} if (!rec.url) { shShowSubPop('📊 频谱图 · #' + id, '
该种子没有频谱图
'); return; } await shShowSpectrogramPop(id, rec.url); } catch (e) { shShowSubPop('📊 频谱图 · #' + id, '
❌ 加载失败:' + esc(e.message || '未知') + '
'); } finally { spectroBtn.disabled = false; spectroBtn.textContent = oldText; } }); } } catch (e) { body.style.cssText = 'flex:1;min-height:280px;max-height:72vh;overflow-y:auto;'; body.innerHTML = '
'; } }; load(false); (async () => { try { const r = await shAutoThanks(id); if (r.ok) { } else if (r.skipped) { console.log('[Thanks] 跳过:' + r.reason); } else { console.warn('[Thanks] 失败:' + (r.reason || '未知')); toastSafe('⚠️ 自动感谢未成功:' + (r.reason || '').slice(0, 40), 'err'); } } catch (e) { console.warn('[Thanks] 异常:' + (e.message || e)); } })(); } function shFindSiteActions(id) { const out = { dl: null, bm: null, rss: null }; if (!id) return out; const sid = String(id); for (const a of document.querySelectorAll('a[href*="download.php?id="]')) { const m = String(a.getAttribute('href') || '').match(/download\.php\?id=(\d+)/); if (m && m[1] === sid) { out.dl = a; break; } } for (const a of document.querySelectorAll('a[href*="bookmark("]')) { if (String(a.getAttribute('href') || '').indexOf('bookmark(' + sid + ',') !== -1) { out.bm = a; break; } } for (const a of document.querySelectorAll('a[onclick*="setbookmark"]')) { if (String(a.getAttribute('onclick') || '').indexOf(',' + sid + ',') !== -1) { out.rss = a; break; } } return out; } function shMountTorrentActions(scope, id) { const host = scope.querySelector('#ocd-tinfo-siteacts'); if (!host || !id) return; const acts = shFindSiteActions(id); const mk = (label, title, iconEl, handler) => { const a = document.createElement('a'); a.href = 'javascript:void(0)'; a.title = title; a.style.cssText = 'display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border:1px solid var(--border,#444);border-radius:6px;background:transparent;color:var(--fg-muted);font-size:11px;font-weight:600;text-decoration:none;cursor:pointer;line-height:1.4;white-space:nowrap;'; let icon = null; if (iconEl) { icon = iconEl.cloneNode(true); icon.removeAttribute('id'); icon.setAttribute('style', (iconEl.getAttribute('style') || '') + ';display:inline-block;vertical-align:middle;'); a.appendChild(icon); } const sp = document.createElement('span'); sp.textContent = label; a.appendChild(sp); a.addEventListener('mouseenter', () => { a.style.borderColor = '#4a69bd'; a.style.color = '#4a69bd'; }); a.addEventListener('mouseleave', () => { a.style.borderColor = 'var(--border,#444)'; a.style.color = 'var(--fg-muted)'; }); a.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); handler(icon); }); return a; }; host.appendChild(mk('下载', '下载本种(调用站内下载)', acts.dl ? acts.dl.querySelector('img') : null, () => { if (acts.dl && acts.dl.isConnected) { acts.dl.click(); return; } window.open('https://open.cd/download.php?id=' + id, '_blank'); })); host.appendChild(mk('收藏', '收藏 / 取消收藏(调用站内收藏)', acts.bm ? acts.bm.querySelector('img') : null, (icon) => { if (!acts.bm || !acts.bm.isConnected) { toastSafe('未找到站内收藏元素,请到详情页操作', 'err'); return; } acts.bm.click(); setTimeout(() => { const oi = acts.bm.querySelector('img'); if (icon && oi) { icon.className = oi.className; if (oi.getAttribute('title')) icon.setAttribute('title', oi.getAttribute('title')); if (oi.getAttribute('alt')) icon.setAttribute('alt', oi.getAttribute('alt')); } }, 800); })); /* 🚨 举报:站点入口为 report.php?torrent=(在「收藏」右侧) */ host.appendChild(mk('举报', '向站点举报该种子(打开站点举报表单,由站点校验后处理)', null, () => { window.open('https://open.cd/report.php?torrent=' + id, '_blank'); toastSafe('🚨 已打开站点举报表单(' + id + ')', 'info'); try { ocdAudit('system', '举报种子', String(id)); } catch (e) {} })); /* 只要有 id 就保留这排按钮(举报仅依赖 id),完全没有下载/收藏元素时也不再隐藏 */ if (!id) host.style.display = 'none'; } let _siteObsTimer = null; function shReinjectSiteButtons() { if (document.hidden) return; try { if (/viewrequests\.php/i.test(location.pathname) && !/[?&]action=view/i.test(location.search)) { shInjectFollowButtonsOnSite(); shInjectAppendButtonsOnList(); shInjectPreviewButtonsOnList(); shInjectRespondButtonsOnList(); shReflowPreviewButtons(); } } catch (e) { console.warn('[Observe] 求种页补注入异常:', e.message || e); } try { if (/torrents\.php/i.test(location.pathname)) { shInjectTorrentInfoButtons(); shApplyPromoHighlight(); } } catch (e) { console.warn('[Observe] 种子页补注入异常:', e.message || e); } } function shObserveSiteDom() { if (window.__ocdSiteObserved) return; window.__ocdSiteObserved = true; const target = document.getElementById('content') || document.body; if (!target || typeof MutationObserver !== 'function') return; const ob = new MutationObserver(() => { if (_siteObsTimer) return; _siteObsTimer = setTimeout(() => { _siteObsTimer = null; shReinjectSiteButtons(); }, 600); }); ob.observe(target, { childList: true, subtree: true }); console.log('[Observe] 已监听站点 DOM 变化,AJAX 翻页后会自动补注入按钮'); } function shInjectTorrentInfoButtons() { if (!/torrents\.php/i.test(location.pathname)) return; let added = 0; document.querySelectorAll('td.nowrap.embedded').forEach(td => { if (td.dataset.ocdTinfoInjected === '1') return; const img = td.querySelector('img[src*="share_rule_"]'); if (!img) return; const tn = td.closest('table.torrentname'); if (!tn) return; const a = tn.querySelector('a[href*="details.php?id="]') || tn.querySelector('a[href*="plugin_details.php?id="]'); if (!a) return; const href = a.getAttribute('href') || ''; const m = href.match(/id=(\d+)/); if (!m) return; const id = m[1]; const title = (a.textContent || '').trim() || ('种子 #' + id); const btn = document.createElement('span'); btn.className = 'ocd-tinfo-btn'; btn.title = '在当前页面查看种子详情'; btn.innerHTML = '预览'; btn.style.cssText = 'display:inline-flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;width:34px;height:34px;box-sizing:border-box;padding:3px 0;margin-left:5px;border:1px solid #4a69bd;border-radius:6px;color:#4a69bd;cursor:pointer;user-select:none;vertical-align:middle;background:linear-gradient(135deg,rgba(74,105,189,.08),rgba(106,140,216,.04));transition:all .18s ease;'; btn.addEventListener('mouseenter', () => { btn.style.background = 'linear-gradient(135deg,#4a69bd,#6a8cd8)'; btn.style.color = '#fff'; btn.style.borderColor = '#6a8cd8'; btn.style.transform = 'translateY(-1px)'; btn.style.boxShadow = '0 3px 10px rgba(74,105,189,.35)'; }); btn.addEventListener('mouseleave', () => { btn.style.background = 'linear-gradient(135deg,rgba(74,105,189,.08),rgba(106,140,216,.04))'; btn.style.color = '#4a69bd'; btn.style.borderColor = '#4a69bd'; btn.style.transform = ''; btn.style.boxShadow = ''; }); btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); shShowTorrentInfoPop(id, title); }); const hostEl = img.parentNode; if (!hostEl) return; hostEl.insertBefore(btn, img.nextSibling); td.dataset.ocdTinfoInjected = '1'; added++; }); if (added) console.log('[TorrentInfo] 已注入 ' + added + ' 个信息按钮'); } function shInjectRespondButtonsOnList() { if (!/viewrequests\.php/i.test(location.pathname)) return; if (/[?&]action=view/i.test(location.search)) return; const rows = document.querySelectorAll('tr'); let added = 0; for (const tr of rows) { const titleLink = tr.querySelector('a[href*="viewrequests.php?action=view&id="]'); if (!titleLink) continue; const tds = tr.querySelectorAll('td'); if (tds.length < 6) continue; let isListRow = false; for (const f of tds[1].querySelectorAll('font')) { const c = (f.getAttribute('color') || '').toLowerCase(); if (c === '#ff0000') { isListRow = true; break; } } if (!isListRow) continue; const m = (titleLink.getAttribute('href') || '').match(/id=(\d+)/); if (!m) continue; const reqid = m[1]; const lastTd = tds[tds.length - 1]; const resLink = lastTd.querySelector('a[href*="viewrequests.php?action=res"]'); if (!resLink) continue; if (lastTd.querySelector('.ocd-site-respond')) continue; const btn = document.createElement('span'); btn.className = 'ocd-site-respond'; btn.textContent = '⚡ 应求'; btn.title = '快速应求:从我的种子中选一个'; btn.style.cssText = 'display:inline-block;margin-left:6px;padding:0 6px;border:1px solid #3fd68f;border-radius:4px;color:#3fd68f;font-size:11px;cursor:pointer;user-select:none;line-height:1.5;vertical-align:middle;'; btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const title = (titleLink.textContent || '').trim(); shQuickRespondDialog(reqid, title); }); const statusWrap = document.createElement('span'); statusWrap.style.cssText = 'display:inline-flex;align-items:center;gap:6px;white-space:nowrap;'; resLink.parentNode.insertBefore(statusWrap, resLink); statusWrap.appendChild(resLink); statusWrap.appendChild(btn); if (lastTd) lastTd.style.whiteSpace = 'nowrap'; added++; } if (added) console.log('[Respond] 列表页已注入 ' + added + ' 个按钮'); } function shShowMyRequests() { const old = document.getElementById('ocd-myreq-pop'); if (old) { old.remove(); return; } const mask = document.createElement('div'); mask.id = 'ocd-myreq-pop'; mask.className = shGetThemeCls(); mask.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:' + ocdTopZ(20) + ';display:flex;align-items:center;justify-content:center;'; const win = document.createElement('div'); win.style.cssText = 'width:min(680px,94vw);max-height:86vh;display:flex;flex-direction:column;background:var(--bg,#242632);color:var(--fg,#e8eaf0);border:1px solid #4a69bd;border-radius:12px;padding:14px 16px;font:13px/1.6 -apple-system,"Microsoft YaHei",sans-serif;box-shadow:0 20px 60px rgba(0,0,0,.55);'; win.innerHTML = '
' + ' 📋 我的求种 / 我的应求' + ' ' + ' ' + ' ✕' + '
' + '
' + '
' + '
' + '
'; mask.appendChild(win); document.body.appendChild(mask); const $ = (id) => win.querySelector('#' + id); const close = () => mask.remove(); mask.addEventListener('click', (e) => { if (e.target === mask) close(); }); $('ocd-myreq-close').addEventListener('click', close); const state = { tab: 'mine', filter: 'all', loading: false, list: [], pages: 0 }; const STATUS_CN = { pending: '求种中', responded: '有应求', completed: '求种成功', unknown: '未知' }; const STATUS_CLR = { pending: '#9aa3b2', responded: '#f59e0b', completed: '#3fd68f', unknown: '#9aa3b2' }; const reloadList = () => { state.list = state.tab === 'mine' ? shGetMyPosts() : state.tab === 'myres' ? shGetMyRes() : shGetFollows(); }; const syncSite = async (kind) => { if (kind === 'followed') return; state.loading = true; renderFoot(); try { const r = await shFetchSiteReqList(kind); const rows = r.rows; state.siteErr = ''; state.siteOk = true; state.pages = r.pages || 0; if (kind === 'mine') shSaveMyPosts(rows); else shSaveMyRes(rows); if (state.tab === kind) { state.list = rows; renderTabs(); renderFilter(); renderList(); renderFoot(); } toastSafe((kind === 'mine' ? '📋 我的求种' : '🙋 我的应求') + ' 已同步 ' + rows.length + ' 条 · ' + (r.pages || 0) + ' 页', 'ok'); } catch (e) { state.siteErr = (e && e.message) || String(e); console.warn('[MyReq] 站点筛选同步失败:', state.siteErr); toastSafe('站点筛选同步失败:' + state.siteErr, 'err'); } state.loading = false; renderFoot(); }; const renderTabs = () => { const myN = shGetMyPosts().length; const foN = shGetFollows().length; const myResN = shGetMyRes().length; const tabs = [ ['mine', '📋 我的求种', myN], ['myres', '🙋 我的应求', myResN], ['followed', '⭐ 我关注的', foN], ]; $('ocd-myreq-tabs').innerHTML = tabs.map(([k, label, n]) => { const on = state.tab === k; return ''; }).join(''); win.querySelectorAll('.ocd-myreq-tab').forEach(b => { b.addEventListener('click', () => { state.tab = b.dataset.k; state.filter = 'all'; reloadList(); renderTabs(); renderFilter(); renderList(); renderFoot(); if (state.tab !== 'followed') syncSite(state.tab); }); }); }; const renderFilter = () => { const cnt = { all: state.list.length, pending: 0, responded: 0, completed: 0 }; state.list.forEach(r => { cnt[r.status] = (cnt[r.status] || 0) + 1; }); const items = [ ['all', '全部', cnt.all], ['pending', '求种中', cnt.pending], ['responded', '有应求', cnt.responded], ['completed', '求种成功', cnt.completed], ].filter(([k, _, n]) => k === 'all' || n > 0); $('ocd-myreq-filter').innerHTML = items.map(([k, label, n]) => { const on = state.filter === k; return ''; }).join(''); win.querySelectorAll('.ocd-myreq-ft').forEach(b => { b.addEventListener('click', () => { state.filter = b.dataset.k; renderFilter(); renderList(); }); }); }; const renderList = () => { const body = $('ocd-myreq-body'); if (!state.list.length) { const isMine = state.tab === 'mine'; body.innerHTML = '
' + '
' + (isMine ? '📭' : '⭐') + '
' + (isMine ? '还没有发布过求种帖
在「一键求种」里发布后,这里会记录并展示状态' : '还没有关注的帖子
在「搜索求助」的求助区结果里点「☆ 关注」收藏帖子') + '
'; return; } const arr = state.filter === 'all' ? state.list : state.list.filter(r => r.status === state.filter); if (!arr.length) { body.innerHTML = '
该筛选下没有帖子
'; return; } /* 3.8 每页条数可配(0 = 不限),默认 20 */ const perPage = Math.max(0, Number((advGroup('ask') || {}).perPage) || 20); const totalN = arr.length; const pageCount = perPage ? Math.max(1, Math.ceil(totalN / perPage)) : 1; if (!state.page || state.page < 1) state.page = 1; if (state.page > pageCount) state.page = pageCount; const view = perPage ? arr.slice((state.page - 1) * perPage, state.page * perPage) : arr; const isFollow = state.tab === 'followed'; body.innerHTML = view.map(r => { const clr = STATUS_CLR[r.status] || 'var(--fg-muted)'; const cn = STATUS_CN[r.status] || '未知'; const when = new Date(r.ts).toLocaleString('zh-CN', { hour12: false }); const subtitle = [r.year, r.artist].filter(Boolean).join(' '); return '
' + '
' + ' ' + cn + '' + ' ' + esc(r.title) + '' + '
' + '
' + ' 💰 ' + esc(String(r.bonus)) + '' + ' |' + ' ' + esc(when) + '' + ' |' + ' #' + esc(r.id) + '' + (subtitle ? ' | ' + esc(subtitle) + '' : '') + ' ' + (r.status !== 'completed' ? '' : '') + (isFollow ? (r.status !== 'completed' ? '' : '') + '' : '' + '') + '查看 ↗' + '
' + '
'; }).join(''); body.querySelectorAll('.ocd-myreq-append').forEach(b => { b.addEventListener('click', () => { const cb = parseInt(b.dataset.bonus, 10); shOpenAppendDialog(b.dataset.id, b.dataset.title || '', Number.isInteger(cb) ? cb : 0); }); }); body.querySelectorAll('.ocd-myreq-edit').forEach(b => { b.addEventListener('click', () => shOpenEditDialog(b.dataset.id, b.dataset.title || '')); }); body.querySelectorAll('.ocd-myreq-del').forEach(b => { b.addEventListener('click', () => { if (!confirm('从本地记录中移除这条?\n(不会删除站内帖子)')) return; shDelMyPost(b.dataset.id); reloadList(); renderTabs(); renderFilter(); renderList(); renderFoot(); }); }); body.querySelectorAll('.ocd-follow-del').forEach(b => { b.addEventListener('click', () => { if (!confirm('取消关注这条帖子?\n(不会删除站内帖子)')) return; shDelFollow(b.dataset.id); reloadList(); renderTabs(); renderFilter(); renderList(); renderFoot(); }); }); body.querySelectorAll('.ocd-myresp-btn').forEach(b => { b.addEventListener('click', () => { shQuickRespondDialog(b.dataset.id, b.dataset.title || ''); }); }); if (pageCount > 1) { body.innerHTML += '
' + '' + '第 ' + state.page + ' / ' + pageCount + ' 页 · 共 ' + totalN + ' 条(每页 ' + perPage + ')' + '' + '' + '' + '
'; const pv2 = body.querySelector('.ocd-myreq-prev'); if (pv2) pv2.addEventListener('click', () => { state.page = Math.max(1, state.page - 1); renderList(); }); const nx2 = body.querySelector('.ocd-myreq-next'); if (nx2) nx2.addEventListener('click', () => { state.page = Math.min(pageCount, state.page + 1); renderList(); }); const al2 = body.querySelector('.ocd-myreq-all'); if (al2) al2.addEventListener('click', () => { advSave({ ask: { perPage: 0 } }); toastSafe('已设为不分页(高级设置可改回)', 'ok'); renderList(); }); } }; const renderFoot = () => { const n = state.list.length; const lastCheck = state.list.map(x => x.lastCheck || 0).sort((a, b) => b - a)[0] || 0; const src = state.tab === 'followed' ? '本地关注列表' : state.tab === 'myres' ? '站点筛选 viewrequests.php?finished=myres' : '站点筛选 viewrequests.php?finished=mine'; const scfg = shGetSyncCfg(); const autoTxt = scfg.enabled ? ('按 ' + scfg.gapMin + ' 分钟自动同步' + (scfg.lastAuto ? ('(上次 ' + new Date(scfg.lastAuto).toLocaleString('zh-CN', { hour12: false }).slice(5, 16) + ')') : '')) : '自动同步未开启'; $('ocd-myreq-foot').textContent = '共 ' + n + ' 条' + (state.pages ? (' · ' + state.pages + ' 页') : '') + ' · 来源:' + src + (state.loading ? ' · ⏳ 正在同步…' : (state.siteOk ? ' · 已同步' : '')) + (state.siteErr ? (' · ⚠ ' + state.siteErr) : '') + ' · ' + autoTxt + (lastCheck ? ' · 状态更新于 ' + new Date(lastCheck).toLocaleTimeString('zh-CN', { hour12: false }) : ''); }; const fetchOneStatus = async (rec, whichTab) => { try { const html = await gmFetchText( 'https://open.cd/viewrequests.php?action=view&id=' + rec.id, { Referer: 'https://open.cd/viewrequests.php' } ); let status = rec.status; const reqM = html.match(/]*class="rowhead[^>]*>\s*应求\s*<\/td>\s*]*class="rowfollow"[^>]*>([\s\S]{0,800}?)<\/td>/i); if (reqM) { const seg = reqM[1]; if (/还没有应求/.test(seg)) { status = 'pending'; } else if (/details\.php\?id=\s*\d+/.test(seg)) { status = /求种成功/.test(html) ? 'completed' : 'responded'; } } if (/]*class="rowhead[^>]*>\s*状态\s*<\/td>\s*]*class="rowfollow[^>]*>[\s\S]{0,80}?求种成功/i.test(html)) { status = 'completed'; } if (whichTab === 'mine') shUpdateMyPost(rec.id, { status, lastCheck: Date.now() }); else shUpdateFollow(rec.id, { status, lastCheck: Date.now() }); return status; } catch (e) { console.warn('[MyReq] 抓取 #' + rec.id + ' 状态失败:', e.message); return rec.status; } }; $('ocd-myreq-settings').addEventListener('click', () => { shOpenSyncSettings(() => { const cfg = shGetSyncCfg(); if (cfg.enabled) shRunAutoSync(); }); }); $('ocd-myreq-refresh').addEventListener('click', async () => { if (state.loading) return; reloadList(); renderTabs(); renderFilter(); renderList(); renderFoot(); if (!state.list.length) { toastSafe('没有可刷新的帖子', 'err'); return; } state.loading = true; const btn = $('ocd-myreq-refresh'); const old = btn.textContent; btn.disabled = true; btn.textContent = '⏳ 刷新中…'; $('ocd-myreq-foot').textContent = '正在抓取状态 0/' + state.list.length + '…'; let done = 0; const curTab = state.tab; const listSnapshot = state.list.slice(); for (const rec of listSnapshot) { await fetchOneStatus(rec, curTab); done++; $('ocd-myreq-foot').textContent = '正在抓取状态 ' + done + '/' + listSnapshot.length + '…'; await new Promise(r => setTimeout(r, 400)); } reloadList(); state.loading = false; btn.disabled = false; btn.textContent = old; renderTabs(); renderFilter(); renderList(); renderFoot(); toastSafe('✅ 状态已刷新', 'ok'); }); window.__shMyreqRefresh = () => { if (!mask.isConnected) { window.__shMyreqRefresh = null; return; } try { reloadList(); renderTabs(); if (state.tab === 'followed') { renderFilter(); renderList(); renderFoot(); } } catch (e) {} }; reloadList(); renderTabs(); renderFilter(); renderList(); renderFoot(); let _syncing = false; const shRunAutoSync = () => { if (_syncing) return; if (!mask.isConnected) return; const cfg = shGetSyncCfg(); if (!cfg.enabled) { console.log('[MyReq] 自动同步已关闭,跳过'); return; } const gapMs = cfg.gapMin * 60 * 1000; const curTab0 = state.tab; reloadList(); const all = state.list; const needSync = all .filter(r => !r.lastCheck || Date.now() - r.lastCheck > gapMs) .slice(0, cfg.limit); if (!needSync.length) { console.log('[MyReq] 无需同步(全部在', cfg.gapMin, '分钟内已检查)'); return; } _syncing = true; console.log('[MyReq] 后台自动同步', needSync.length, '条(tab:', curTab0, '| 间隔:', cfg.gapMin, '分钟 | 上限:', cfg.limit, '条)'); let syncDone = 0; (async () => { for (const rec of needSync) { try { await fetchOneStatus(rec, curTab0); } catch (e) {} syncDone++; if (syncDone % 3 === 0) { $('ocd-myreq-foot').textContent = '后台同步 ' + syncDone + '/' + needSync.length + '…'; } await new Promise(r => setTimeout(r, 600)); } reloadList(); renderTabs(); renderFilter(); renderList(); renderFoot(); _syncing = false; console.log('[MyReq] 后台同步完成,共', needSync.length, '条'); })(); }; const _origRemove = mask.remove.bind(mask); mask.remove = function () { window.__shMyreqRefresh = null; _origRemove(); }; setTimeout(shRunAutoSync, 300); } let _shDraftBoxRef = null; function shRefreshDraftBox() { if (_shDraftBoxRef) _shDraftBoxRef(); } function shShowDraftBox(onCountChange) { const old = document.getElementById('ocd-draft-pop'); if (old) { old.remove(); _shDraftBoxRef = null; return; } const mask = document.createElement('div'); mask.id = 'ocd-draft-pop'; mask.className = shGetThemeCls(); mask.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:' + ocdTopZ(20) + ';display:flex;align-items:center;justify-content:center;'; const win = document.createElement('div'); win.style.cssText = 'width:min(680px,94vw);max-height:82vh;display:flex;flex-direction:column;background:var(--bg,#242632);color:var(--fg,#e8eaf0);border:1px solid #f59e0b;border-radius:12px;padding:14px 16px;font:13px/1.6 -apple-system,"Microsoft YaHei",sans-serif;box-shadow:0 20px 60px rgba(0,0,0,.55);'; win.innerHTML = '
' + ' 💾 求种草稿箱' + ' ' + ' ✕' + '
' + '
'; mask.appendChild(win); document.body.appendChild(mask); const body = win.querySelector('#ocd-draft-body'); const refresh = () => { const arr = shGetDrafts(); if (!arr.length) { body.innerHTML = '
' + '
📭
' + '草稿箱为空
' + '在「一键发布求种帖」界面点「💾 保存草稿」即可存到这里' + '
'; return; } body.innerHTML = arr.map(d => { const when = new Date(d.ts).toLocaleString('zh-CN', { hour12: false }); return '
' + '
' + esc(d.title || '(无标题)') + '
' + '
' + ' ' + esc(when) + '' + (d.year || d.artist ? ' | ' + esc([d.year, d.artist].filter(Boolean).join(' ')) + '' : '') + (d.bonus ? ' | 💰 ' + esc(String(d.bonus)) + '' : '') + ' | ' + (d.mode === 'track' ? '单曲' : '专辑') + '' + '
' + '
' + esc((d.body || '').slice(0, 200)) + ((d.body || '').length > 200 ? '…' : '') + '
' + '
' + ' ' + ' ' + '
' + '
'; }).join(''); body.querySelectorAll('.ocd-draft-del').forEach(btn => { btn.addEventListener('click', () => { if (!confirm('删除这条草稿?')) return; shDelDraft(btn.dataset.id); refresh(); if (onCountChange) onCountChange(); }); }); body.querySelectorAll('.ocd-draft-load').forEach(btn => { btn.addEventListener('click', () => { const d = shGetDraft(btn.dataset.id); if (!d) { toastSafe('草稿不存在', 'err'); return; } shOpenAskDialogFromDraft(d); }); }); }; win.querySelector('#ocd-draft-close').addEventListener('click', () => { _shDraftBoxRef = null; mask.remove(); }); mask.addEventListener('click', (e) => { if (e.target === mask) { _shDraftBoxRef = null; mask.remove(); } }); win.querySelector('#ocd-draft-clearall').addEventListener('click', () => { if (!shGetDrafts().length) { toastSafe('草稿箱已为空', 'err'); return; } if (!confirm('清空全部草稿?此操作不可撤销。')) return; shSaveDrafts([]); refresh(); if (onCountChange) onCountChange(); }); _shDraftBoxRef = refresh; refresh(); } function shConfirmPublish(snap) { const old = document.getElementById('ocd-pub-confirm'); if (old) old.remove(); const PUB_FEE = 100; const myMagic = shGetMyMagic(); const remainAfter = myMagic ? Math.max(0, myMagic - snap.bonus - PUB_FEE) : 0; const mask = document.createElement('div'); mask.id = 'ocd-pub-confirm'; mask.className = shGetThemeCls(); mask.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:' + ocdTopZ(20) + ';display:flex;align-items:center;justify-content:center;'; const win = document.createElement('div'); win.style.cssText = 'width:min(520px,94vw);max-height:90vh;display:flex;flex-direction:column;background:var(--bg,#242632);color:var(--fg,#e8eaf0);border:1px solid #4a69bd;border-radius:12px;padding:16px 18px;font:13px/1.7 -apple-system,"Microsoft YaHei",sans-serif;box-shadow:0 20px 60px rgba(0,0,0,.55);animation:ocd-pop .2s ease;'; win.innerHTML = '
' + ' ⚠️ 确认发布求种帖' + '
' + '
' + '
标题:' + esc(snap.title) + '
' + '
悬赏:' + snap.bonus + ' 魔力值
' + '
手续费:' + PUB_FEE + ' 魔力值
' + '
合计扣减:' + (snap.bonus + PUB_FEE) + ' 魔力值
' + (myMagic ? '
余额变化:' + esc(dpFmtNum(myMagic, 0)) + ' → ' + esc(dpFmtNum(remainAfter, 0)) + '
' : '') + '
' + '
正文预览:
' + '
' +
      esc(snap.body) + '
' + '
' + '
' + ' ' + ' ' + '
'; mask.appendChild(win); document.body.appendChild(mask); const $ = (id) => win.querySelector('#' + id); const close = () => mask.remove(); mask.addEventListener('click', (e) => { if (e.target === mask) close(); }); $('ocd-pub-cfm-cancel').addEventListener('click', () => { close(); shOpenAskDialog({ mode: snap.mode, artist: snap.artist || '', album: snap.album || '', year: snap.year || '', platformHit: null, _prefill: { title: snap.title, body: snap.body, bonus: snap.bonus }, }); }); $('ocd-pub-cfm-ok').addEventListener('click', async () => { const btn = $('ocd-pub-cfm-ok'); btn.disabled = true; btn.textContent = '⏳ 发布中…'; $('ocd-pub-cfm-err').innerHTML = ''; const r = await shSubmitRequest({ title: snap.title, body: snap.body, bonus: snap.bonus }); if (r.ok) { close(); shAddMyPost({ id: r.reqId || ('t_' + Date.now()), title: snap.title, bonus: snap.bonus, mode: snap.mode, artist: snap.artist || '', album: snap.album || '', year: snap.year || '', status: 'pending', ts: Date.now(), }); console.log('[Ask] 本地记录已写入 | reqId:', r.reqId || '(空)'); if (snap.draftId) { shDelDraft(snap.draftId); if (window.__shUpdateDraftCount) window.__shUpdateDraftCount(); if (typeof shRefreshDraftBox === 'function') shRefreshDraftBox(); console.log('[Ask] 草稿已发布,自动移除:', snap.draftId); } shAddHistory({ mode: snap.mode, title: snap.title, status: 'pending', reqId: r.reqId || '' }); shShowPublishSuccess(snap, r.reqId, remainAfter); } else { btn.disabled = false; btn.textContent = '✓ 确认发布'; $('ocd-pub-cfm-err').innerHTML = '
' + '❌ ' + esc(r.err) + '
'; } }); } function shShowPublishSuccess(snap, reqId, remainAfter) { const old = document.getElementById('ocd-pub-success'); if (old) old.remove(); const mask = document.createElement('div'); mask.id = 'ocd-pub-success'; mask.className = shGetThemeCls(); mask.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:' + ocdTopZ(20) + ';display:flex;align-items:center;justify-content:center;'; const win = document.createElement('div'); win.style.cssText = 'width:min(460px,92vw);background:var(--bg,#242632);color:var(--fg,#e8eaf0);border:1px solid #3fd68f;border-radius:12px;padding:18px 20px;font:13px/1.8 -apple-system,"Microsoft YaHei",sans-serif;box-shadow:0 20px 60px rgba(0,0,0,.55);animation:ocd-pop .25s ease;'; win.innerHTML = '
' + '
✅
' + '
求种帖已发布
' + (reqId ? '
帖子编号 #' + esc(reqId) + '
' : '') + '
' + '
' + '
标题:' + esc(snap.title) + '
' + '
悬赏:' + snap.bonus + ' 魔力值
' + '
手续费:100 魔力值
' + '
剩余魔力值:' + esc(dpFmtNum(remainAfter, 0)) + '
' + '
' + '
' + (reqId ? '查看帖子 ↗' : '') + ' ' + '
'; mask.appendChild(win); document.body.appendChild(mask); const close = () => mask.remove(); mask.addEventListener('click', (e) => { if (e.target === mask) close(); }); win.querySelector('#ocd-pub-succ-ok').addEventListener('click', close); toastSafe('✅ 求种帖已发布' + (reqId ? '(#' + reqId + ')' : ''), 'ok'); } function shOpenAskDialogFromDraft(d) { const dp = document.getElementById('ocd-draft-pop'); if (dp) dp.remove(); _shDraftBoxRef = null; shOpenAskDialog({ mode: d.mode || 'album', artist: d.artist || '', album: d.album || '', year: d.year || '', platformHit: null, _prefill: { title: d.title, body: d.body, bonus: d.bonus }, _draftId: d.id, }); } function shConfirmAppend(reqId, reqTitle, amt) { const old = document.getElementById('ocd-append-confirm'); if (old) old.remove(); const myMagic = shGetMyMagic(); const remainAfter = myMagic ? Math.max(0, myMagic - amt - 50) : 0; const mask = document.createElement('div'); mask.id = 'ocd-append-confirm'; mask.className = shGetThemeCls(); mask.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:' + ocdTopZ(20) + ';display:flex;align-items:center;justify-content:center;'; const win = document.createElement('div'); win.style.cssText = 'width:min(440px,92vw);background:var(--bg,#242632);color:var(--fg,#e8eaf0);border:1px solid #f59e0b;border-radius:12px;padding:16px 18px;font:13px/1.7 -apple-system,"Microsoft YaHei",sans-serif;box-shadow:0 20px 60px rgba(0,0,0,.55);animation:ocd-pop .2s ease;'; win.innerHTML = '
' + ' ⚠️ 确认追加悬赏' + '
' + '
' + '
帖子:' + esc(reqTitle || ('#' + reqId)) + '
' + '
追加金额:' + amt + ' 魔力值
' + '
手续费:50 魔力值
' + '
合计扣减:' + (amt + 50) + ' 魔力值
' + (myMagic ? '
余额变化:' + esc(dpFmtNum(myMagic, 0)) + ' → ' + esc(dpFmtNum(remainAfter, 0)) + '
' : '') + '
' + '
' + '
' + ' ' + ' ' + '
'; mask.appendChild(win); document.body.appendChild(mask); const $ = (id) => win.querySelector('#' + id); const close = () => mask.remove(); mask.addEventListener('click', (e) => { if (e.target === mask) close(); }); $('ocd-append-cfm-cancel').addEventListener('click', close); $('ocd-append-cfm-ok').addEventListener('click', async () => { const btn = $('ocd-append-cfm-ok'); btn.disabled = true; btn.textContent = '⏳ 提交中…'; $('ocd-append-cfm-err').innerHTML = ''; const r = await shAppendBonus(reqId, amt); if (r.ok) { close(); const arr = shGetMyPosts(); const rec = arr.find(x => x.id === reqId); if (rec) { const newBonus = (rec.bonus || 0) + amt; const history = Array.isArray(rec.history) ? rec.history.slice() : []; history.unshift({ ts: Date.now(), amount: amt }); shUpdateMyPost(reqId, { bonus: newBonus, history: history.slice(0, 20) }); shUpdateBonusInDOM(reqId, newBonus); const myreqBody = document.getElementById('ocd-myreq-body'); if (myreqBody) { const sel = '.ocd-myreq-append[data-id="' + reqId + '"]'; const btn = myreqBody.querySelector(sel); if (btn) btn.dataset.bonus = String(newBonus); } } else { document.querySelectorAll('[data-bonus-display="' + reqId + '"]').forEach(s => { const cur = parseInt((s.textContent || '').replace(/\D/g, ''), 10) || 0; s.innerHTML = '💰 ' + (cur + amt) + ''; }); document.querySelectorAll('.sh-append-btn[data-reqid="' + reqId + '"]').forEach(b => { const cur = parseInt(b.dataset.bonus, 10) || 0; b.dataset.bonus = String(cur + amt); }); } shShowAppendSuccess(reqId, reqTitle, amt, remainAfter); } else { btn.disabled = false; btn.textContent = '✓ 确认追加'; $('ocd-append-cfm-err').innerHTML = '
' + '❌ ' + esc(r.err) + '
'; } }); } function shUpdateBonusInDOM(reqId, newBonus) { document.querySelectorAll('.sh-append-btn[data-reqid="' + reqId + '"]').forEach(b => { b.dataset.bonus = String(newBonus); }); document.querySelectorAll('[data-bonus-display="' + reqId + '"]').forEach(s => { s.innerHTML = '💰 ' + newBonus + ''; }); document.querySelectorAll('.ocd-myreq-append[data-id="' + reqId + '"]').forEach(b => { b.dataset.bonus = String(newBonus); }); document.querySelectorAll('.ocd-myreq-bonus[data-id="' + reqId + '"]').forEach(s => { s.innerHTML = '💰 ' + newBonus + ''; }); console.log('[Bonus] DOM 悬赏值已更新:', reqId, '→', newBonus); } function shShowAppendSuccess(reqId, reqTitle, amt, remainAfter) { const old = document.getElementById('ocd-append-success'); if (old) old.remove(); const mask = document.createElement('div'); mask.id = 'ocd-append-success'; mask.className = shGetThemeCls(); mask.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:' + ocdTopZ(20) + ';display:flex;align-items:center;justify-content:center;'; const win = document.createElement('div'); win.style.cssText = 'width:min(420px,92vw);background:var(--bg,#242632);color:var(--fg,#e8eaf0);border:1px solid #3fd68f;border-radius:12px;padding:18px 20px;font:13px/1.8 -apple-system,"Microsoft YaHei",sans-serif;box-shadow:0 20px 60px rgba(0,0,0,.55);animation:ocd-pop .25s ease;'; win.innerHTML = '
' + '
✅
' + '
追加成功
' + '
' + '
' + '
帖子:' + esc(reqTitle || ('#' + reqId)) + '
' + '
已追加:' + amt + ' 魔力值
' + '
手续费:50 魔力值
' + '
剩余魔力值:' + esc(dpFmtNum(remainAfter, 0)) + '
' + '
' + '
' + (reqId ? '查看帖子 ↗' : '') + ' ' + '
'; mask.appendChild(win); document.body.appendChild(mask); const close = () => mask.remove(); mask.addEventListener('click', (e) => { if (e.target === mask) close(); }); win.querySelector('#ocd-append-succ-ok').addEventListener('click', close); toastSafe('✅ 已成功追加 ' + amt + ' 魔力值', 'ok'); } /* V9.76.17(用户 m13487 ⑤):搜索求助助手**取消会话缓存** —— _shSession / shSaveSession 与「上次搜索结果已恢复(30 分钟内有效)」整块已删除 ✓ 关闭面板即清空 ✓ */ function openSearchHelpUI(container) { if (document.getElementById('ocd-sh-panel')) return; const panel = document.createElement('div'); panel.id = 'ocd-sh-panel'; panel.style.cssText = [ 'position:fixed', 'top:60px', 'right:16px', 'z-index:9999990', 'width:520px', 'max-width:94vw', 'max-height:88vh', 'display:flex', 'flex-direction:column', 'background:var(--bg,rgba(24,26,32,.97))', 'color:var(--fg,#e8eaf0)', 'border:1px solid #4a69bd', 'border-radius:12px', 'box-shadow:0 12px 40px rgba(0,0,0,.5), 0 0 0 1px rgba(74,105,189,.25)', 'font:13px/1.6 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif', 'overflow:hidden' ].join(';'); const cfg = shGetCfg(); panel.innerHTML = '
' + ' 🔍 搜索求助助手' + ' ' + '
' + '
' + '
' + ' ' + ' ' + '
' + '
' + ' 优先平台' + ' ' + '
' + '
' + '
' + ' 年份' + ' ' + ' 艺人' + ' ' + '
' + '
' + ' 曲目' + ' ' + '
' + '
' + ' 专辑' + ' ' + '
' + /* V9.76.17(用户 m13487 ⑤):搜索按钮改为一行 —— 左侧「开始搜索」,右侧小「🧹 清空」✓ */ '
' + ' ' + ' ' + '
' + ' ' + '
' + ' ' + ' ' + '
' + '
' + '
' + '
' + '
' + ' ' + ' ' + '
'; container.appendChild(panel); const main = document.getElementById('ocd-helper-panel'); const themeCls = (main && Array.from(main.classList).find(c => c.indexOf('ocd-theme-') === 0)) || ('ocd-theme-' + store.get(THEME_KEY, 'dark')); panel.classList.add(themeCls); makeEdgeResize(panel, { min: 420, max: 720, def: 520, key: 'ocd_helper_sh_w_v1', getPanel: () => panel }); if (!document.getElementById('ocd-sh-css')) { const css = document.createElement('style'); css.id = 'ocd-sh-css'; css.textContent = '.sh-btn{padding:2px 8px;cursor:pointer;font-size:12px;border:1px solid var(--border,#444);border-radius:6px;background:transparent;color:var(--fg-muted,#9aa3b2);}' + '.sh-btn:hover{border-color:#4a69bd;color:#4a69bd;}' + '.sh-step.done{border-left-color:#3fd68f !important;color:#3fd68f !important;}' + '.sh-step.running{border-left-color:#4a69bd !important;color:#4a69bd !important;animation:sh-blink 1.2s ease-in-out infinite;}' + '.sh-step.fail{border-left-color:#ff6b6b !important;color:#ff6b6b !important;}' + '.sh-step.warn{border-left-color:#f59e0b !important;color:#f59e0b !important;}' + '@keyframes sh-blink{0%,100%{opacity:1}50%{opacity:.5}}'; document.head.appendChild(css); } applyOpenAnim(panel); makeDraggable(panel.querySelector('#sh_head'), 'ocd_helper_sh_pos_v1', panel); makePinBtn(panel.querySelector('#sh_head'), panel, '#sh_close'); { const headEl = panel.querySelector('#sh_head'); headEl.title = '拖动移动 · 双击收起/展开'; let collapsed = false; headEl.addEventListener('dblclick', (e) => { if (e.target.closest('button')) return; const b = $('sh_body'); const hw = $('sh_hist_wrap'); collapsed = !collapsed; b.style.display = collapsed ? 'none' : ''; hw.style.display = collapsed ? 'none' : ''; }); } const $ = id => panel.querySelector('#' + id); const updatePlatHint = () => { const v = $('sh_plat').value; const hint = $('sh_plat_hint'); if (v === 'auto') { hint.textContent = '中文内容只走国内平台,外语优先海外平台'; } else { const p = shPlatformsAll().find(x => x.api === v); hint.innerHTML = '⭐ 本次优先 ' + esc(p ? p.name : v) + ',失败后按语言规则换源' + '(不受批量检测启用开关限制)'; } }; updatePlatHint(); $('sh_plat').addEventListener('change', () => { shSaveCfg({ priorityPlatform: $('sh_plat').value }); updatePlatHint(); }); panel.querySelectorAll('.sh-tab').forEach(btn => { btn.addEventListener('click', () => { const mode = btn.dataset.mode; shSaveCfg({ mode }); panel.querySelectorAll('.sh-tab').forEach(b => { const on = b.dataset.mode === mode; b.style.borderColor = on ? '#4a69bd' : 'var(--border,#444)'; b.style.background = on ? 'rgba(74,105,189,.12)' : 'transparent'; b.style.color = on ? '#4a69bd' : 'var(--fg,#e8eaf0)'; }); panel.querySelectorAll('.sh-row-track').forEach(el => el.style.display = mode === 'album' ? 'none' : 'flex'); panel.querySelectorAll('.sh-row-album').forEach(el => el.style.display = mode === 'track' ? 'none' : 'flex'); }); }); const shSetStep = (n, cls, customText, expandable) => { const el = panel.querySelector('.sh-step[data-step="' + n + '"]'); if (!el) return; el.className = 'sh-step' + (cls ? ' ' + cls : ''); el.style.color = cls === 'warn' ? '#f59e0b' : ''; el.style.borderLeftColor = cls === 'warn' ? '#f59e0b' : cls === 'done' ? '#3fd68f' : cls === 'running' ? '#4a69bd' : cls === 'fail' ? '#ff6b6b' : ''; const base = customText != null ? String(customText).replace(/^[○✅❌⏳🟡▸▾]\s*/, '').replace(/\s*[▸▾]\s*$/, '') : el.textContent.replace(/^[○✅❌⏳🟡▸▾]\s*/, '').replace(/\s*[▸▾]\s*$/, ''); const icon = cls === 'done' ? '✅ ' : cls === 'running' ? '⏳ ' : cls === 'fail' ? '❌ ' : cls === 'warn' ? '🟡 ' : '○ '; if (expandable) { el.dataset.expandable = '1'; el.style.cursor = 'pointer'; const target = panel.querySelector('#sh-step-result-' + n); const collapsed = !target || target.style.display === 'none'; el.textContent = icon + base + (collapsed ? ' ▸' : ' ▾'); } else { delete el.dataset.expandable; el.style.cursor = ''; el.textContent = icon + base; } if (!el.__expandBound) { el.__expandBound = true; el.addEventListener('click', () => { if (el.dataset.expandable !== '1') return; const target = panel.querySelector('#sh-step-result-' + n); if (!target) return; const opening = target.style.display === 'none'; target.style.display = opening ? '' : 'none'; const cur = el.textContent; el.textContent = cur.replace(/[▸▾]\s*$/, opening ? '▾' : '▸'); }); } }; /* V9.76.17(用户 m13487 ⑤):清空当前搜索结果 —— 结果区 + 4 个步骤结果块 + 步骤文字 与进度区一起清掉(输入框内容保留 ✓ 便于改一下关键词重搜 ✓) V9.76.18 修正(真机 bug m14220–m14222):#sh_result 正是 4 个 #sh-step-result-* 的 **父节点**(见面板 HTML)✗ 旧写法 resultEl.innerHTML = "" 会把 4 个结果块一起删掉 → 紧接着 shRenderDupResult(null, …) 抛 TypeError → ① 行点不开 / 没有「继续查求助区」/ shSearching 卡在 true(清空被拒)✓ 现在只清 4 个块自身的内容 + 移除块以外的子节点 (按钮行 / 平台解析结果)✓ 四个块永远留在 DOM 里 ✓ */ const clearSearchResults = (silent) => { const resultEl = $('sh_result'); if (resultEl) { Array.from(resultEl.children).forEach((c) => { if (/^sh-step-result-\d+$/.test(c.id)) { c.innerHTML = ''; c.style.display = 'none'; } else c.remove(); }); } for (let i = 1; i <= 4; i++) shSetStep(i, ''); const st = $('sh_steps'); if (st) st.style.display = 'none'; if (!silent) toastSafe('🧹 已清空搜索结果', 'ok'); }; const hasSearchOutput = () => { const resultEl = $('sh_result'); if (resultEl && String(resultEl.textContent || '').trim()) return true; if (panel.querySelector('.sh-step.running')) return true; for (let i = 1; i <= 4; i++) { const rb = panel.querySelector('#sh-step-result-' + i); if (rb && String(rb.textContent || '').trim()) return true; } return false; }; let shSearching = false; $('sh_search').addEventListener('click', async () => { if (shSearching) { toastSafe('正在搜索中…', 'err'); return; } const artist = $('sh_artist').value.trim(); const track = $('sh_track').value.trim(); const album = $('sh_album').value.trim(); const year = $('sh_year').value.trim(); const mode = shGetCfg().mode; if (mode === 'track' && !track) { toastSafe('请填写曲目名', 'err'); $('sh_track').focus(); return; } if (mode === 'album' && !album) { toastSafe('请填写专辑名', 'err'); $('sh_album').focus(); return; } shSearching = true; /* V9.76.18(真机 bug m14220–m14222):整段搜索流程包 try/finally —— 任何环节抛异常都只提示真实错误 + 自动放行 ✓ 不再把面板锁死在「正在搜索中」✗ */ try { const resultEl = $('sh_result'); /* V9.76.17(用户 m13487 ⑤):开始搜索先清空上一次结果 —— 否则换关键词再搜会把两次平台解析叠在一起 ✗ */ clearSearchResults(true); $('sh_steps').style.display = 'block'; for (let i = 1; i <= 4; i++) shSetStep(i, ''); const rBlock1 = panel.querySelector('#sh-step-result-1'); if (rBlock1) { rBlock1.innerHTML = '
⏳ 正在查重站内种子…
'; rBlock1.style.display = ''; } shSetStep(1, 'running', '① 站内查重 · 查重中…'); const dup = await shDupSearch(artist, track, album, year, mode); if (rBlock1) { rBlock1.innerHTML = ''; rBlock1.style.display = 'none'; } if (dup.state === 'dup' && dup.hits && dup.hits.length) { shSetStep(1, 'warn', '① 站内查重 · 命中 ' + dup.hits.length + ' 条', true); if (rBlock1) shRenderDupResult(rBlock1, dup, mode); } else if (dup.state === 'clean') { shSetStep(1, 'done', '① 站内查重 · 未收录'); } else { shSetStep(1, 'fail', '① 站内查重 · 失败'); if (rBlock1) { shRenderDupResult(rBlock1, dup, mode); rBlock1.style.display = ''; } } if (dup.state === 'dup') { const btnRow = document.createElement('div'); btnRow.style.cssText = 'margin-top:10px;display:flex;gap:8px;justify-content:center;'; const contBtn = document.createElement('button'); contBtn.textContent = '▶ 继续查求助区'; contBtn.style.cssText = 'padding:6px 16px;cursor:pointer;border:1px solid #4a69bd;border-radius:7px;background:rgba(74,105,189,.12);color:#4a69bd;font-size:12px;font-weight:600;'; const stopBtn = document.createElement('button'); stopBtn.textContent = '⏹ 停止搜索'; stopBtn.style.cssText = 'padding:6px 16px;cursor:pointer;border:1px solid var(--border,#444);border-radius:7px;background:transparent;color:var(--fg-muted,#9aa3b2);font-size:12px;'; btnRow.appendChild(contBtn); btnRow.appendChild(stopBtn); resultEl.appendChild(btnRow); stopBtn.addEventListener('click', () => { shSearching = false; btnRow.remove(); toastSafe('已停止搜索', 'ok'); }); contBtn.addEventListener('click', async () => { btnRow.remove(); /* V9.76.18:手动继续这一路也要兜住异常 ✓ 失败不再卡住面板 ✓ */ try { await shRunRequestSearch(); } catch (e2) { toastSafe('❌ 搜索出错:' + ((e2 && e2.message) ? e2.message : e2), 'err'); } finally { shSearching = false; } }); shSearching = false; return; } await shRunRequestSearch(); async function shRunRequestSearch() { const rBlock2 = panel.querySelector('#sh-step-result-2'); /* V9.76.18:结果块万一不在(外力清空等极端情况)就整体放弃这一环 ✓ 绝不能带着 null 往下走 —— 一次 TypeError 就会把「正在搜索中」锁死 ✗ */ if (!rBlock2) { shSetStep(2, 'fail', '② 求助区查重 · 结果块缺失'); shSearching = false; return; } rBlock2.innerHTML = '
⏳ 正在查重求助区…
'; rBlock2.style.display = ''; shSetStep(2, 'running', '② 求助区查重 · 查重中…'); const req = await shRequestSearch(artist, track, album, year, mode); rBlock2.innerHTML = ''; rBlock2.style.display = 'none'; if (req.state === 'found' && req.posts.length) { shSetStep(2, 'warn', '② 求助区查重 · 命中 ' + req.posts.length + ' 条', true); shRenderRequestResult(rBlock2, req); } else if (req.state === 'clean') { shSetStep(2, 'done', '② 求助区查重 · 未收录'); } else { shSetStep(2, 'fail', '② 求助区查重 · 失败'); shRenderRequestResult(rBlock2, req); rBlock2.style.display = ''; } if (req.state === 'found') { const btnRow = document.createElement('div'); btnRow.style.cssText = 'margin-top:10px;display:flex;gap:8px;justify-content:center;'; const contBtn = document.createElement('button'); contBtn.textContent = '▶ 继续解析平台'; contBtn.style.cssText = 'padding:6px 18px;cursor:pointer;border:1px solid #4a69bd;border-radius:7px;background:rgba(74,105,189,.12);color:#4a69bd;font-size:12px;font-weight:600;'; const stopBtn = document.createElement('button'); stopBtn.textContent = '⏹ 停止搜索'; stopBtn.style.cssText = 'padding:6px 18px;cursor:pointer;border:1px solid var(--border,#444);border-radius:7px;background:transparent;color:var(--fg-muted,#9aa3b2);font-size:12px;'; btnRow.appendChild(contBtn); btnRow.appendChild(stopBtn); const resultHost = panel.querySelector('#sh_result') || rBlock2.parentElement; resultHost.appendChild(btnRow); const userAction = await new Promise((resolve) => { contBtn.addEventListener('click', () => { btnRow.remove(); resolve('continue'); }); stopBtn.addEventListener('click', () => { btnRow.remove(); toastSafe('已停止搜索', 'ok'); resolve('stop'); }); }); if (userAction === 'stop') { shSearching = false; return; } } shSetStep(3, 'running', '② 平台解析 · 解析中…'); const platWrap = document.createElement('div'); platWrap.style.marginTop = '12px'; platWrap.innerHTML = '
⏳ 正在解析各音乐平台…
'; resultEl.appendChild(platWrap); const searchAlbumName = mode === 'track' ? track : album; const parsed = { artist, album: searchAlbumName, year: year || '', subtitle: '' }; const preSelected = shGetCfg().priorityPlatform || 'auto'; platWrap.innerHTML = ''; const platResultBox = document.createElement('div'); platWrap.appendChild(platResultBox); const platResult = await shPlatformSearch(parsed, preSelected, null); const _hasResult = platResult.results.length > 0; shSetStep(3, _hasResult ? 'done' : 'fail', _hasResult ? '② 平台解析 · 解析成功' : '② 平台解析 · 未命中'); if (platResult.queue && platResult.queue.length <= 1 && platResult.results.length > 0) { const _qpEl = panel.querySelector('#sh-plat-progress, .sh-plat-progress, [data-sh-progress]'); if (_qpEl) _qpEl.style.display = 'none'; } platResultBox.innerHTML = ''; shRenderPlatformResult(platResultBox, platResult, parsed, mode); shSetStep(4, _hasResult ? 'done' : '', _hasResult ? '③ 一键求助就绪中!' : '③ 一键求助 · 待平台命中'); shSearching = false; /* V9.76.17(用户 m13487 ⑤):不再保存会话(取消缓存)✓ */ toastSafe('✅ 搜索完成', 'ok'); } } catch (e) { const _em = (e && e.message) ? e.message : String(e); const _s1 = panel.querySelector('.sh-step'); if (_s1 && _s1.classList.contains('running')) shSetStep(1, 'fail', '① 站内查重 · 失败(' + _em + ')'); toastSafe('❌ 搜索出错:' + _em, 'err'); } finally { /* V9.76.18(真机 bug m14220–m14222):无论成功 / 失败 / 抛异常,都不能让 shSearching 卡在 true —— 否则「🧹 清空」被拒 + 面板再也搜不动 ✗ */ shSearching = false; } }); const myReqBtn = $('sh_myreq_btn'); const draftBtn = $('sh_draft_btn'); const draftCntEl = $('sh_draft_count'); myReqBtn.addEventListener('click', () => shShowMyRequests()); const shUpdateDraftCount = () => { const n = shGetDrafts().length; if (draftCntEl) { if (n > 0) { draftCntEl.textContent = String(n); draftCntEl.style.display = ''; } else { draftCntEl.style.display = 'none'; } } }; shUpdateDraftCount(); window.__shUpdateDraftCount = shUpdateDraftCount; draftBtn.addEventListener('click', () => shShowDraftBox(shUpdateDraftCount)); /* V9.76.17(用户 m13487 ⑤):清空按钮 —— 清掉搜索结果与进度,保留输入框内容 ✓ */ const clearBtn = $('sh_clear'); if (clearBtn) clearBtn.addEventListener('click', () => { if (shSearching) { toastSafe('正在搜索中…请稍候再清空', 'err'); return; } clearSearchResults(false); }); $('sh_close').addEventListener('click', () => { /* V9.76.17(用户 m13487 ⑤):只要存在搜索结果或正在搜索,关闭前都要确认一次 —— 确认后关闭即清空(不再缓存、不再自动恢复 ✗)✓ */ if (hasSearchOutput()) { const asking = panel.querySelector('.sh-step.running') ? '⚠️ 搜索正在进行中\n\n· 关闭会丢失当前已得结果\n· 建议双击标题栏收起面板(不丢结果)\n\n确定要关闭吗?' : '⚠️ 当前搜索存在任务\n\n· 关闭会清空搜索结果与进度(不再缓存、下次打开也不会自动恢复 ✗)\n· 建议双击标题栏收起面板(结果留着)\n\n确定要清空退出吗?'; if (!confirm(asking)) return; } closeSearchHelpUI(); }); focusPanel(panel); if (!panel.__themeWatch) { panel.__themeWatch = setInterval(() => { if (!panel.isConnected) { clearInterval(panel.__themeWatch); return; } const curTheme = store.get(THEME_KEY, 'dark'); const targetCls = 'ocd-theme-' + curTheme; if (!panel.classList.contains(targetCls)) { panel.classList.remove(...Array.from(panel.classList).filter(c => c.indexOf('ocd-theme-') === 0)); panel.classList.add(targetCls); } }, 500); } } function closeSearchHelpUI() { const el = document.getElementById('ocd-sh-panel'); if (el) { /* V9.76.17(用户 m13487 ⑤):关闭即清空 —— 不再保存会话、不再恢复 ✓ */ el.remove(); } if (_openModuleId === 'searchhelp') _openModuleId = null; } /* ================================================================ * §41 求种帖预览 * ================================================================ */ const SH_PREVIEW_TTL = advTtlMs('preview', 5); const _shPreviewCache = {}; const SH_REQ_ST = { pending: { cn: '求种中', clr: '#9aa3b2' }, responded: { cn: '有应求', clr: '#f59e0b' }, completed: { cn: '求种成功', clr: '#3fd68f' }, unknown: { cn: '未知', clr: '#9aa3b2' }, }; function shReqStatusOf(text) { let t = String(text || ''); if (!t) return 'unknown'; if (/求种成功|应求成功/.test(t)) return 'completed'; t = t.replace(/还没有应求|暂无应求|没有应求|无应求/g, ' '); if (/有应求/.test(t)) return 'responded'; if (/求种中|等待应求/.test(t)) return 'pending'; return 'unknown'; } function shReqViewUrl(reqid) { return 'https://open.cd/viewrequests.php?action=view&id=' + reqid; } function shReqAbsUrl(href) { const h = String(href || '').trim(); if (!h || /^javascript:/i.test(h)) return ''; if (h.indexOf('http') === 0) return h; return 'https://open.cd/' + h.replace(/^\/+/, '').replace(/^\.\//, ''); } async function shFetchReqView(reqid, force) { const c = _shPreviewCache[reqid]; if (!force && c && Date.now() - c.ts < SH_PREVIEW_TTL) return c; const html = await gmFetchText(shReqViewUrl(reqid), { Referer: 'https://open.cd/viewrequests.php' }); const rec = { ts: Date.now(), html: html || '' }; _shPreviewCache[reqid] = rec; return rec; } function shParserDocOf(html) { try { return new DOMParser().parseFromString(String(html || ''), 'text/html'); } catch (e) { return null; } } function shFindCommentRoot(doc) { if (!doc) return null; for (const id of ['commentarea', 'comments', 'comment', 'commentlist', 'comment-list', 'comment_list', 'divcomments']) { const el = doc.getElementById(id); if (el && (el.textContent || '').trim()) return el; } for (const tr of doc.querySelectorAll('tr')) { const head = tr.querySelector('td.rowhead'); if (!head) continue; const lab = (head.textContent || '').replace(/\s+/g, '').replace(/[::]$/, ''); if (/评论|跟帖|回复/.test(lab) && !/没有|暂无|禁止|已关闭/.test(lab)) { const body = tr.querySelector('td.rowfollow'); if (body) return body; } } let best = null, bestN = 0; for (const el of doc.querySelectorAll('[id],[class]')) { const key = String(el.id || '') + ' ' + (typeof el.className === 'string' ? el.className : ''); if (!/comments|comment-?list|commentlist|comment_?area/i.test(key)) continue; const n = el.querySelectorAll('.comment, .comment-item, li').length; if (n > bestN) { bestN = n; best = el; } } if (best) return best; const first = doc.querySelector('.comment, .comment-item'); if (first) { const p = first.parentElement; if (p && p.querySelectorAll('.comment, .comment-item').length > 1) return p; return first; } for (const el of doc.querySelectorAll('[id],[class]')) { const key = String(el.id || '') + ' ' + (typeof el.className === 'string' ? el.className : ''); if (/comment/i.test(key) && (el.textContent || '').trim()) return el; } return null; } function shParseCommentItems(root) { const items = []; if (!root) return items; let blocks = Array.from(root.querySelectorAll('li, .comment, .comment-item, .post, table tr')); if (!blocks.length) blocks = Array.from(root.children); for (const b of blocks) { if (b.querySelector && b.querySelector('td.rowhead, td.rowtitle, table.resource')) continue; const flat = (b.textContent || '').replace(/\s+/g, ' ').trim(); if (flat.length < 2) continue; const a = b.querySelector('a[href*="userdetails.php"]'); const author = a ? (a.textContent || '').trim() : ''; const tm = flat.match(/\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(?::\d{2})?/) || flat.match(/\d+\s*(?:秒|分钟|小时|天|个月)前/); const time = tm ? tm[0] : ''; let body = shHtmlToTextKeepBr(b); if (author) body = body.split(author).join(' '); if (time) body = body.split(time).join(' '); body = body.replace(/\s*\n\s*/g, '\n').replace(/\n{3,}/g, '\n\n').trim(); if (!body || body.length < 1) continue; items.push({ author: author, time: time, body: body }); } const out = []; for (const it of items) { let dup = false; for (const o of items) { if (o === it) continue; if (o.body.length > it.body.length && o.body.indexOf(it.body) !== -1 && o.author === it.author) { dup = true; break; } } if (dup) continue; if (!out.some(x => x.body === it.body && x.author === it.author)) out.push(it); } return out.slice(0, 100); } function shBlockToComment(b) { const flat = (b.textContent || '').replace(/\s+/g, ' ').trim(); if (flat.length < 2) return null; const a = b.querySelector('a[href*="userdetails.php"]'); const author = a ? (a.textContent || '').trim() : ''; const tm = flat.match(/\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(?::\d{2})?/) || flat.match(/\d+\s*(?:秒|分钟|小时|天|个月)前/); const time = tm ? tm[0] : ''; let body = shHtmlToTextKeepBr(b); if (author) body = body.split(author).join(' '); if (time) body = body.split(time).join(' '); body = body.replace(/\s*\n\s*/g, '\n').replace(/\n{3,}/g, '\n\n').trim(); if (!body) return null; return { author: author, time: time, body: body }; } function shScanCommentBlocks(doc) { if (!doc) return []; const cands = []; for (const el of doc.querySelectorAll('tr, li, div, p, table')) { if (!el.querySelector('a[href*="userdetails.php"]')) continue; if (el.closest('form')) continue; if (el.querySelector('td.rowhead, td.rowtitle, table.resource')) continue; const flat = (el.textContent || '').replace(/\s+/g, ' ').trim(); if (flat.length < 12) continue; if (!/\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}|\d+\s*(?:秒|分钟|小时|天|个月)前/.test(flat)) continue; cands.push(el); } return cands.filter(el => !cands.some(o => o !== el && el.contains(o))); } function shCountCommentKeys(doc) { let n = 0; if (!doc) return 0; for (const el of doc.querySelectorAll('[id],[class]')) { const k = String(el.id || '') + ' ' + (typeof el.className === 'string' ? el.className : ''); if (/comment|reply|跟帖|评论/i.test(k)) n++; } return n; } function shFindCommentEndpoints(html) { const set = []; const re = /[A-Za-z0-9_\-./?=&]*(?:comment|reply|takerepl)[A-Za-z0-9_\-./?=&]*\.php[A-Za-z0-9_\-./?=&]*/gi; let m; while ((m = re.exec(String(html || ''))) !== null) { if (set.indexOf(m[0]) === -1) set.push(m[0]); if (set.length >= 8) break; } return set; } function shBodyFromCommentTable(tbl) { if (!tbl) return ''; for (const cell of tbl.querySelectorAll('td.rowfollow')) { if (cell.getAttribute('width') === '150') continue; if (cell.querySelector('img[alt="avatar"], img[width="150px"], img[width="150"]')) continue; const t = shHtmlToTextKeepBr(cell).trim(); if (t) return t; } return ''; } function shFirstBodyAfter(head) { const doc = head.ownerDocument; if (!doc) return ''; for (const cell of doc.querySelectorAll('td.rowfollow')) { if (!(head.compareDocumentPosition(cell) & 4)) continue; if (cell.getAttribute('width') === '150') continue; if (cell.querySelector('img[alt="avatar"], img[width="150px"], img[width="150"]')) continue; const t = shHtmlToTextKeepBr(cell).trim(); if (t) return t; } return ''; } function shParseCommentsNexus(doc) { const out = []; if (!doc) return out; const seen = {}; for (const head of doc.querySelectorAll('table[id^="cid"]')) { const cidM = String(head.getAttribute('id') || '').match(/^cid(\d+)$/i); const cid = cidM ? cidM[1] : ''; if (cid && seen[cid]) continue; const hrow = head.querySelector('tr') || head; const a = hrow.querySelector('a[href*="userdetails.php"]'); const author = a ? ((a.querySelector('b') || a).textContent || '').replace(/\s+/g, ' ').trim() : ''; const flat = (hrow.textContent || '').replace(/\s+/g, ' ').trim(); const tm = flat.match(/\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}(?::\d{2})?/); const clsB = hrow.querySelector('b[class$="_Name"]'); const userClass = clsB ? (clsB.textContent || '').replace(/\s+/g, ' ').trim() : ''; let bodyTable = null; let node = head.closest('div') || head; for (let hops = 0; hops < 4 && node; hops++) { node = node.nextElementSibling; if (!node) break; if (node.tagName === 'TABLE') { bodyTable = node; break; } const inner = node.querySelector ? node.querySelector('table') : null; if (inner) { bodyTable = inner; break; } } let body = ''; if (bodyTable) body = shBodyFromCommentTable(bodyTable); if (!body) body = shFirstBodyAfter(head); if (!author && !body) continue; if (cid) seen[cid] = 1; out.push({ id: cid, author: author, userClass: userClass, time: tm ? tm[0] : '', body: body }); } return out; } function shFindCommentForm(doc) { if (!doc) return null; const NAME_RE = /comment|body|content|text|descr|message|reply|跟帖|评论/i; let best = null, bestScore = 0, bestField = ''; for (const form of doc.querySelectorAll('form')) { let sc = 0; const act = String(form.getAttribute('action') || ''); if (/comment|reply|takerepl|takerequest/i.test(act)) sc += 4; if (form.querySelector('[name="amount"]')) sc -= 5; let ta = null; for (const t of form.querySelectorAll('textarea')) { if (!ta) ta = t; if (t.name && NAME_RE.test(t.name)) { ta = t; break; } } if (ta) { sc += 2; if (ta.name && NAME_RE.test(ta.name)) sc += 2; if (String(ta.getAttribute('placeholder') || '').match(/评论|回复|跟帖|说点/)) sc += 1; } if (form.querySelector('input[type="submit"], button[type="submit"]')) sc += 1; if (sc > bestScore) { bestScore = sc; best = form; bestField = ta ? String(ta.name || '') : ''; } } if (!best || bestScore < 3 || !bestField) return null; let action = String(best.getAttribute('action') || ''); if (!action) action = 'viewrequests.php'; let url = action; if (url.indexOf('http') !== 0) url = 'https://open.cd/' + url.replace(/^\/+/, ''); return { form: best, field: bestField, url: url, score: bestScore }; } function shFindActionLink(doc, re) { if (!doc) return null; const all = doc.querySelectorAll('a[href], button, input[type="submit"], input[type="button"]'); const hits = []; for (const a of all) { const href = String(a.getAttribute('href') || ''); const txt = (a.textContent || a.value || '').replace(/\s+/g, ' ').trim(); const title = String(a.getAttribute('title') || ''); if (!re.test(txt) && !re.test(title) && !re.test(href)) continue; hits.push({ href: href, text: txt || title, el: a, short: (txt || title).length <= 8 }); } for (const hit of hits) { if (!hit.short) continue; if (hit.href && !/^javascript:/i.test(hit.href) && hit.href !== '#') return { href: hit.href, text: hit.text }; if (hit.el && hit.el.tagName !== 'A') return { href: '', text: hit.text, el: hit.el }; } for (const hit of hits) { if (hit.href && !/^javascript:/i.test(hit.href) && hit.href !== '#') return { href: hit.href, text: hit.text }; } return null; } function shFindRowAction(tr, re) { if (!tr) return null; for (const a of tr.querySelectorAll('a, button, input[type="button"]')) { const href = String(a.getAttribute('href') || ''); const txt = (a.textContent || a.value || '').replace(/\s+/g, ' ').trim(); const title = String(a.getAttribute('title') || ''); if (re.test(txt) || re.test(title) || re.test(href)) return a; } return null; } function shParseReqView(html, reqid) { const doc = shParserDocOf(html); const out = { statusKey: 'unknown', statusTxt: '', bonus: 0, intro: '', responds: [], comments: [], commentForm: null, report: null, edit: null, }; if (!doc) return out; const pairs = []; for (const tr of doc.querySelectorAll('tr')) { const h = tr.querySelector('td.rowhead'); const b = tr.querySelector('td.rowfollow'); if (h && b) pairs.push({ lab: (h.textContent || '').replace(/\s+/g, '').replace(/[::]$/, ''), body: b }); } let respondsSeen = false; for (const p of pairs) { if (!out.statusTxt && /^状态$/.test(p.lab)) out.statusTxt = (p.body.textContent || '').replace(/\s+/g, ' ').trim(); if (!out.bonus && /^(悬赏|金额|魔力值|悬赏金额)$/.test(p.lab)) { const n = parseInt(((p.body.textContent || '').match(/\d+/) || [0])[0], 10); if (n) out.bonus = n; } if (!respondsSeen && /^应求/.test(p.lab)) { respondsSeen = true; const seg = p.body.textContent || ''; if (!/还没有应求|无应求/.test(seg)) { for (const a of p.body.querySelectorAll('a[href*="details.php?id="]')) { const m = String(a.getAttribute('href') || '').match(/details\.php\?id=\s*(\d+)/); if (!m) continue; out.responds.push({ id: m[1], title: (a.textContent || '').replace(/\s+/g, ' ').trim() }); } } } if (!out.intro && /^(内容|正文|描述|说明|简介|详情|介绍|求种说明)$/.test(p.lab)) { out.intro = shHtmlToTextKeepBr(p.body); } } if (!out.intro) { let bestTxt = ''; for (const p of pairs) { if (/应求|状态|标题|作者|时间|悬赏|金额|回复|评论|跟帖|操作|管理/.test(p.lab)) continue; const t = shHtmlToTextKeepBr(p.body); if (t.length > bestTxt.length) bestTxt = t; } if (bestTxt.trim().length >= 30) out.intro = bestTxt.trim(); } out.respondsSeen = respondsSeen; out.statusKey = shReqStatusOf(out.statusTxt); if (out.respondsSeen && !out.responds.length && out.statusKey === 'responded') { out.statusKey = 'pending'; console.log('[ReqPreview] #' + reqid + ' 状态改判:详情页应求行为空 → 求种中'); } let root = shFindCommentRoot(doc); out.comments = root ? shParseCommentItems(root) : []; let nexus = shParseCommentsNexus(doc); if (nexus.length && nexus.length >= out.comments.length) { out.comments = nexus; out.commentStyle = 'nexus'; } let scanBlocks = []; if (!out.comments.length) { scanBlocks = shScanCommentBlocks(doc); if (scanBlocks.length) { out.comments = scanBlocks.map(shBlockToComment).filter(Boolean); if (out.comments.length) console.log('[ReqPreview] 评论走扫描回退,命中 ' + out.comments.length + ' 条'); } } out.commentForm = shFindCommentForm(doc); out.report = shFindActionLink(doc, /举报|投诉|report/i); out.edit = shFindActionLink(doc, /编辑|edit/i); out.diag = { hasRoot: !!root, nexus: nexus.length, style: out.commentStyle || 'generic', blocks: scanBlocks.length, keys: shCountCommentKeys(doc), endpoints: shFindCommentEndpoints(html), }; if (reqid && typeof _shPostRespondsCache === 'object' && _shPostRespondsCache) { try { _shPostRespondsCache[String(reqid)] = { ts: Date.now(), data: { hasResponds: out.responds.length > 0, list: out.responds }, }; } catch (e) {} } return out; } function shSerializeForm(form, patch) { const fd = new URLSearchParams(); if (!form) return fd; for (const el of form.querySelectorAll('input, textarea, select')) { const name = el.getAttribute('name'); if (!name) continue; const type = String(el.getAttribute('type') || el.tagName).toLowerCase(); if (type === 'submit' || type === 'button' || type === 'reset' || type === 'file' || type === 'image') continue; if ((type === 'checkbox' || type === 'radio') && !el.checked) continue; let v = el.value != null ? String(el.value) : ''; if (patch && Object.prototype.hasOwnProperty.call(patch, name)) v = patch[name]; fd.append(name, v); } return fd; } async function shPostFormUrl(url, fd, referer) { return new Promise((resolve) => { if (typeof GM_xmlhttpRequest !== 'function') return resolve({ ok: false, err: 'GM_xmlhttpRequest 不可用' }); GM_xmlhttpRequest({ method: 'POST', url: url, headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Referer': referer || 'https://open.cd/', 'Origin': 'https://open.cd', }, data: fd.toString(), timeout: 20000, onload: (r) => { const resp = r.responseText || ''; if (r.status !== 200) return resolve({ ok: false, err: 'HTTP ' + r.status }); if (/]*>\s*成功\s*<\/h2>/i.test(resp)) return resolve({ ok: true, resp: resp }); const errM = resp.match(/]*>\s*出错[了!]*\s*<\/h2>[\s\S]{0,800}?]*class=["']text["'][^>]*>([\s\S]{2,300}?)<\/td>/i); if (errM) return resolve({ ok: false, err: stripHtmlTags(errM[1]).slice(0, 200) || '提交被拒绝' }); const errM2 = resp.match(/class=["'](?:warning|error)["'][^>]*>([\s\S]{2,200}?)<\//i); if (errM2) return resolve({ ok: false, err: stripHtmlTags(errM2[1]).slice(0, 200) }); if (/viewrequests\.php|details\.php|comment/i.test(resp) && !/' + '
' + ' ' + '
' + '
' + '
'; mask.appendChild(win); document.body.appendChild(mask); const ctx = { reqid: reqid, win: win, mask: mask, info: info, parsed: null, native: null, isMine: isMine }; const $ = (id) => win.querySelector('#' + id); const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } }; const onWheel = (e) => { const t = e.target; if (!t || !t.closest || !t.closest('#ocd-req-preview')) return; const sc = t.closest('[data-ocd-scroll]'); if (!sc) { e.preventDefault(); return; } const atTop = sc.scrollTop <= 0 && e.deltaY < 0; const atBottom = sc.scrollTop + sc.clientHeight >= sc.scrollHeight - 1 && e.deltaY > 0; if (atTop || atBottom) e.preventDefault(); }; const close = () => { document.removeEventListener('keydown', onKey, true); document.removeEventListener('wheel', onWheel, true); mask.remove(); }; document.addEventListener('keydown', onKey, true); document.addEventListener('wheel', onWheel, { passive: false, capture: true }); mask.addEventListener('click', (e) => { if (e.target === mask) close(); }); $('ocd-rpv-close').addEventListener('click', close); const setNativeBtns = (p) => { ctx.native = p; const b = $('ocd-rpv-native'); const has = isMine ? !!p.edit : !!p.report; b.title = has ? '使用站点自带的' + (isMine ? '编辑' : '举报') + '入口' : '未在页面中识别到站点自带的' + (isMine ? '编辑' : '举报') + '入口,将打开原帖'; if (!has) b.style.opacity = '.72'; }; const renderResp = (p) => { const rw = $('ocd-rpv-resp'); const n = $('ocd-rpv-resp-n'); if (!p.responds.length) { rw.innerHTML = '暂无应求 —— 可点上方「⚡ 应求」用自己发布的种子应求'; if (n) n.textContent = ''; return; } rw.innerHTML = p.responds.map(r => '#' + esc(r.id) + ' ' + esc(r.title) + ' ↗').join(''); if (n) n.textContent = '· ' + p.responds.length + ' 条'; }; const load = async (force) => { $('ocd-rpv-resp').textContent = '加载中…'; $('ocd-rpv-body').textContent = '加载中…'; try { const rec = await shFetchReqView(reqid, force); const p = shParseReqView(rec.html, reqid); ctx.parsed = p; let stKey = p.statusKey; if ((!stKey || stKey === 'unknown') && p.respondsSeen) { if (info.status === 'completed') stKey = 'completed'; else stKey = p.responds.length ? 'responded' : 'pending'; } if (stKey && stKey !== 'unknown') { const s2 = SH_REQ_ST[stKey]; const sb = $('ocd-rpv-st'); sb.textContent = s2.cn; sb.style.background = s2.clr + '22'; sb.style.color = s2.clr; } if (p.bonus) $('ocd-rpv-bonus').innerHTML = '💰 ' + esc(String(p.bonus)) + ''; renderResp(p); /* V9.76.17(用户 m13487 ⑥-4):正文里的链接渲染成可点超链接 ✓ (先转义再套 ,XSS 面不变 ✓;容器仍保留 white-space:pre-wrap 保证换行 ✓) */ $('ocd-rpv-body').innerHTML = p.intro ? shReqLinkify(p.intro) : '(该帖没有正文内容)'; const dg = p.diag || { hasRoot: false, blocks: 0, keys: 0, endpoints: [] }; const cmNote = p.comments.length ? '' : ('未识别到评论(诊断:容器' + (dg.hasRoot ? '有' : '无') + '|候选块 ' + dg.blocks + '|comment关键字 ' + dg.keys + '|表单 ' + (p.commentForm ? p.commentForm.field : '无') + ')' + (p.commentForm ? '' : ',可在原帖页面评论')); shRenderReqComments(ctx, p.comments, cmNote); const pgUrls = shCommentPageUrls(shParserDocOf(rec.html), reqid); ctx.cmPages = pgUrls; const moreBtn = $('ocd-rpv-cm-more'); if (moreBtn) { moreBtn.style.display = pgUrls.length ? '' : 'none'; moreBtn.textContent = '载入其余 ' + pgUrls.length + ' 页'; moreBtn.onclick = async () => { if (moreBtn.disabled) return; moreBtn.disabled = true; const oldTxt = moreBtn.textContent; moreBtn.textContent = '载入中…'; const got = await shFetchMoreComments(ctx, reqid, pgUrls); moreBtn.disabled = false; moreBtn.textContent = oldTxt; moreBtn.style.display = got ? 'none' : ''; toastSafe(got ? ('📄 已载入其余评论 ' + got + ' 条') : '未取到新评论', got ? 'ok' : 'err'); }; } $('ocd-rpv-cm-send').title = p.commentForm ? ('提交到 ' + p.commentForm.url + '(字段 ' + p.commentForm.field + ')') : '未识别到评论表单'; setNativeBtns(p); console.log('[ReqPreview] #' + reqid + ' 解析完成:应求 ' + p.responds.length + ' 条 · 评论 ' + p.comments.length + ' 条(容器' + (dg.hasRoot ? '✓' : '✗') + ' 候选块' + dg.blocks + ' 关键字' + dg.keys + ') · 表单 ' + (p.commentForm ? p.commentForm.field : '无') + ' · 举报 ' + (p.report ? '有' : '无') + ' · 编辑 ' + (p.edit ? '有' : '无')); if (dg.endpoints.length) console.log('[ReqPreview] #' + reqid + ' 页面中的评论/回复相关地址:', dg.endpoints); } catch (e) { const msg = '抓取失败:' + (e.message || e); $('ocd-rpv-resp').innerHTML = '' + esc(msg) + ''; $('ocd-rpv-body').textContent = '—'; console.warn('[ReqPreview] ' + msg); } }; $('ocd-rpv-reload').addEventListener('click', () => { load(true); }); $('ocd-rpv-grab-btn').addEventListener('click', () => { shReqGrab(reqid).catch(err => toastSafe('取证失败:' + (err.message || err), 'err')); }); $('ocd-rpv-respond').addEventListener('click', () => { shQuickRespondDialog(reqid, title); }); $('ocd-rpv-append').addEventListener('click', () => { const cur = ctx.parsed && ctx.parsed.bonus ? ctx.parsed.bonus : bonus; shOpenAppendDialog(reqid, title, cur); }); $('ocd-rpv-native').addEventListener('click', () => { const p = ctx.parsed || {}; const nat = isMine ? (p.edit || null) : (p.report || null); const rowEl = isMine ? shFindRowAction(row, /编辑|edit/i) : shFindRowAction(row, /举报|投诉|report/i); const href = (nat && nat.href) || (rowEl ? String(rowEl.getAttribute('href') || '') : ''); const abs = shReqAbsUrl(href); if (abs) { window.open(abs, '_blank'); return; } if (rowEl && rowEl.isConnected) { close(); try { rowEl.click(); } catch (e) {} return; } if (isMine) { close(); shOpenEditDialog(reqid, title); return; } window.open(shReqViewUrl(reqid), '_blank'); }); $('ocd-rpv-cm-btn').addEventListener('click', () => { const box = $('ocd-rpv-cm'); const show = box.style.display === 'none'; box.style.display = show ? 'block' : 'none'; if (show) { $('ocd-rpv-cm-text').focus(); if (!ctx.parsed) load(false); } }); $('ocd-rpv-cm-send').addEventListener('click', async () => { const ta = $('ocd-rpv-cm-text'); const text = String(ta.value || '').trim(); if (!text) { toastSafe('评论内容不能为空', 'err'); return; } const p = ctx.parsed; if (!p || !p.commentForm) { toastSafe('未识别到评论表单,请在原帖页面评论', 'err'); return; } const btn = $('ocd-rpv-cm-send'); btn.disabled = true; const oldTxt = btn.textContent; btn.textContent = '提交中…'; const patch = {}; patch[p.commentForm.field] = text; const fd = shSerializeForm(p.commentForm.form, patch); const r = await shPostFormUrl(p.commentForm.url, fd, shReqViewUrl(reqid)); /* V9.76.17(用户 m13487 ⑥-1):**不拿 r.ok 当唯一判据** —— 真机复测发现站点有时 不回成功页(被判失败 ✗)但其实评论已经发出去了 ✓ → 强制重抓一次原帖,用「这段正文有没有出现在评论列表里」当成功证据(真证据 ✗ 不猜) */ let landed = false; try { await load(true); const arr = (ctx.parsed && Array.isArray(ctx.parsed.comments)) ? ctx.parsed.comments : []; const core = String(text).replace(/\[\/?[^\]]{0,40}\]/g, ' ').replace(/\s+/g, '').slice(0, 60); landed = arr.some((c) => { if (!c || !c.body) return false; if (shReqSameText(c.body, text)) return true; const nb = String(c.body).replace(/\s+/g, ''); return !!core && nb.indexOf(core) >= 0; }); } catch (e) { console.warn('[ReqPreview] 评论落地复核失败:', (e && e.message) || e); } /* 站点明确回成功页但列表还没刷出来(缓存 / 该评论在后续分页)→ 也按成功处理 ✓ */ const softOk = r.ok; if (!landed && softOk) landed = true; if (landed) { ctx.lastPosted = { text: text, ts: Date.now() }; ta.value = ''; ctx.replyTo = null; btn.disabled = false; btn.textContent = '发表评论'; /* ⑥-1:就地实时显示这条新评论(绿色高亮 + ✓ 刚发布 + 滚到可见 ✓) */ try { const items = (ctx.parsed && ctx.parsed.comments) || []; shRenderReqComments(ctx, items, ''); const box2 = $('ocd-rpv-cm'); if (box2) box2.style.display = 'block'; const listEl = $('ocd-rpv-cm-list'); const fresh = listEl && listEl.querySelector('[data-ocd-new="1"]'); if (fresh && fresh.scrollIntoView) fresh.scrollIntoView({ block: 'nearest' }); } catch (e) {} toastSafe(r.ok && !r.soft ? '✅ 评论已发布(列表已就地刷新 ✓)' : '✅ 评论已发布(站点未回成功页,已按评论列表复核确认 ✓)', 'ok'); return; } btn.disabled = false; btn.textContent = oldTxt || '发表评论'; toastSafe('❌ 评论失败:' + (r.err || '重抓原帖后没在评论列表里看到这条评论'), 'err'); console.warn('[ReqPreview] 评论提交失败:', r.err, 'ok=', r.ok, p.commentForm); }); $('ocd-rpv-cm-text').addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); $('ocd-rpv-cm-send').click(); } }); $('ocd-rpv-quote').addEventListener('click', () => { const ta = $('ocd-rpv-cm-text'); if (!ta) return; ta.value = shReqQuoteText(ctx.parsed) + ta.value; ta.focus(); toastSafe('❝ 已填入引用,按 Ctrl+Enter 发送', 'ok'); }); /* V9.76.17(用户 m13487 ⑥-3):#ocd-rpv-triple 与其监听器已删除 ✓ */ load(false); } async function shReqDiag(reqid) { const rec = await shFetchReqView(reqid, true); const p = shParseReqView(rec.html, reqid); const doc = shParserDocOf(rec.html); const labels = []; if (doc) { doc.querySelectorAll('td.rowhead').forEach(h => { const t = (h.textContent || '').replace(/\s+/g, '').replace(/[::]$/, ''); if (t && labels.indexOf(t) === -1) labels.push(t); }); } const cands = shScanCommentBlocks(doc); const samples = cands.slice(0, 3).map(el => ({ 标签: el.tagName.toLowerCase() + (el.className ? '.' + String(el.className).replace(/\s+/g, '.') : ''), 文本长度: (el.textContent || '').trim().length, HTML: (el.outerHTML || '').replace(/\s+/g, ' ').slice(0, 400), })); const out = { 帖子: reqid, 状态: p.statusTxt, 悬赏: p.bonus, 正文长度: (p.intro || '').length, 应求条数: p.responds.length, 评论条数: p.comments.length, 评论表单: p.commentForm ? (p.commentForm.field + ' @ ' + p.commentForm.url) : '未找到', 举报入口: p.report ? (p.report.href || p.report.text) : '未找到', 编辑入口: p.edit ? (p.edit.href || p.edit.text) : '未找到', 内部诊断: p.diag, 表头标签: labels.join(' | '), 页面userdetails链接数: doc ? doc.querySelectorAll('a[href*="userdetails.php"]').length : -1, 页面日期时间数: (rec.html.match(/\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}/g) || []).length, 扫描候选片段: samples, HTML长度: rec.html.length, }; console.log('[ReqPreview] 诊断 #' + reqid + ':', out); return out; } async function shReqGrab(reqid) { const rec = await shFetchReqView(reqid, true); const html = rec.html; const doc = shParserDocOf(html); const p = shParseReqView(html, reqid); const L = []; L.push('=== OpenCD 求种帖取证报告 #' + reqid + ' ==='); L.push('HTML长度=' + html.length + ' 状态=' + (p.statusTxt || '?') + ' 悬赏=' + p.bonus + ' 正文长度=' + (p.intro || '').length + ' 脚本判定评论=' + p.comments.length + ' 应求=' + p.responds.length); L.push('内部诊断=' + JSON.stringify(p.diag)); L.push(''); L.push('--- 1) 信息表 rowhead => rowfollow 预览 ---'); if (doc) { let n = 0; doc.querySelectorAll('tr').forEach(tr => { const h = tr.querySelector('td.rowhead'); const b = tr.querySelector('td.rowfollow'); if (!h || !b) return; const lab = (h.textContent || '').replace(/\s+/g, '').replace(/[::]$/, ''); L.push('[' + (++n) + '] ' + lab + ' => ' + (b.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 70)); }); } L.push(''); L.push('--- 2) 关键词附近 HTML(各取前 2 处,前后各 800 字符) ---'); for (const kw of ['评论', '跟帖', '回复']) { let from = 0, hit = 0; while (hit < 2) { const idx = html.indexOf(kw, from); if (idx === -1) break; L.push('[' + kw + ' @' + idx + '] ' + html.slice(Math.max(0, idx - 800), idx + 800).replace(/\s+/g, ' ')); from = idx + kw.length; hit++; } if (!hit) L.push('[' + kw + '] 页面未出现'); } L.push(''); L.push('--- 3) 页面全部 form ---'); if (doc) { let n = 0; doc.querySelectorAll('form').forEach(f => { const fields = []; f.querySelectorAll('input, textarea, select').forEach(el => { const nm = el.getAttribute('name'); if (nm) fields.push(nm + (el.tagName === 'TEXTAREA' ? '(文本域)' : '')); }); L.push('[' + (++n) + '] action=' + (f.getAttribute('action') || '(空)') + ' method=' + (f.getAttribute('method') || 'get') + ' 字段=' + (fields.join(',') || '(无)')); }); } L.push(''); L.push('--- 4) 脚本的评论候选块 outerHTML ---'); const blocks = shScanCommentBlocks(doc); if (!blocks.length) L.push('(无候选)'); blocks.slice(0, 6).forEach((el, i) => { L.push('[' + i + '] <' + el.tagName.toLowerCase() + ' class="' + String(el.className || '') + '"> ' + (el.outerHTML || '').replace(/\s+/g, ' ').slice(0, 700)); }); const text = L.join('\n'); shShowReqGrab(reqid, text); console.log('[ReqPreview] 取证报告已生成:' + text.length + ' 字符'); return text; } function shShowReqGrab(reqid, text) { const old = document.getElementById('ocd-rpv-grab'); if (old) old.remove(); const mask = document.createElement('div'); mask.id = 'ocd-rpv-grab'; mask.className = shGetThemeCls(); mask.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:' + ocdTopZ(20) + ';display:flex;align-items:center;justify-content:center;'; const win = document.createElement('div'); win.style.cssText = 'width:min(920px,95vw);max-height:90vh;display:flex;flex-direction:column;background:var(--bg,#242632);color:var(--fg,#e8eaf0);border:1px solid #4a69bd;border-radius:12px;padding:12px 14px;font:12px/1.6 -apple-system,"Microsoft YaHei",sans-serif;'; win.innerHTML = '
' + ' 🔎 求种帖取证报告 #' + esc(reqid) + '' + ' ' + ' ✕' + '
' + '
把文本框内容整段复制发给作者即可(点「复制全文」,或 Ctrl+A / Ctrl+C)
' + ''; mask.appendChild(win); document.body.appendChild(mask); const ta = win.querySelector('#ocd-rpv-grab-text'); ta.value = text; const close = () => { window.removeEventListener('keydown', onKey, true); mask.remove(); }; const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } }; window.addEventListener('keydown', onKey, true); win.querySelector('#ocd-rpv-grab-close').addEventListener('click', close); mask.addEventListener('click', (e) => { if (e.target === mask) close(); }); win.querySelector('#ocd-rpv-grab-copy').addEventListener('click', () => { ta.focus(); ta.select(); copyText(text, () => toastSafe('📋 取证报告已复制到剪贴板', 'ok')); }); } function shInjectPreviewButtonsOnList() { if (!/viewrequests\.php/i.test(location.pathname)) return; if (/[?&]action=view/i.test(location.search)) return; const links = document.querySelectorAll('a[href*="viewrequests.php?action=view&id="]'); let added = 0; for (const link of links) { if (link.dataset.shPreviewInjected === '1') continue; const tr = link.closest('tr'); if (!tr) continue; const tds = tr.querySelectorAll('td'); if (tds.length < 6) continue; const bonusEl = shFindBonusEl(tds); if (!bonusEl) continue; const bonus = bonusEl.bonus; const allView = tr.querySelectorAll('a[href*="viewrequests.php?action=view&id="]'); if (allView[0] !== link) continue; const m = String(link.getAttribute('href') || '').match(/id=(\d+)/); if (!m) continue; const reqid = m[1]; link.dataset.shPreviewInjected = '1'; const title = (link.textContent || '').trim(); const lastTd = tds[tds.length - 1]; const status = shReqStatusOf(lastTd ? lastTd.textContent : ''); const btn = document.createElement('span'); btn.className = 'ocd-site-preview'; btn.dataset.reqid = reqid; btn.textContent = '👁 预览'; btn.title = '不离开本页即可查看该求种帖的状态 / 悬赏 / 应求 / 正文 / 评论'; btn.style.cssText = 'display:inline-block;margin-left:6px;padding:0 6px;border:1px solid #4a9bff;border-radius:4px;color:#4a9bff;font-size:11px;cursor:pointer;user-select:none;vertical-align:middle;line-height:1.5;white-space:nowrap;'; btn.addEventListener('mouseenter', () => { btn.style.background = 'rgba(74,155,255,.14)'; }); btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent'; }); btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); let pubUid = ''; for (const a of tr.querySelectorAll('a[href*="userdetails.php?id="]')) { const mm = String(a.getAttribute('href') || '').match(/id=(\d+)/); if (mm) { pubUid = mm[1]; break; } } shOpenReqPreview(reqid, { title: title, bonus: bonus, status: status, pubUid: pubUid, row: tr }).catch(err => { console.warn('[ReqPreview] 打开失败:', err.message || err); }); }); const follow = tr.querySelector('.sh-site-follow[data-reqid="' + reqid + '"]'); if (follow && follow.parentNode) { shGroupPreviewWith(btn, follow); } else { const nameTd = link.closest('td'); if (nameTd) { let wrap = nameTd.querySelector('.ocd-nameflex'); if (!wrap) { wrap = document.createElement('div'); wrap.className = 'ocd-nameflex'; wrap.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%;'; while (nameTd.firstChild) wrap.appendChild(nameTd.firstChild); nameTd.appendChild(wrap); } wrap.appendChild(btn); } else { link.after(btn); } } added++; } if (added) { console.log('[ReqPreview] 已注入 ' + added + ' 个预览按钮'); setTimeout(shReflowPreviewButtons, 700); } } function shGroupPreviewWith(btn, follow) { let wrap = follow.closest('.ocd-nameflex'); if (!wrap) { const p = follow.parentElement; if (!p) return; wrap = (p.classList && p.classList.contains('ocd-actgroup')) ? p.parentElement : p; } if (!wrap) return; let group = null; for (const ch of wrap.children) { if (ch.classList && ch.classList.contains('ocd-actgroup')) { group = ch; break; } } if (!group) { group = document.createElement('span'); group.className = 'ocd-actgroup'; group.style.cssText = 'display:inline-flex;align-items:center;gap:6px;margin-left:auto;flex:0 0 auto;vertical-align:middle;'; if (follow.parentElement === wrap) wrap.insertBefore(group, follow); else wrap.appendChild(group); } if (follow.parentElement !== group) { group.appendChild(follow); follow.style.marginLeft = '0'; } if (btn.parentElement !== group || btn.nextElementSibling !== follow) group.insertBefore(btn, follow); btn.style.marginLeft = '0'; const link = wrap.querySelector('a[href*="viewrequests.php?action=view&id="]'); if (link) { link.style.flex = '1 1 auto'; link.style.minWidth = '0'; link.style.overflow = 'hidden'; link.style.textOverflow = 'ellipsis'; link.style.whiteSpace = 'nowrap'; } } function shReflowPreviewButtons() { let fixed = 0; document.querySelectorAll('.ocd-site-preview').forEach(btn => { const follow = document.querySelector('.sh-site-follow[data-reqid="' + btn.dataset.reqid + '"]'); if (!follow || !follow.parentNode) return; const before = btn.nextElementSibling; shGroupPreviewWith(btn, follow); if (before !== follow) fixed++; }); if (fixed) console.log('[ReqPreview] 按钮组归位 ' + fixed + ' 处'); } /* ================================================================ * §42 发布统计 * ================================================================ */ function openBatchStats() { const exist = document.getElementById('ocd-batch-stats-win'); if (exist) { exist.remove(); return; } const { tasks } = getBatchTasks(); const hist0 = store.get('ocd_helper_statshist_v1', {}) || {}; const hist = { total: hist0.total || 0, byMonth: hist0.byMonth || {}, byPlat: hist0.byPlat || {}, fail: hist0.fail || 0, msSum: hist0.msSum || 0, msN: hist0.msN || 0, }; const now = new Date(); const monthKey = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0'); const monthPub = hist.byMonth[monthKey] || 0; const platEntries = Object.entries(hist.byPlat).sort((a, b) => b[1] - a[1]); const platMax = platEntries.length ? platEntries[0][1] : 1; const avgS = hist.msN ? Math.round(hist.msSum / hist.msN / 1000) : 0; const failRate = (hist.total + hist.fail) ? Math.round(hist.fail / (hist.total + hist.fail) * 100) : 0; const curPlat = {}; tasks.forEach(t => { const pn = (t.result && t.result.platform && t.result.platform.hit && t.result.platform.hit.platformName) || (t.parsed && t.parsed.platformName); if (pn) curPlat[pn] = (curPlat[pn] || 0) + 1; }); const mask = document.createElement('div'); mask.id = 'ocd-batch-stats-win'; mask.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:' + ocdTopZ(20) + ';display:flex;align-items:center;justify-content:center;'; const win = document.createElement('div'); win.style.cssText = 'width:min(520px,92vw);background:var(--bg,#242632);color:var(--fg,#e8eaf0);border:1px solid var(--border,#444);border-radius:14px;padding:16px 18px;font:13px/1.7 -apple-system,"Microsoft YaHei",sans-serif;box-shadow:0 20px 60px rgba(0,0,0,.5);position:relative;'; const head = document.createElement('div'); head.style.cssText = 'display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;cursor:move;user-select:none;'; head.innerHTML = '📊 发布统计' + '✕'; win.appendChild(head); makeDraggable(head, 'ocd_helper_stats_win_v1', win); const savedPos = store.get('ocd_helper_stats_win_v1', {}) || {}; if (savedPos.x != null) { mask.style.alignItems = 'flex-start'; mask.style.justifyContent = 'flex-start'; requestAnimationFrame(() => { const r = win.getBoundingClientRect(); win.style.position = 'fixed'; win.style.left = Math.max(0, Math.min(window.innerWidth - r.width, savedPos.x)) + 'px'; win.style.top = Math.max(0, Math.min(window.innerHeight - 40, savedPos.y)) + 'px'; win.style.margin = '0'; }); } const statsBody = document.createElement('div'); statsBody.innerHTML = '
' + '
' + '
' + monthPub + '
' + '
本月发布
' + '
' + '
' + hist.total + '
' + '
累计发布
' + '
' + '
' + avgS + 's
' + '
平均解析耗时
' + '
' + '
' + failRate + '%
' + '
失败率
' + '
' + '
平台分布(累计)
' + '
' + (platEntries.length ? platEntries.map(([name, n]) => { const pct = Math.round(n / platMax * 100); return '
' + '' + esc(name) + '' + '
' + '
' + '' + n + '
'; }).join('') : '
暂无数据(发布后自动累计)
') + '
' + '
当前批次平台分布
' + '
' + (Object.keys(curPlat).length ? Object.entries(curPlat).sort((a,b)=>b[1]-a[1]).map(([n,c]) => esc(n) + ' ×' + c).join(' | ') : '(空)') + '
'; win.appendChild(statsBody); mask.appendChild(win); document.body.appendChild(mask); const main = document.getElementById('ocd-helper-panel'); if (main) { const cls = Array.from(main.classList).find(c => c.indexOf('ocd-theme-') === 0); if (cls) mask.classList.add(cls); } win.querySelector('#ocd-stats-close').addEventListener('click', () => mask.remove()); mask.addEventListener('click', (e) => { if (e.target === mask) mask.remove(); }); } function shStatPubFail(taskId, reason) { try { const { tasks } = getBatchTasks(); const t = tasks.find(x => x.id === taskId); const pn = (t && taskPlatformOf(t)) || (t && t.parsed && t.parsed.platformName) || ''; statsRecord(false, pn, 0); console.log('[Stats] 记录发布失败:', String(reason || '').slice(0, 40), '| 任务', taskId, '| 平台', pn || '(未知)'); } catch (e) {} } function shStatPreFail(reason) { try { statsRecord(false, '', 0, 'pre'); console.log('[Stats] 记录预发布失败:', String(reason || '').slice(0, 40)); } catch (e) {} } function statsRecord(publish, platformName, costMs, kind) { try { const hist0 = store.get('ocd_helper_statshist_v1', {}) || {}; const hist = { total: hist0.total || 0, byMonth: hist0.byMonth || {}, byPlat: hist0.byPlat || {}, fail: hist0.fail || 0, failPre: hist0.failPre || 0, msSum: hist0.msSum || 0, msN: hist0.msN || 0, }; if (publish) { hist.total++; const mk = new Date().getFullYear() + '-' + String(new Date().getMonth() + 1).padStart(2, '0'); hist.byMonth[mk] = (hist.byMonth[mk] || 0) + 1; if (platformName) hist.byPlat[platformName] = (hist.byPlat[platformName] || 0) + 1; } else if (kind === 'pre') hist.failPre++; else hist.fail++; if (costMs) { hist.msSum += costMs; hist.msN++; } store.set('ocd_helper_statshist_v1', hist); } catch (e) {} } /* ================================================================ * §43 数据面板:快照采集器 * ================================================================ */ function dpSizeToMB(numStr, unit) { const n = parseFloat(String(numStr).replace(/,/g, '')); if (!isFinite(n)) return 0; const u = String(unit || '').toUpperCase(); if (u.indexOf('TB') !== -1) return n * 1048576; if (u.indexOf('GB') !== -1) return n * 1024; if (u.indexOf('KB') !== -1) return n / 1024; return n; } function dpParseSnapshot(html, selfUid) { const s = String(html || ''); const snap = { ts: Date.now(), magic: 0, ratio: 0, uploadedMB: 0, downloadedMB: 0, uploadedMonthMB: 0, hr: 0, published: 0, publishedGB: 0, seeding: 0, leeching: 0 }; let hitCnt = 0; let m = s.match(/魔力值?\s*]*>?\s*[::]?\s*<\/?a?[^>]*>?\s*([0-9][0-9,]*\.?\d*)/) || s.match(/魔力值\s*<\/td>]*>([0-9][0-9,]*\.?\d*)\s*:\s*]*>([0-9.]+)<\/font>/) || s.match(/分享率:<\/font>\s*([0-9.]+)/); if (m) { snap.ratio = parseFloat(m[1]); hitCnt++; } m = s.match(/上传量<\/strong>\s*:\s*([0-9.,]+)\s*(TB|GB|MB|KB)(?:\(本月([0-9.,]+)\s*(TB|GB|MB|KB)\))?/i); if (m) { snap.uploadedMB = dpSizeToMB(m[1], m[2]); if (m[3]) snap.uploadedMonthMB = dpSizeToMB(m[3], m[4]); hitCnt++; } else { m = s.match(/上传量:<\/font>\s*([0-9.,]+)\s*(TB|GB|MB|KB)/i); if (m) { snap.uploadedMB = dpSizeToMB(m[1], m[2]); hitCnt++; } } m = s.match(/下载量<\/strong>\s*:\s*([0-9.,]+)\s*(TB|GB|MB|KB)/i) || s.match(/下载量:<\/font>\s*([0-9.,]+)\s*(TB|GB|MB|KB)/i); if (m) { snap.downloadedMB = dpSizeToMB(m[1], m[2]); hitCnt++; } m = s.match(/H&R<\/td>]*>(?:]*>)?(?:]*>)?(\d+)/) || s.match(/H&R:\s*]*>(?:]*>)?(\d+)/); if (m) { snap.hr = parseInt(m[1], 10) || 0; hitCnt++; } const countOf = (re) => { const mm = s.match(re); return mm ? mm : null; }; m = countOf(/发布种子<\/a>[\s\S]{0,400}?\((\d+)个种子,共计([0-9.,]+)\s*(TB|GB|MB|KB)\)/); if (m) { snap.published = parseInt(m[1], 10); snap.publishedGB = dpSizeToMB(m[2], m[3]) / 1024; hitCnt++; } m = countOf(/当前做种<\/a>[\s\S]{0,400}?\((\d+)个种子/); if (m) { snap.seeding = parseInt(m[1], 10); hitCnt++; } else { m = s.match(/title="当前做种"[^>]*>\s*(\d+)/); if (m) { snap.seeding = parseInt(m[1], 10); hitCnt++; } } m = countOf(/当前下载<\/a>[\s\S]{0,400}?\((\d+)个种子/); if (m) { snap.leeching = parseInt(m[1], 10); hitCnt++; } else { m = s.match(/title="当前下载"[^>]*>\s*(\d+)/); if (m) { snap.leeching = parseInt(m[1], 10); hitCnt++; } } if (hitCnt < 3) throw new Error('指标解析不足(' + hitCnt + '/9):页面结构可能变化或未登录'); return snap; } function dpSeedTimeToSec(t) { const s = String(t || '').trim(); const dm = s.match(/^(?:(\d+)天)?(\d+):(\d+):(\d+)$/); if (!dm) return 0; return (parseInt(dm[1], 10) || 0) * 86400 + parseInt(dm[2], 10) * 3600 + parseInt(dm[3], 10) * 60 + parseInt(dm[4], 10); } async function dpCollectSeedEarnings(onProgress) { const uid = (store.get('ocd_helper_dp_uid_v1', '') || '').trim(); if (!uid) throw new Error('用户 ID 未识别(先在数据面板触发一次快照采集)'); const rows = []; let page = 1, firstPageCap = 0; for (;;) { const r = await new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url: 'https://open.cd/getusertorrentlistajax.php?userid=' + uid + '&type=uploaded&page=' + page, headers: { Referer: 'https://open.cd/userdetails.php?id=' + uid }, timeout: advTimeoutMs('fetch', 120000), onload: (res) => resolve({ status: res.status, text: res.responseText || '' }), onerror: () => resolve({ status: 0, text: '' }), ontimeout: () => resolve({ status: 0, text: '' }), }); }); if (r.status !== 200 || !r.text || r.text.length < 100) break; const titleRe = /
]*title="([^"]{1,300})"[^>]*>/gi; const promoRe = /]+class="pro_[a-z0-9]+"[^>]*title="([^"]{1,20})"/i; let m, cnt = 0; const anchors = []; while ((m = titleRe.exec(r.text)) !== null) { anchors.push({ idx: m.index, len: m[0].length, id: m[1], title: stripHtmlTags(m[2]).trim() }); } const cellRe = /]*>([\s\S]{1,2000}?)<\/td>/g; const cellsAll = []; let cm; while ((cm = cellRe.exec(r.text)) !== null) { cellsAll.push({ idx: cm.index, content: cm[1] }); } for (const a of anchors) { let ti = -1; for (let i = cellsAll.length - 1; i >= 0; i--) { if (cellsAll[i].idx <= a.idx) { ti = i; break; } } if (ti === -1 || ti + 5 >= cellsAll.length) continue; const headCell = cellsAll[ti].content; const pm = headCell.match(promoRe); const promo = pm ? pm[1] : ''; const cSize = cellsAll[ti + 1].content; const cSeed = cellsAll[ti + 2].content; const cLeech = cellsAll[ti + 3].content; const cUp = cellsAll[ti + 4].content; const cTime = cellsAll[ti + 5].content; const szM = cSize.match(/([0-9.,]+)\s*(?:| )\s*(TB|GB|MB|KB)/i); const upM = cUp.match(/([0-9.,]+)\s*(?:| )\s*(TB|GB|MB|KB)/i); if (!szM || !upM) continue; const sizeMB = dpSizeToMB(szM[1], szM[2]); const upMB = dpSizeToMB(upM[1], upM[2]); const seeders = parseInt(stripHtmlTags(cSeed), 10) || 0; const leechers = parseInt(stripHtmlTags(cLeech), 10) || 0; const seedSec = dpSeedTimeToSec(stripHtmlTags(cTime).trim()); if (upMB <= 0 && !promo) continue; cnt++; rows.push({ id: 'seed_' + a.id, tid: a.id, title: a.title, sizeMB, seeders, leechers, uploadedMB: upMB, seedSec, promo, ts: Date.now(), }); } if (onProgress) onProgress(page, rows.length); const pageCap = anchors.length; if (pageCap === 0) break; if (page === 1) firstPageCap = pageCap; if (firstPageCap && pageCap < firstPageCap) break; page++; if (page > 60) break; await new Promise(r2 => setTimeout(r2, 1200)); } if (!rows.length) throw new Error('发布列表解析为空(接口形态可能变化)'); await ocdDB.bulkPut('metaLibrary', rows); return rows; } async function dpCollectHrList() { const rows = []; const r = await new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url: 'https://open.cd/getusertorrentlistajax.php?userid=' + (store.get('ocd_helper_dp_uid_v1', '') || '') + '&type=hr', headers: { Referer: 'https://open.cd/userdetails.php' }, timeout: advTimeoutMs('fetch', 120000), onload: (res) => resolve({ status: res.status, text: res.responseText || '' }), onerror: (e) => { console.error('[DataPanel] H&R 请求 onerror:', e && e.error || e); resolve({ status: 0, text: '' }); }, ontimeout: () => { console.error('[DataPanel] H&R 请求超时(120s)'); resolve({ status: 0, text: '' }); }, }); }); if (r.status !== 200 || !r.text) throw new Error('H&R 获取失败 HTTP=' + r.status); const re = /]*title="([^"]{1,300})"[^>]*>[\s\S]{0,300}?<\/td>]*>([0-9.,]+)\s*\s*(TB|GB|MB|KB)<\/td>]*>(\d+)<\/td>]*>(\d+)<\/td>]*>([0-9.,]+)\s*\s*(TB|GB|MB|KB)<\/td>]*>([^<]{1,40})<\/td>/gi; let m; while ((m = re.exec(r.text)) !== null) { rows.push({ id: 'hr_' + m[1], tid: m[1], title: stripHtmlTags(m[2]).trim(), sizeMB: dpSizeToMB(m[3], m[4]), seeders: parseInt(m[5], 10) || 0, uploadedMB: dpSizeToMB(m[7], m[8]), ts: Date.now(), }); } if (!rows.length) { if (r.text.length < 500 || /没有记录|0条记录|无记录/.test(r.text)) return []; throw new Error('H&R 列表解析为空(页面结构可能变化,响应长 ' + r.text.length + ')'); } await ocdDB.bulkPut('reqLedger', rows); return rows; } async function dpCollectSnapshot() { const uid = (store.get('ocd_helper_dp_uid_v1', '') || '').trim(); let url; if (/userdetails\.php\?id=(\d+)/.test(location.search) && location.pathname.indexOf('userdetails') !== -1) { url = location.href; } else if (uid) { url = 'https://open.cd/userdetails.php?id=' + uid; } else { const a = document.querySelector('.infos-bar a[href*="userdetails.php?id="]'); const mu = a ? (a.getAttribute('href').match(/id=(\d+)/) || [])[1] : ''; if (mu) { store.set('ocd_helper_dp_uid_v1', mu); url = 'https://open.cd/userdetails.php?id=' + mu; } else throw new Error('无法确定用户 ID(请先登录,或在个人详情页触发一次采集)'); } let snap; if (url === location.href && /userdetails\.php/.test(location.pathname)) { snap = dpParseSnapshot(document.documentElement.innerHTML, uid); } else { const r = await gmFetchText(url, { Referer: 'https://open.cd/' }); if (!r || r.length < 3000) throw new Error('详情页响应异常(len=' + (r || '').length + ')'); snap = dpParseSnapshot(r, uid); } try { const bonusHtml = await gmFetchText('https://open.cd/mybonus.php', { Referer: 'https://open.cd/' }); const bm = bonusHtml.match(/你当前每小时能获取([0-9.]+)个魔力值/); if (bm) snap.hourlyMagic = parseFloat(bm[1]); else snap.hourlyMagic = 0; } catch (e) { snap.hourlyMagic = 0; } const all = await ocdDB.getAll('snapshots'); const near = all.filter(x => Math.abs(x.ts - snap.ts) < 5 * 60 * 1000).sort((a, b) => b.ts - a.ts)[0]; if (near) snap.ts = near.ts; const ok = await ocdDB.put('snapshots', snap); if (!ok) throw new Error('快照写入失败(存储不可用)'); dpPref.set({ lastCollectTs: snap.ts, lastHr: snap.hr }); /* 9.1 低魔力提醒:低于阈值时提醒充能(默认关闭,6 小时内只提醒一次) */ try { const minM = Number((advGroup('notify') || {}).magicMin) || 0; const magic = Number(snap.magic) || 0; if (minM > 0 && magic < minM && !advSnoozed('magic-low')) { advSnooze('magic-low', 6 * 60 * 60 * 1000); ocdAudit('notify', '魔力值低于阈值', magic + ' < ' + minM); toastAction('⚠️ 魔力值仅 ' + magic + '(低于 ' + minM + '),建议去「魔力值」页充能', '打开数据面板', () => shOpenModuleById('datapanel'), { ms: 12000, level: 'warn' }); try { advWebhook({ kind: 'notify', title: '魔力值偏低', text: '当前 ' + magic + '(阈值 ' + minM + ')', url: 'https://open.cd/mybonus.php' }); } catch (e) {} } } catch (e) {} return snap; } /* ================================================================ * §44 模块宿主 + 发布页助手生命周期 + 拖拽工具 * ================================================================ */ let _openModuleId = null; const PANEL_IDS = ['ocd-batch-panel', 'ocd-dp-panel', 'ocd-dc-panel', 'ocd-probe-panel', 'ocd-helper-panel', 'ocd-sh-panel']; function allPanelEls() { const out = []; const seen = {}; const push = (el) => { if (!el || seen[el.id || ''] || el.id === 'ocd-mod-host') return; seen[el.id || ''] = 1; out.push(el); }; PANEL_IDS.forEach(id => push(document.getElementById(id))); document.querySelectorAll('[data-ocd-panel]').forEach(push); return out.filter(el => el.style.display !== 'none'); } function isPinned(panelEl) { return panelEl.__pinned === true; } function focusPanel(panelEl) { if (!panelEl || panelEl.__pinned) return; let max = 9999990; allPanelEls().forEach(el => { if (el !== panelEl && !el.__pinned) max = Math.max(max, parseInt(el.style.zIndex, 10) || 0); }); if (max + 1 >= 9999997) { const all = allPanelEls() .filter(el => !el.__pinned) .sort((a, b) => (parseInt(b.style.zIndex, 10) || 0) - (parseInt(a.style.zIndex, 10) || 0)); const base = 9999990; const top = Math.max(0, all.length - 1); all.forEach((el, i) => { el.style.zIndex = String(base + (top - i)); }); max = base + top; } panelEl.style.zIndex = String(max + 1); } function togglePin(panelEl, pinBtn) { if (!panelEl) return; if (isPinned(panelEl)) { panelEl.__pinned = false; focusPanel(panelEl); pinBtn.textContent = '📌'; pinBtn.title = '置顶(保持在其他面板之上)'; pinBtn.classList.remove('pinned'); pinBtn.style.opacity = (pinBtn.matches(':hover')) ? '1' : ''; } else { panelEl.__pinned = true; let pinMax = 9999997; PANEL_IDS.forEach(id => { const el = document.getElementById(id); if (el && el !== panelEl && el.__pinned) pinMax = Math.max(pinMax, parseInt(el.style.zIndex, 10) || 0); }); panelEl.style.zIndex = String(pinMax + 1); pinBtn.textContent = '📍'; pinBtn.title = '取消置顶'; pinBtn.classList.add('pinned'); pinBtn.style.opacity = '1'; } } function makePinBtn(headEl, panelEl, beforeSel) { const btn = document.createElement('span'); btn.className = 'ocd-pin-btn'; btn.textContent = '📌'; btn.title = '置顶(保持在其他面板之上)'; btn.style.cssText = 'cursor:pointer;user-select:none;font-size:13px;margin-right:8px;opacity:.55;transition:opacity .15s;'; btn.addEventListener('mouseenter', () => { btn.style.opacity = '1'; }); btn.addEventListener('mouseleave', () => { if (!isPinned(panelEl)) btn.style.opacity = '.55'; }); btn.addEventListener('click', (e) => { e.stopPropagation(); togglePin(panelEl, btn); }); const anchor = beforeSel ? headEl.querySelector(beforeSel) : null; if (anchor && anchor.parentElement) anchor.parentElement.insertBefore(btn, anchor); else headEl.appendChild(btn); return btn; } function ensureModuleHost() { let host = document.getElementById('ocd-mod-host'); if (!host) { host = document.createElement('div'); host.id = 'ocd-mod-host'; host.style.cssText = 'position:fixed; left:0; top:0; width:0; height:0; z-index:9999997;'; document.body.appendChild(host); } return host; } function openModuleUI(mod) { if (_openModuleId === mod.id) { if (mod.onClose) mod.onClose(); _openModuleId = null; return; } if (getMultiUI()) { if (mod.id !== 'uploader') { const host = ensureModuleHost(); let container = document.getElementById('ocd-mod-' + mod.id); if (!container) { container = document.createElement('div'); container.id = 'ocd-mod-' + mod.id; host.appendChild(container); } mod.onOpen(container); if (container.firstElementChild) applyOpenAnim(container.firstElementChild); if (container.firstElementChild) focusPanel(container.firstElementChild); } else { mod.onOpen(); applyOpenAnim(document.getElementById('ocd-helper-panel')); } _openModuleId = mod.id; return; } if (_openModuleId) { const prev = getModule(_openModuleId); if (prev && prev.onClose) prev.onClose(); _openModuleId = null; } if (mod.id !== 'uploader') { const host = ensureModuleHost(); host.innerHTML = ''; const container = document.createElement('div'); container.id = 'ocd-mod-' + mod.id; host.appendChild(container); mod.onOpen(container); if (container.firstElementChild) applyOpenAnim(container.firstElementChild); } else { mod.onOpen(); applyOpenAnim(document.getElementById('ocd-helper-panel')); } _openModuleId = mod.id; } let _uploaderBuilt = false; let _uploaderVisible = false; function initUploaderBusiness() { const fo = getFsOpts(); if (fo.blurFade) { document.addEventListener('mouseover', (e) => { const p = document.getElementById('ocd-helper-panel'); if (!p) return; if (e.target.closest('#ocd-helper-panel')) p.style.opacity = '1'; else if (_uploaderVisible) p.style.opacity = (Number(getFsOpts().blurOpacity) / 100) + ''; }); } } function openUploaderUI() { if (!_uploaderBuilt) { buildPanel(); _uploaderBuilt = true; } const panel = document.getElementById('ocd-helper-panel'); if (!panel) return; panel.style.display = 'flex'; _uploaderVisible = true; _openModuleId = 'uploader'; focusPanel(panel); } function closeUploaderUI() { const panel = document.getElementById('ocd-helper-panel'); if (panel) panel.style.display = 'none'; _uploaderVisible = false; if (_openModuleId === 'uploader') _openModuleId = null; } const _dragShared = { active: null }; function _ensureDragListener() { if (_dragShared.bound) return; _dragShared.bound = true; let pendEv = null, rafId = 0; const applyDrag = () => { rafId = 0; const st = _dragShared.active; const ev = pendEv; pendEv = null; if (!st || !st.dragging || !ev) return; const dx = ev.clientX - st.sx, dy = ev.clientY - st.sy; if (!st.moved && Math.hypot(dx, dy) > 5) { st.moved = true; st.panelEl.classList.add('ocd-dragging'); } if (!st.moved) return; const maxX = window.innerWidth - (st.w || 320), maxY = window.innerHeight - 30; const nx = Math.max(0, Math.min(maxX, st.fx + dx)); const ny = Math.max(0, Math.min(maxY, st.fy + dy)); st.panelEl.style.left = nx + 'px'; st.panelEl.style.top = ny + 'px'; st.panelEl.style.right = 'auto'; /* 拖到边缘时降低不透明度(5.3),松手回正 */ try { const nearEdge = nx <= 20 || (nx + (st.w || 320)) >= (window.innerWidth - 20) || ny <= 8; if (nearEdge !== !!st.edgeLit) { st.edgeLit = nearEdge; st.panelEl.style.opacity = nearEdge ? '0.78' : ''; st.panelEl.style.transition = 'opacity .12s'; } } catch (e) {} }; const _pt = (e) => (e.touches && e.touches[0]) ? { clientX: e.touches[0].clientX, clientY: e.touches[0].clientY } : e; const _feed = (e) => { const st = _dragShared.active; if (!st || !st.dragging) return; pendEv = _pt(e); if (rafId) return; rafId = requestAnimationFrame(applyDrag); }; const _end = () => { const st = _dragShared.active; if (!st || !st.dragging) return; if (rafId) { cancelAnimationFrame(rafId); rafId = 0; applyDrag(); } st.dragging = false; st.panelEl.classList.remove('ocd-dragging'); st.panelEl.style.opacity = ''; try { if (st.moved) { /* 松手吸附到最近的水平边缘(距离 ≤ 16px 时) */ const rect = st.panelEl.getBoundingClientRect(); const gapL = rect.left, gapR = window.innerWidth - rect.right; if (Math.min(gapL, gapR) <= 16) { if (gapL < gapR) { st.panelEl.style.left = '0px'; st.panelEl.style.right = 'auto'; } else { st.panelEl.style.left = 'auto'; st.panelEl.style.right = '0px'; } } const s2 = store.get(st.storeKey, {}) || {}; const r2 = st.panelEl.getBoundingClientRect(); s2.x = Math.round(r2.left); s2.y = Math.round(r2.top); store.set(st.storeKey, s2); } } catch (e) {} _dragShared.active = null; }; window.addEventListener('mousemove', _feed); /* 触屏(5.6):touchmove 复用同一状态机;touchstart 由下方委托监听启动拖拽 */ window.addEventListener('touchmove', (e) => { const st = _dragShared.active; if (!st || !st.dragging) return; if (e.cancelable) { try { e.preventDefault(); } catch (err) {} } _feed(e); }, { passive: false }); window.addEventListener('touchend', _end, { passive: true }); window.addEventListener('touchcancel', _end, { passive: true }); if (!window.__ocdTouchDragBound) { window.__ocdTouchDragBound = true; /* 委托:长按 / 触摸面板标题栏即开始拖动(与鼠标行为一致) */ document.addEventListener('touchstart', (e) => { try { const touch = e.touches && e.touches[0]; if (!touch) return; const head = e.target && e.target.closest ? e.target.closest('[id$="-head"], .ocd-head, [data-ocd-drag]') : null; if (!head) return; if (e.target.closest('button, input, select, textarea, details, summary')) return; const panelEl = head.closest('[data-ocd-panel], [data-ocd-view], #ocd-settings-center, .ocd-util-pop'); if (!panelEl) return; const rect = panelEl.getBoundingClientRect(); _dragShared.active = { dragging: true, moved: false, sx: touch.clientX, sy: touch.clientY, fx: rect.left, fy: rect.top, w: rect.width, h: rect.height, panelEl: panelEl, storeKey: 'ocd_helper_panel_pos_' + (panelEl.id || 'anon'), }; dlog('[Touch] 开始拖动面板:' + (panelEl.id || 'anon')); } catch (err) {} }, { passive: true }); } window.addEventListener('mouseup', _end); } function makeDraggable(handleEl, storeKey, panelEl) { _ensureDragListener(); handleEl.classList.add('ocd-drag-handle'); handleEl.addEventListener('mousedown', (e) => { if (e.target.closest('button, input, select, textarea, details, summary')) return; const rect = panelEl.getBoundingClientRect(); _dragShared.active = { dragging: true, moved: false, sx: e.clientX, sy: e.clientY, fx: rect.left, fy: rect.top, w: rect.width, h: rect.height, panelEl, storeKey, }; e.preventDefault(); }); const restore = () => { const s = store.get(storeKey, {}) || {}; if (s.x != null && panelEl.isConnected) { const rect = panelEl.getBoundingClientRect(); panelEl.style.left = Math.max(0, Math.min(window.innerWidth - rect.width, s.x)) + 'px'; panelEl.style.top = Math.max(0, Math.min(window.innerHeight - 30, s.y)) + 'px'; panelEl.style.right = 'auto'; } }; requestAnimationFrame(restore); } function injectDragCss() { if (document.getElementById('ocd-drag-css')) return; const css = document.createElement('style'); css.id = 'ocd-drag-css'; css.textContent = ` .ocd-drag-handle{ cursor: move !important; } .ocd-drag-handle *{ cursor: inherit !important; } .ocd-drag-handle [id$="-close"], .ocd-drag-handle [id$="_close"], .ocd-drag-handle button, .ocd-drag-handle [style*="cursor:pointer"], .ocd-drag-handle [style*="cursor: pointer"]{ cursor: pointer !important; } .ocd-drag-handle:active{ cursor: move !important; } .ocd-dragging{ will-change: left, top; outline: 2px solid var(--accent, #5b8cff) !important; outline-offset: -1px; box-shadow: 0 0 18px var(--ring, rgba(91,140,255,.45)), 0 12px 40px rgba(0,0,0,.5) !important; transform: scale(1.01); transition: none; user-select: none; opacity: .96; z-index: 10000100 !important; } .ocd-dragging .ocd-drag-handle{ background: var(--bg-soft, rgba(255,255,255,.08)); } `; document.head.appendChild(css); } const _resizeShared = { active: null }; /* V9.76.17(用户 m13487 ④):拖动边缘时给一条彩色提示条 —— 横向拖动(调高度)= 贴着面板底边的横条;纵向拖动(调宽度)= 贴着面板左边的竖条 ✓ */ function _resizeStripFor(mode, rect) { let el = document.getElementById('ocd-resize-strip'); if (!mode || !rect) { if (el) el.remove(); return; } if (!el) { el = document.createElement('div'); el.id = 'ocd-resize-strip'; document.body.appendChild(el); } if (mode === 'h') { el.style.cssText = 'position:fixed;pointer-events:none;z-index:10000200;border-radius:2px;' + 'left:' + Math.round(rect.left) + 'px;width:' + Math.round(rect.width) + 'px;' + 'top:' + Math.round(rect.bottom - 2) + 'px;height:3px;' + 'background:linear-gradient(90deg,#5b8cff,#3fd68f);box-shadow:0 0 8px rgba(91,140,255,.85);'; } else { el.style.cssText = 'position:fixed;pointer-events:none;z-index:10000200;border-radius:2px;' + 'top:' + Math.round(rect.top) + 'px;height:' + Math.round(rect.height) + 'px;' + 'left:' + Math.round(rect.left - 2) + 'px;width:3px;' + 'background:linear-gradient(180deg,#5b8cff,#3fd68f);box-shadow:0 0 8px rgba(91,140,255,.85);'; } } /* 面板的最小高度 = 标题栏高度 + 上下余量(这样能一直缩到「只剩标题栏」✓ 用户 m13487 ④), 取不到标题栏时退回 fallback / 44px 保底 ✓ */ function _panelMinH(panel, fallback) { let h = 0; try { panel.querySelectorAll('[id$="-head"], .ocd-panel-head, .ocd-head, .ocd-drag-handle').forEach((el) => { const r = el.getBoundingClientRect(); if (r && r.height > h) h = r.height; }); } catch (e) {} const fb = Number(fallback) || 0; return Math.round(Math.max(h + 18, fb, 44)); } function _ensureResizeListener() { if (_resizeShared.bound) return; _resizeShared.bound = true; window.addEventListener('mousemove', (e) => { const st = _resizeShared.active; if (!st) return; if (st.mode === 'h') { const maxH = (window.innerHeight - 24); const h = Math.max(st.minH, Math.min(maxH, st.startH + (e.clientY - st.ry))); if (st.opts.applyH) st.opts.applyH(h); else { st.opts.getPanel().style.height = h + 'px'; st.opts.getPanel().style.maxHeight = Math.round(window.innerHeight - 16) + 'px'; } _resizeStripFor('h', st.opts.getPanel().getBoundingClientRect()); return; } const w = Math.max(st.opts.min, Math.min(st.opts.max, st.startW + (st.rx - e.clientX))); if (st.opts.applyVal) st.opts.applyVal(w); else st.opts.getPanel().style.width = w + 'px'; _resizeStripFor('w', st.opts.getPanel().getBoundingClientRect()); }); window.addEventListener('mouseup', () => { const st = _resizeShared.active; if (!st) return; _resizeShared.active = null; _resizeStripFor(null, null); const r = st.opts.getPanel().getBoundingClientRect(); if (st.mode === 'h') { if (st.opts.keyH) store.set(st.opts.keyH, Math.round(r.height)); } else if (st.opts.key) store.set(st.opts.key, Math.round(r.width)); }); } /* V9.76.17(用户 m13487 ④):opts.mode = 'w' | 'h' | 'wh'(默认 'wh' 同时给左边缘 + 下边缘) · 'w':左边缘 6px,ew-resize,双击复位 def,存档 key(宽度) · 'h':下边缘 6px,ns-resize,下限 = 标题栏高度 + 18(可缩到只剩标题栏 ✓), 双击恢复 defH(没给则清空高度回到 CSS 默认),存档 keyH(高度)✓ */ function makeEdgeResize(anchorEl, opts) { _ensureResizeListener(); const o = opts || {}; const mode = String(o.mode || 'wh'); if (mode.indexOf('w') >= 0) { const edge = document.createElement('div'); edge.title = '拖动调整宽度(双击恢复默认宽度)'; edge.style.cssText = 'position:absolute;left:0;top:0;bottom:0;width:6px;cursor:ew-resize;z-index:30;'; anchorEl.appendChild(edge); edge.addEventListener('mousedown', (e) => { _resizeShared.active = { mode: 'w', rx: e.clientX, startW: o.getPanel().getBoundingClientRect().width, opts: o, }; _resizeStripFor('w', o.getPanel().getBoundingClientRect()); e.preventDefault(); e.stopPropagation(); }); edge.addEventListener('dblclick', () => { if (o.applyVal) o.applyVal(o.def); else o.getPanel().style.width = o.def + 'px'; if (o.key) store.set(o.key, 0); }); const savedW = o.key ? Number(store.get(o.key, 0)) : 0; if (savedW >= o.min && savedW <= o.max) { if (o.applyVal) o.applyVal(savedW); else o.getPanel().style.width = savedW + 'px'; } } if (mode.indexOf('h') >= 0) { const bedge = document.createElement('div'); bedge.title = '拖动调整高度(可缩到只剩标题栏;双击恢复默认高度)'; bedge.style.cssText = 'position:absolute;left:0;right:0;bottom:0;height:6px;cursor:ns-resize;z-index:30;'; anchorEl.appendChild(bedge); bedge.addEventListener('mousedown', (e) => { const p = o.getPanel(); _resizeShared.active = { mode: 'h', ry: e.clientY, startH: p.getBoundingClientRect().height, minH: _panelMinH(p, o.minH), opts: o, }; _resizeStripFor('h', p.getBoundingClientRect()); e.preventDefault(); e.stopPropagation(); }); bedge.addEventListener('dblclick', () => { const p = o.getPanel(); const d = Number(o.defH) || 0; if (d > 0) { p.style.height = d + 'px'; p.style.maxHeight = Math.round(window.innerHeight - 16) + 'px'; } else { p.style.height = ''; p.style.maxHeight = ''; } if (o.keyH) store.set(o.keyH, 0); }); const p0 = o.getPanel(); const savedH = o.keyH ? Number(store.get(o.keyH, 0)) : 0; if (savedH >= _panelMinH(p0, o.minH) && savedH <= window.innerHeight - 24) { p0.style.height = savedH + 'px'; p0.style.maxHeight = Math.round(window.innerHeight - 16) + 'px'; } } return anchorEl; } /* A3 (ported from V10.7.0): widen the dup-check panel to fit the longest hit title. Width only -- never writes height, so collapse/drag behaviour is untouched. */ function dcAutoWiden() { try { const p = document.getElementById("ocd-dc-panel"); if (!p) return; const list = Array.from(p.querySelectorAll('a[href*="plugin_details.php"]')) .map((a) => (a.textContent || "").trim()).filter(Boolean); if (!list.length) return; let widest = 0; list.forEach((str) => { let w = 0; for (const ch of str) w += /[\u4e00-\u9fa5\u3000-\u303f\uff00-\uffef]/.test(ch) ? 13 : 7.2; if (w > widest) widest = w; }); const need = Math.round(widest + 150); const cap = Math.round(window.innerWidth * 0.92); const target = Math.max(360, Math.min(need, cap)); const cur = Math.round(p.getBoundingClientRect().width || 0); if (target > cur + 12) { p.style.width = target + "px"; p.style.maxWidth = "92vw"; console.log("[DupCheck] auto-widen: " + cur + " -> " + target + "px"); } } catch (e) {} } /* A3: lightweight scheduler -- width only, 600ms, never touches height. */ try { setInterval(function () { try { dcAutoWiden(); } catch (e) {} }, 600); } catch (e) {} function injectSiteIsolationCss() { if (document.getElementById('ocd-site-iso-css')) return; const css = document.createElement('style'); css.id = 'ocd-site-iso-css'; css.textContent = ` #ocd-batch-panel input, #ocd-batch-panel select, #ocd-batch-panel textarea, #ocd-helper-panel input, #ocd-helper-panel select, #ocd-helper-panel textarea, #ocd-probe-panel input, #ocd-probe-panel select, #ocd-probe-panel textarea, #ocd-dp-panel input, #ocd-dp-panel select, #ocd-dp-panel textarea, #ocd-dc-panel input, #ocd-dc-panel select, #ocd-dc-panel textarea, #ocd-settings-center input, #ocd-settings-center select, #ocd-batch-panel button, #ocd-helper-panel button, #ocd-probe-panel button, #ocd-dp-panel button, #ocd-dc-panel button{ box-sizing: border-box !important; min-width: 0 !important; max-width: 100% !important; } #ocd-batch-panel select, #ocd-batch-panel input[type="text"], #ocd-helper-panel select, #ocd-helper-panel input[type="text"], #ocd-probe-panel select, #ocd-probe-panel input[type="text"], #ocd-dp-panel select, #ocd-dp-panel input[type="text"], #ocd-dc-panel select, #ocd-dc-panel input[type="text"], #ocd-settings-center select, #ocd-settings-center input[type="text"], #ocd-probe-panel button, #ocd-dp-panel button, #ocd-dc-panel button{ width: auto !important; } #ocd-batch-panel #ocd-batch-input, #ocd-helper-panel #ocd-input, #ocd-helper-panel #ocd-dup-input, #ocd-dc-panel #dc_input, #ocd-probe-panel #ps_kw, #ocd-dp-panel #dp_pub_kw, #ocd-batch-panel #ocd-batch-filter, #ocd-probe-panel #ocd-probe-out{ width: 100% !important; } #ocd-batch-speed{ flex: 0 0 auto; } #ocd-bf-plat{ max-width: 150px !important; } #ocd-batch-panel, #ocd-helper-panel, #ocd-probe-panel, #ocd-dp-panel, #ocd-dc-panel{ overflow-x: hidden; } #ocd-batch-panel button, #ocd-helper-panel button, #ocd-probe-panel button, #ocd-dp-panel button, #ocd-dc-panel button, #ocd-settings-center button{ border-style: solid !important; border-width: 1px !important; border-radius: 0 !important; outline: none !important; text-shadow: none !important; text-decoration: none !important; letter-spacing: normal !important; line-height: 1.5 !important; } #ocd-helper-panel #ocd-fill, #ocd-helper-panel #ocd-multi-btn, #ocd-helper-panel #ocd-multi-run, #ocd-helper-panel #ocd-multi-retry, #ocd-helper-panel #ocd-settings button, #ocd-batch-panel .ocd-bm-primary{ border: none !important; border-radius: var(--ocd-btn-radius, 8px) !important; } #ocd-helper-panel #ocd-head span, #ocd-batch-panel #ocd-batch-head span{ border: none !important; padding: 0 !important; background: transparent !important; } #ocd-dc-panel #dc_head button, #ocd-dp-panel #dp_head button, #ocd-probe-panel #ps_head button{ padding: 2px 8px !important; border-style: solid !important; border-width: 1px !important; } #ocd-dc-panel button, #ocd-dp-panel button, #ocd-probe-panel button{ font-size: 12px !important; font-weight: 600 !important; cursor: pointer !important; } #ocd-dc-panel button[style*="cursor:pointer"], #ocd-dc-panel button[style*="cursor: pointer"], #ocd-dp-panel button[style*="cursor:pointer"], #ocd-dp-panel button[style*="cursor: pointer"], #ocd-probe-panel button[style*="cursor:pointer"], #ocd-probe-panel button[style*="cursor: pointer"]{ cursor: pointer !important; } #ocd-batch-panel button:hover, #ocd-helper-panel button:hover, #ocd-probe-panel button:hover, #ocd-dp-panel button:hover, #ocd-dc-panel button:hover, #ocd-settings-center button:hover{ border-color: var(--accent, #5b8cff) !important; color: var(--accent, #5b8cff) !important; background: var(--bg-soft, rgba(255,255,255,.08)) !important; transition: border-color .12s, color .12s, background .12s; } #ocd-batch-panel .ocd-bm-primary:hover, #ocd-helper-panel #ocd-fill:hover, #ocd-helper-panel #ocd-multi-btn:hover, #ocd-helper-panel #ocd-multi-run:hover, #ocd-helper-panel #ocd-multi-retry:hover, #ocd-helper-panel #ocd-settings button:hover{ background: linear-gradient(135deg, var(--accent, #5b8cff), var(--accent2, #8a6bff)) !important; filter: brightness(1.12); color: var(--btn-fg, #fff) !important; } #ocd-batch-panel .ocd-bt-del:hover, #ocd-batch-panel .ocd-bm-danger:hover, #ocd-batch-panel [data-bop="del"]:hover, #ocd-batch-panel #ocd-batch-clear:hover, #ocd-probe-panel #ps_cledl:hover, #ocd-dp-panel #dp_clean:hover{ background: var(--err, #ff6b6b) !important; border-color: var(--err, #ff6b6b) !important; color: #fff !important; } #ocd-batch-panel .ocd-bm-go:hover, #ocd-batch-panel .ocd-bt-pub:hover, #ocd-batch-panel .ocd-bt-rev:hover, #ocd-batch-panel .ocd-selall:hover{ border-color: var(--ok, #3fd68f) !important; color: var(--ok, #3fd68f) !important; background: rgba(63, 214, 143, .12) !important; } #ocd-batch-panel .ocd-bm-warn:hover, #ocd-batch-panel .ocd-bt-retry:hover, #ocd-dc-panel #dc_stop:hover{ border-color: #c8a415 !important; color: #c8a415 !important; background: rgba(200, 164, 21, .12) !important; } .ocd-stat-chip:hover{ background: var(--bg-soft, rgba(255,255,255,.08)) !important; filter: brightness(1.15); } .ocd-batch-ck:hover{ transform: scale(1.2); transition: transform .1s; } #ocd-sp-tagpop span[data-tag]:hover, #ocd-tag-pop span[data-tag]:hover{ background: var(--bg-soft, rgba(255,255,255,.08)) !important; } #ocd-helper-panel #ocd-head span:hover, #ocd-batch-panel #ocd-batch-head span:hover{ background: var(--bg-soft, rgba(255,255,255,.08)); } #ocd-probe-panel .ps-btn, #ocd-dp-panel .ps-btn, #ocd-dc-panel .ps-btn{ font-weight: 400 !important; font-size: 11px !important; } #ocd-dc-panel #dc_run, #ocd-dp-panel #dp_collect{ font-weight: 700 !important; } .ocd-pin-btn.pinned{ opacity:1 !important; filter: drop-shadow(0 0 3px var(--accent, #5b8cff)); } `; document.head.appendChild(css); } /* ================================================================ * §45 设置中心 * ================================================================ */ function applyFabDiyTemp(cfg) { try { refreshFab(cfg); saveFabDiy(cfg); } catch (e) { console.warn('[FabDIY] 实时应用异常:', e.message || e); } } function buildFabDiyPanel(box) { const cfg = getFabDiy(); const mkColor = (label, key) => { const w = document.createElement('div'); w.style.cssText = 'display:flex;align-items:center;gap:6px;margin:4px 0;'; const lb = document.createElement('span'); lb.style.cssText = 'flex:0 0 78px;font-size:11px;color:var(--fg-muted,#9aa3b2);text-align:right;'; lb.textContent = label; const ip = document.createElement('input'); ip.type = 'color'; const raw = String(cfg[key] || '#5b8cff').trim(); const rgbaM = raw.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)$/i); if (rgbaM) { ip.value = '#' + [1, 2, 3].map(i => Number(rgbaM[i]).toString(16).padStart(2, '0')).join(''); cfg[key + 'A'] = rgbaM[4] != null ? Number(rgbaM[4]) : 1; } else if (/^#[0-9a-fA-F]{6}$/.test(raw)) { ip.value = raw; } else { ip.value = '#5b8cff'; } ip.style.cssText = 'width:36px;height:24px;padding:0;border:1px solid var(--border,#444);border-radius:5px;background:none;cursor:pointer;'; w.appendChild(lb); if (rgbaM) { const ar = document.createElement('input'); ar.type = 'range'; ar.min = '0'; ar.max = '1'; ar.step = '0.05'; ar.value = String(cfg[key + 'A']); ar.title = '不透明度'; ar.style.cssText = 'flex:0 0 60px;'; ar.addEventListener('input', () => { cfg[key + 'A'] = Number(ar.value); }); w.appendChild(ar); } w.appendChild(ip); box.appendChild(w); return ip; }; const mkRange = (label, key, min, max, step, suffix) => { const w = document.createElement('div'); w.style.cssText = 'display:flex;align-items:center;gap:6px;margin:4px 0;'; const lb = document.createElement('span'); lb.style.cssText = 'flex:0 0 78px;font-size:11px;color:var(--fg-muted,#9aa3b2);text-align:right;'; lb.textContent = label; const rg = document.createElement('input'); rg.type = 'range'; rg.min = min; rg.max = max; rg.step = step; rg.value = cfg[key] != null ? cfg[key] : min; rg.style.cssText = 'flex:1;'; const num = document.createElement('span'); num.style.cssText = 'flex:0 0 44px;font-size:11px;color:var(--fg,#e8eaf0);'; const sync = () => { num.textContent = rg.value + (suffix || ''); }; rg.addEventListener('input', () => { sync(); }); sync(); w.appendChild(lb); w.appendChild(rg); w.appendChild(num); box.appendChild(w); return rg; }; const mkText = (label, key, ph) => { const w = document.createElement('div'); w.style.cssText = 'display:flex;align-items:center;gap:6px;margin:4px 0;'; const lb = document.createElement('span'); lb.style.cssText = 'flex:0 0 78px;font-size:11px;color:var(--fg-muted,#9aa3b2);text-align:right;'; lb.textContent = label; const ip = document.createElement('input'); ip.type = 'text'; ip.value = cfg[key] || ''; ip.placeholder = ph || ''; ip.style.cssText = 'flex:1;box-sizing:border-box;padding:3px 6px;border:1px solid var(--border,#444);border-radius:5px;background:var(--input-bg,#2a303c);color:var(--fg,#e8eaf0);'; w.appendChild(lb); w.appendChild(ip); box.appendChild(w); return ip; }; const mkChkRow = (label, key) => { const w = document.createElement('label'); w.style.cssText = 'display:flex;align-items:center;gap:6px;margin:4px 0;cursor:pointer;font-size:12px;'; const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !!cfg[key]; w.appendChild(cb); w.appendChild(document.createTextNode(label)); box.appendChild(w); return cb; }; const pTitle = document.createElement('div'); pTitle.style.cssText = 'font-weight:700;margin:2px 0 4px;display:flex;justify-content:space-between;align-items:center;'; pTitle.innerHTML = '预设风格' + ''; box.appendChild(pTitle); const curPresetName = (() => { const cur = getFabDiy(); const match = FAB_DIY_PRESETS.find(p => JSON.stringify(p.cfg) === JSON.stringify(cur)); return match ? match.name : ''; })(); const presetPop = document.createElement('div'); presetPop.style.cssText = 'display:none;margin-top:6px;border:1px solid var(--border,#444);border-radius:9px;padding:8px;background:var(--input-bg,#1a1f2a)'; const grid = document.createElement('div'); grid.style.cssText = 'display:grid;grid-template-columns:repeat(4,1fr);gap:5px;'; FAB_DIY_PRESETS.forEach((p, pi) => { const cell = document.createElement('button'); const isCur = (p.name === curPresetName); cell.textContent = p.name; cell.title = '点击套用「' + p.name + '」风格'; cell.style.cssText = [ 'padding:8px 2px', 'cursor:pointer', 'font-size:10.5px', 'border-radius:7px', 'text-align:center', 'border:1px solid ' + (isCur ? 'var(--accent,#5b8cff)' : 'var(--border,#444)'), 'color:' + (isCur ? 'var(--accent,#5b8cff)' : 'var(--fg,#e8eaf0)'), 'background:var(--bg-soft,#31384a)', 'font-weight:' + (isCur ? 700 : 400), 'transition: border-color .15s, color .15s', 'overflow:hidden; text-overflow:ellipsis; white-space:nowrap;' ].join(';'); if (isCur) cell.textContent = '✓ ' + p.name; cell.addEventListener('click', () => { saveFabDiy(JSON.parse(JSON.stringify(p.cfg))); Object.assign(liveCfg, p.cfg); refreshFab(p.cfg); presetPop.style.display = 'none'; const btn = box.querySelector('#ocd-fabdiy-preset-btn'); if (btn) btn.innerHTML = '📂 预设风格列表(' + FAB_DIY_PRESETS.length + ')· ' + esc(p.name); }); grid.appendChild(cell); }); presetPop.appendChild(grid); box.appendChild(presetPop); box.querySelector('#ocd-fabdiy-preset-btn').addEventListener('click', () => { const open = presetPop.style.display !== 'block'; presetPop.style.display = open ? 'block' : 'none'; if (open) { const curCell = grid.querySelector('button[style*="font-weight:700"], button[style*="font-weight: 700"]'); if (curCell) curCell.scrollIntoView({ block: 'nearest' }); } }); const hr = () => { const h = document.createElement('div'); h.style.cssText = 'border-top:1px dashed var(--border,#444);margin:8px 0;'; box.appendChild(h); }; hr(); const liveCfg = Object.assign({}, cfg); const bgTypeSel = document.createElement('select'); bgTypeSel.style.cssText = 'margin:4px 0;padding:3px 6px;border-radius:5px;border:1px solid var(--border,#444);background:var(--input-bg,#2a303c);color:var(--fg,#e8eaf0);'; bgTypeSel.innerHTML = ''; bgTypeSel.value = cfg.bgType || 'gradient'; box.appendChild(bgTypeSel); const c1 = mkColor('底色 1:', 'bgColor1'); const c2 = mkColor('底色 2:', 'bgColor2'); const ang = mkRange('渐变角度:', 'bgAngle', 0, 360, 15, '°'); const syncBg = () => { Object.assign(liveCfg, { bgType: bgTypeSel.value, bgColor1: c1.value, bgColor2: c2.value, bgAngle: Number(ang.value) }); applyFabDiyTemp(Object.assign({}, liveCfg)); }; [bgTypeSel, c1, c2].forEach(el => el.addEventListener('input', syncBg)); ang.addEventListener('input', syncBg); hr(); const bw = mkRange('描边宽度:', 'borderWidth', 0, 6, 1, 'px'); const bc = mkColor('描边颜色:', 'borderColor'); const syncBorder = () => { Object.assign(liveCfg, { borderWidth: Number(bw.value), borderColor: bc.value }); applyFabDiyTemp(Object.assign({}, liveCfg)); }; bw.addEventListener('input', syncBorder); bc.addEventListener('input', syncBorder); hr(); const haloChk = mkChkRow('外发光光环', 'haloOn'); const hc = mkColor('光环颜色:', 'haloColor'); const hs = mkRange('光环强度:', 'haloSize', 0, 30, 1, 'px'); const hm = document.createElement('select'); hm.style.cssText = 'margin:4px 0 4px 78px;padding:3px 6px;border-radius:5px;border:1px solid var(--border,#444);background:var(--input-bg,#2a303c);color:var(--fg,#e8eaf0);'; hm.innerHTML = ''; hm.value = cfg.haloMode || 'static'; const hsp = mkRange('光环速度:', 'haloSpeed', 0.5, 3, 0.1, 'x'); const syncHalo = () => { hsp.disabled = (hm.value === 'static'); Object.assign(liveCfg, { haloOn: haloChk.checked, haloColor: hc.value, haloSize: Number(hs.value), haloMode: hm.value, haloSpeed: Number(hsp.value) }); applyFabDiyTemp(Object.assign({}, liveCfg)); }; [haloChk, hc, hs, hm, hsp].forEach(el => { el.addEventListener(el.tagName === 'INPUT' && el.type === 'checkbox' ? 'change' : 'input', syncHalo); }); hr(); const swChk = mkChkRow('白光扫过', 'sweepOn'); const sc = mkColor('扫光颜色:', 'sweepColor'); const ssp = mkRange('扫光速度:', 'sweepSpeed', 0.5, 5, 0.1, 's'); const ssw = mkRange('扫光宽度:', 'sweepWidth', 20, 120, 5, 'px'); const ssh = mkRange('扫光高度:', 'sweepHeight', 60, 300, 10, '%'); const sang = mkRange('扫过角度:', 'sweepAngle', -45, 45, 1, '°'); const strail = document.createElement('select'); strail.style.cssText = 'margin:4px 0 4px 78px;padding:3px 6px;border-radius:5px;border:1px solid var(--border,#444);background:var(--input-bg,#2a303c);color:var(--fg,#e8eaf0);'; strail.innerHTML = ''; strail.value = cfg.sweepTrail || 'one'; box.appendChild(strail); const soff = mkRange('起始偏移:', 'sweepOffset', -30, 30, 5, '%'); const sox = mkRange('左右偏移:', 'sweepOffsetX', -30, 30, 5, '%'); const speak = mkRange('亮度峰值:', 'sweepPeak', 0.2, 1, 0.05, ''); const syncSweep = () => { Object.assign(liveCfg, { sweepOn: swChk.checked, sweepColor: sc.value, sweepSpeed: Number(ssp.value), sweepWidth: Number(ssw.value), sweepHeight: Number(ssh.value), sweepAngle: Number(sang.value), sweepTrail: strail.value, sweepOffset: Number(soff.value), sweepOffsetX: Number(sox.value), sweepPeak: Number(speak.value), }); applyFabDiyTemp(Object.assign({}, liveCfg)); }; [swChk, sc, ssp, ssw, ssh, sang, strail, soff, sox, speak].forEach(el => { el.addEventListener(el.tagName === 'INPUT' && el.type === 'checkbox' ? 'change' : 'input', syncSweep); }); hr(); const l1 = mkText('上行文字:', 'line1', '🎵 或任意文字'); const l1s = mkRange('上行字号:', 'line1Size', 10, 28, 1, 'px'); const l1c = mkColor('上行颜色:', 'line1Color'); const l2 = mkText('下行文字:', 'line2', '留空则不显示'); const l2s = mkRange('下行字号:', 'line2Size', 6, 16, 1, 'px'); const l2c = mkColor('下行颜色:', 'line2Color'); const lgap = mkRange('行间距:', 'lineGap', 0, 8, 1, 'px'); const syncText = () => { Object.assign(liveCfg, { line1: l1.value, line1Size: Number(l1s.value), line1Color: l1c.value, line2: l2.value, line2Size: Number(l2s.value), line2Color: l2c.value, lineGap: Number(lgap.value), }); applyFabDiyTemp(Object.assign({}, liveCfg)); }; [l1, l1s, l1c, l2, l2s, l2c, lgap].forEach(el => el.addEventListener('input', syncText)); hr(); const brChk = mkChkRow('呼吸缩放', 'breathOn'); const brs = mkRange('呼吸速度:', 'breathSpeed', 0.6, 4, 0.1, 's'); const bra = mkRange('呼吸幅度:', 'breathAmp', 2, 12, 1, '%'); const wbChk = mkChkRow('悬浮摇摆', 'wobbleOn'); const syncMotion = () => { Object.assign(liveCfg, { breathOn: brChk.checked, breathSpeed: Number(brs.value), breathAmp: Number(bra.value), wobbleOn: wbChk.checked }); applyFabDiyTemp(Object.assign({}, liveCfg)); }; [brChk, brs, bra, wbChk].forEach(el => { el.addEventListener(el.tagName === 'INPUT' && el.type === 'checkbox' ? 'change' : 'input', syncMotion); }); hr(); const shapeRow = document.createElement('div'); shapeRow.style.cssText = 'display:flex;align-items:center;gap:6px;margin:4px 0;'; const shapeLb = document.createElement('span'); shapeLb.style.cssText = 'flex:0 0 78px;font-size:11px;color:var(--fg-muted,#9aa3b2);text-align:right;'; shapeLb.textContent = '形状:'; const shapeSel = document.createElement('select'); shapeSel.style.cssText = 'padding:3px 6px;border-radius:5px;border:1px solid var(--border,#444);background:var(--input-bg,#2a303c);color:var(--fg,#e8eaf0);'; shapeSel.innerHTML = ''; shapeSel.value = cfg.shape || 'circle'; shapeSel.addEventListener('change', () => { liveCfg.shape = shapeSel.value; applyFabDiyTemp(Object.assign({}, liveCfg)); }); shapeRow.appendChild(shapeLb); shapeRow.appendChild(shapeSel); box.appendChild(shapeRow); hr(); const fbTitle = document.createElement('div'); fbTitle.style.cssText = 'font-weight:700;margin:4px 0;'; fbTitle.textContent = '流光边框'; box.appendChild(fbTitle); const fbChk = mkChkRow('启用流光旋转边框', 'flowBorderOn'); const fb1 = mkColor('流光色 1:', 'flowColors1'); const fb2 = mkColor('流光色 2:', 'flowColors2'); const fb3 = mkColor('流光色 3:', 'flowColors3'); const fbSp = mkRange('旋转速度:', 'flowSpeed', 1, 8, 0.2, 's'); const fcVal = (i) => (Array.isArray(liveCfg.flowColors) ? liveCfg.flowColors[i] : (Array.isArray(cfg.flowColors) ? cfg.flowColors[i] : '')); fb1.value = /^#[0-9a-fA-F]{6}$/.test(fcVal(0) || '') ? fcVal(0) : '#5b8cff'; fb2.value = /^#[0-9a-fA-F]{6}$/.test(fcVal(1) || '') ? fcVal(1) : '#c58bff'; fb3.value = /^#[0-9a-fA-F]{6}$/.test(fcVal(2) || '') ? fcVal(2) : '#7dd8ff'; const syncFB = () => { Object.assign(liveCfg, { flowBorderOn: fbChk.checked, flowSpeed: Number(fbSp.value), flowColors: [fb1.value, fb2.value, fb3.value] }); applyFabDiyTemp(Object.assign({}, liveCfg)); }; [fbChk, fb1, fb2, fb3, fbSp].forEach(el => { el.addEventListener(el.tagName === 'INPUT' && el.type === 'checkbox' ? 'change' : 'input', syncFB); }); hr(); const paTitle = document.createElement('div'); paTitle.style.cssText = 'font-weight:700;margin:4px 0;'; paTitle.textContent = '粒子环绕'; box.appendChild(paTitle); const paChk = mkChkRow('启用环绕粒子', 'particleOn'); const paC = mkColor('粒子颜色:', 'particleColor'); const paN = mkRange('粒子数量:', 'particleCount', 1, 6, 1, '颗'); const paSp = mkRange('环绕速度:', 'particleSpeed', 3, 12, 0.5, 's'); const paR = mkRange('轨道半径:', 'particleRadius', 26, 40, 1, 'px'); const syncPA = () => { Object.assign(liveCfg, { particleOn: paChk.checked, particleColor: paC.value, particleCount: Number(paN.value), particleSpeed: Number(paSp.value), particleRadius: Number(paR.value) }); applyFabDiyTemp(Object.assign({}, liveCfg)); }; [paChk, paC, paN, paSp, paR].forEach(el => { el.addEventListener(el.tagName === 'INPUT' && el.type === 'checkbox' ? 'change' : 'input', syncPA); }); hr(); const texTitle = document.createElement('div'); texTitle.style.cssText = 'font-weight:700;margin:4px 0;'; texTitle.textContent = '背景纹理'; box.appendChild(texTitle); const texSel = document.createElement('select'); texSel.style.cssText = 'margin:4px 0 4px 78px;padding:3px 6px;border-radius:5px;border:1px solid var(--border,#444);background:var(--input-bg,#2a303c);color:var(--fg,#e8eaf0);'; texSel.innerHTML = ''; texSel.value = cfg.texture || 'none'; box.appendChild(texSel); const texOp = mkRange('纹理浓度:', 'textureOpacity', 0.05, 0.6, 0.05, ''); const syncTex = () => { Object.assign(liveCfg, { texture: texSel.value, textureOpacity: Number(texOp.value) }); applyFabDiyTemp(Object.assign({}, liveCfg)); }; [texSel, texOp].forEach(el => el.addEventListener('input', syncTex)); texSel.addEventListener('change', syncTex); hr(); const cfTitle = document.createElement('div'); cfTitle.style.cssText = 'font-weight:700;margin:4px 0;'; cfTitle.textContent = '点击效果'; box.appendChild(cfTitle); const cfxSel = document.createElement('select'); cfxSel.style.cssText = 'margin:4px 0 4px 78px;padding:3px 6px;border-radius:5px;border:1px solid var(--border,#444);background:var(--input-bg,#2a303c);color:var(--fg,#e8eaf0);'; cfxSel.innerHTML = '' + '' + '' + '' + ''; cfxSel.value = liveCfg.clickFx || 'none'; box.appendChild(cfxSel); const cfdur = mkRange('效果速度:', 'clickFxDur', 0.5, 2, 0.1, 'x'); const cfc = mkColor('效果颜色:', 'clickFxColor'); const syncCFX = () => { Object.assign(liveCfg, { clickFx: cfxSel.value, clickFxDur: Number(cfdur.value), clickFxColor: cfc.value }); applyFabDiyTemp(Object.assign({}, liveCfg)); }; [cfxSel, cfdur, cfc].forEach(el => el.addEventListener('input', syncCFX)); cfxSel.addEventListener('change', syncCFX); hr(); const lkTitle = document.createElement('div'); lkTitle.style.cssText = 'font-weight:700;margin:4px 0;'; lkTitle.textContent = '点击联动'; const lkHint = document.createElement('div'); lkHint.style.cssText = 'color:var(--fg-muted,#9aa3b2);font-size:10px;margin-bottom:4px;'; lkHint.textContent = '点击悬浮球时触发的联动反馈;粒子/扫光联动需先开启对应功能'; box.appendChild(lkTitle); box.appendChild(lkHint); const lkRipple = mkChkRow('联动:液体泛波纹(常驻可用)', 'linkRipple'); const lkParticle = mkChkRow('联动:粒子加速旋转(需开启「粒子环绕」)', 'linkParticle'); const lkSweep = mkChkRow('联动:扫光加速(需开启「白光扫过」)', 'linkSweep'); const syncLink = () => { Object.assign(liveCfg, { linkRipple: lkRipple.checked, linkParticle: lkParticle.checked, linkSweep: lkSweep.checked }); applyFabDiyTemp(Object.assign({}, liveCfg)); }; [lkRipple, lkParticle, lkSweep].forEach(cb => cb.addEventListener('change', syncLink)); hr(); const icoTitle = document.createElement('div'); icoTitle.style.cssText = 'font-weight:700;margin:4px 0;'; icoTitle.textContent = '图标动效(上行)'; box.appendChild(icoTitle); const icoSel = document.createElement('select'); icoSel.style.cssText = 'margin:4px 0 4px 78px;padding:3px 6px;border-radius:5px;border:1px solid var(--border,#444);background:var(--input-bg,#2a303c);color:var(--fg,#e8eaf0);'; icoSel.innerHTML = ''; icoSel.value = liveCfg.icoFx || 'none'; box.appendChild(icoSel); const icoSp = mkRange('动效速度:', 'icoFxSpeed', 0.5, 3, 0.1, 'x'); const syncIco = () => { Object.assign(liveCfg, { icoFx: icoSel.value, icoFxSpeed: Number(icoSp.value) }); applyFabDiyTemp(Object.assign({}, liveCfg)); }; [icoSel, icoSp].forEach(el => el.addEventListener('input', syncIco)); hr(); const sizeRow = mkRange('球体尺寸:', 'fabSize', 36, 64, 1, 'px'); sizeRow.addEventListener('input', () => { Object.assign(liveCfg, { fabSize: Number(sizeRow.value) }); applyFabDiyTemp(Object.assign({}, liveCfg)); }); hr(); const chTitle = document.createElement('div'); chTitle.style.cssText = 'font-weight:700;margin:4px 0;'; chTitle.textContent = '全息色差(glitch)'; box.appendChild(chTitle); const chChk = mkChkRow('启用色差分离闪烁', 'chromaOn'); const chSp = mkRange('闪烁速度:', 'chromaSpeed', 0.5, 3, 0.1, 'x'); const syncChroma = () => { Object.assign(liveCfg, { chromaOn: chChk.checked, chromaSpeed: Number(chSp.value) }); applyFabDiyTemp(Object.assign({}, liveCfg)); }; [chChk, chSp].forEach(el => { el.addEventListener(el.tagName === 'INPUT' && el.type === 'checkbox' ? 'change' : 'input', syncChroma); }); hr(); } /* 脚本所在目录(用于「使用手册」等本地文件入口;浏览器不允许脚本得知真实路径时用此默认值) */ const SELF_DIR_HINT = 'C:/Program Files/Notepad++/OpenCD 工具箱 脚本'; /* ============ 📌 快捷设置固定项(把任意「可固定设置」引用到快捷设置) ============ */ const QUICKPINS_KEY = 'ocd_helper_quickpins_v1'; function quickPinsGet() { const l = store.get(QUICKPINS_KEY, []) || []; return Array.isArray(l) ? l.filter(x => x && x.id) : []; } function quickPinsSet(list) { try { store.set(QUICKPINS_KEY, (list || []).slice(0, 40)); } catch (e) {} return quickPinsGet(); } function quickPinsAdd(id) { if (!QUICK_FIELDS[id]) { toastSafe('该设置暂不支持固定到快捷设置', 'warn'); return false; } const list = quickPinsGet(); if (list.some(x => x.id === id)) { toastSafe('「' + QUICK_FIELDS[id].label + '」已在快捷设置里', 'info'); return false; } list.push({ id: id, ts: Date.now() }); quickPinsSet(list); try { const h = document.getElementById('ocd-quickpins-host'); if (h) renderQuickPins(h); } catch (e) {} ocdAudit('system', '快捷设置固定项 +1', QUICK_FIELDS[id].label); toastSafe('📌 已固定「' + QUICK_FIELDS[id].label + '」到 ⚡ 快捷设置', 'ok'); return true; } function quickPinsRemove(id) { const list = quickPinsGet().filter(x => x.id !== id); quickPinsSet(list); try { const h = document.getElementById('ocd-quickpins-host'); if (h) renderQuickPins(h); } catch (e) {} ocdAudit('system', '快捷设置固定项 -1', id); return list; } /* 可固定字段表:id → 读取 / 写入 / 展示(覆盖高级设置与模块内联设置里最常用的项) */ const QUICK_FIELDS = { 'adv-subpages': { label: '订阅页数上限', unit: '页', type: 'num', min: 0, max: 500, get: () => advNum('subPages', 6), set: (v) => advSave({ subPages: v }) }, 'adv-subhits': { label: '订阅命中保留', unit: '条', type: 'num', min: 0, max: 20000, get: () => advNum('subHits', 300), set: (v) => advSave({ subHits: v }) }, 'adv-subsoft': { label: '订阅软提醒阈值', unit: '页', type: 'num', min: 0, max: 200, get: () => advNum('subSoftPages', 6), set: (v) => advSave({ subSoftPages: v }) }, 'adv-int-site': { label: '站内搜索间隔', unit: 'ms', type: 'num', min: 0, max: 5000, get: () => (advPrefs().concurrency || {}).siteSearch, set: (v) => advSave({ concurrency: Object.assign({}, advPrefs().concurrency, { siteSearch: v }) }) }, 'adv-int-plat': { label: '平台搜索间隔', unit: 'ms', type: 'num', min: 0, max: 5000, get: () => (advPrefs().concurrency || {}).platformSearch, set: (v) => advSave({ concurrency: Object.assign({}, advPrefs().concurrency, { platformSearch: v }) }) }, 'adv-conc-batch': { label: '批量并发', unit: '个', type: 'num', min: 1, max: 5, get: () => (advPrefs().concurrency || {}).batch, set: (v) => advSave({ concurrency: Object.assign({}, advPrefs().concurrency, { batch: v }) }) }, 'adv-batch-retry': { label: '失败自动重试', unit: '次', type: 'num', min: 0, max: 5, get: () => (advPrefs().batch || {}).retry, set: (v) => advSave({ batch: Object.assign({}, advPrefs().batch, { retry: v }) }) }, 'adv-ask-reward': { label: '默认悬赏', unit: '魔力值', type: 'num', min: 100, max: 1000000, get: () => (advPrefs().ask || {}).reward, set: (v) => advSave({ ask: Object.assign({}, advPrefs().ask, { reward: v }) }) }, 'adv-ask-perpage': { label: '列表每页条数', unit: '条', type: 'num', min: 0, max: 500, get: () => (advPrefs().ask || {}).perPage, set: (v) => advSave({ ask: Object.assign({}, advPrefs().ask, { perPage: v }) }) }, 'adv-pv-h': { label: '预览正文最大高度', unit: 'vh', type: 'num', min: 10, max: 90, get: () => (advPrefs().preview || {}).maxH, set: (v) => advSave({ preview: Object.assign({}, advPrefs().preview, { maxH: v }) }) }, 'adv-pv-cm': { label: '预览评论每页', unit: '条', type: 'num', min: 5, max: 200, get: () => (advPrefs().preview || {}).commentsPerPage, set: (v) => advSave({ preview: Object.assign({}, advPrefs().preview, { commentsPerPage: v }) }) }, 'adv-cache-cap': { label: '缓存条数上限', unit: '条', type: 'num', min: 50, max: 5000, get: () => advNum('cacheCap', 300), set: (v) => advSave({ cacheCap: v }) }, 'adv-ttl-torrent': { label: '种子详情缓存', unit: '分钟', type: 'num', min: 0, max: 1440, get: () => (advPrefs().ttl || {}).torrentInfo, set: (v) => advSave({ ttl: Object.assign({}, advPrefs().ttl, { torrentInfo: v }) }) }, 'adv-ttl-files': { label: '文件列表缓存', unit: '分钟', type: 'num', min: 0, max: 1440, get: () => (advPrefs().ttl || {}).fileList, set: (v) => advSave({ ttl: Object.assign({}, advPrefs().ttl, { fileList: v }) }) }, 'adv-ui-font': { label: '界面字号', unit: 'px', type: 'num', min: 10, max: 20, get: () => (advPrefs().ui || {}).fontPx, set: (v) => advSave({ ui: Object.assign({}, advPrefs().ui, { fontPx: v }) }) }, 'adv-ui-radius': { label: '面板圆角', unit: 'px', type: 'num', min: 0, max: 28, get: () => (advPrefs().ui || {}).radius, set: (v) => advSave({ ui: Object.assign({}, advPrefs().ui, { radius: v }) }) }, 'adv-ui-fab': { label: '悬浮球大小', unit: 'px', type: 'num', min: 28, max: 96, get: () => (advPrefs().ui || {}).fabSize, set: (v) => advSave({ ui: Object.assign({}, advPrefs().ui, { fabSize: v }) }) }, 'adv-nt-magic': { label: '魔力值提醒阈值', unit: '点', type: 'num', min: 0, max: 1000000, get: () => (advPrefs().notify || {}).magicMin, set: (v) => advSave({ notify: Object.assign({}, advPrefs().notify, { magicMin: v }) }) }, 'adv-nt-retain': { label: '通知保留条数', unit: '条', type: 'num', min: 0, max: 2000, get: () => (advPrefs().notify || {}).retain, set: (v) => advSave({ notify: Object.assign({}, advPrefs().notify, { retain: v }) }) }, 'adv-seed-pages': { label: '做种看板页数', unit: '页', type: 'num', min: 1, max: 60, get: () => (advPrefs().seed || {}).pageCap, set: (v) => advSave({ seed: Object.assign({}, advPrefs().seed, { pageCap: v }) }) }, 'adv-seed-dead': { label: '断种阈值', unit: '做种数', type: 'num', min: 0, max: 999, get: () => (advPrefs().seed || {}).deadMax, set: (v) => advSave({ seed: Object.assign({}, advPrefs().seed, { deadMax: v }) }) }, 'adv-to-fetch': { label: '抓取超时', unit: '秒', type: 'num', min: 5, max: 600, get: () => (advPrefs().timeouts || {}).fetch, set: (v) => advSave({ timeouts: Object.assign({}, advPrefs().timeouts, { fetch: v }) }) }, 'adv-log-level': { label: '日志级别', type: 'sel', options: ['error', 'warn', 'info', 'debug'], get: () => (advPrefs().log || {}).level || 'info', set: (v) => advSave({ log: Object.assign({}, advPrefs().log, { level: v }) }) }, 'adv-exp-redact': { label: '导出时打码', type: 'bool', get: () => !!(advPrefs().export || {}).redact, set: (v) => advSave({ export: Object.assign({}, advPrefs().export, { redact: !!v }) }) }, }; /* 生成「一个可固定设置」的控件(快捷设置里使用;与高级设置里的原件读写同一份配置) */ function quickFieldControl(id) { const f = QUICK_FIELDS[id]; if (!f) return null; const wrap = document.createElement('div'); wrap.style.cssText = 'display:flex;align-items:center;gap:8px;margin:4px 0;font-size:11.5px;'; const lab = document.createElement('span'); lab.style.cssText = 'flex:0 0 auto;min-width:118px;color:var(--fg-muted);'; lab.textContent = f.label; wrap.appendChild(lab); let input; if (f.type === 'bool') { input = document.createElement('input'); input.type = 'checkbox'; input.checked = !!f.get(); input.addEventListener('change', () => { f.set(input.checked); toastSafe('已' + (input.checked ? '开启' : '关闭') + '「' + f.label + '」', 'ok'); }); wrap.appendChild(input); } else if (f.type === 'sel') { input = document.createElement('select'); input.style.cssText = 'padding:2px 6px;border:1px solid var(--border,#444);border-radius:6px;background:var(--input-bg,#2a303c);color:var(--fg);font-size:11px;'; (f.options || []).forEach(o => { const op = document.createElement('option'); op.value = o; op.textContent = o; input.appendChild(op); }); input.value = String(f.get() || ''); input.addEventListener('change', () => { f.set(String(input.value)); toastSafe('「' + f.label + '」已设为 ' + input.value, 'ok'); }); wrap.appendChild(input); } else { input = document.createElement('input'); input.type = 'text'; input.style.cssText = 'flex:0 0 92px;box-sizing:border-box;padding:2px 6px;border:1px solid var(--border,#444);border-radius:6px;background:var(--input-bg,#2a303c);color:var(--fg);font-size:11px;'; input.value = String(f.get() === undefined || f.get() === null ? '' : f.get()); const commit = () => { let v = Number(String(input.value).trim()); if (!isFinite(v)) v = f.get(); if (f.min !== undefined) v = Math.max(f.min, v); if (f.max !== undefined) v = Math.min(f.max, v); input.value = String(v); f.set(v); toastSafe('「' + f.label + '」已设为 ' + v + (f.unit ? ' ' + f.unit : ''), 'ok'); }; input.addEventListener('change', commit); wrap.appendChild(input); } if (f.unit) { const u = document.createElement('span'); u.style.cssText = 'color:var(--fg-muted);'; u.textContent = f.unit; wrap.appendChild(u); } const rm = document.createElement('button'); rm.textContent = '✕'; rm.title = '从快捷设置移除'; rm.style.cssText = 'flex:0 0 auto;margin-left:auto;padding:0 7px;cursor:pointer;border:1px solid var(--border,#444);border-radius:6px;background:transparent;color:var(--fg-muted);font-size:11px;'; rm.addEventListener('click', () => { quickPinsRemove(id); const boxQ = document.getElementById('ocd-quickpins-host'); if (boxQ) renderQuickPins(boxQ); toastSafe('已从快捷设置移除「' + f.label + '」', 'info'); }); wrap.appendChild(rm); return wrap; } /* 渲染「固定到快捷设置」区域 */ function renderQuickPins(host) { if (!host) return; const list = quickPinsGet(); host.innerHTML = ''; const tip = document.createElement('div'); tip.style.cssText = 'font-size:11px;color:var(--fg-muted);line-height:1.7;margin:2px 0 4px;'; tip.innerHTML = list.length ? '📌 你固定的设置(与「🎚️ 高级设置」里的原件是同一份配置,改哪边都同步):' : '📌 还没有固定任何设置:在「🎚️ 高级设置」里点设置右侧的 📌 按钮,即可把常用项引用到这里,日常只在这一处改。'; host.appendChild(tip); list.forEach((x) => { const c = quickFieldControl(x.id); if (c) host.appendChild(c); else quickPinsRemove(x.id); }); if (list.length) { const clr = document.createElement('button'); clr.textContent = '清空固定项'; clr.style.cssText = 'margin:6px 0 2px;padding:3px 10px;cursor:pointer;border:1px solid var(--border,#444);border-radius:7px;background:transparent;color:var(--fg-muted);font-size:11px;'; clr.addEventListener('click', async () => { const okc = await shConfirm({ title: '清空快捷设置固定项?', html: '共 ' + quickPinsGet().length + ' 项(不会改动这些设置本身的值)。', buttons: [{ text: '取消', value: false }, { text: '清空', value: true, primary: true }] }); if (!okc) return; quickPinsSet([]); renderQuickPins(host); toastSafe('已清空固定项', 'ok'); }); host.appendChild(clr); } } /* 「📌 固定」按钮(内联到高级设置的每一行) */ function quickPinBtn(fieldId) { if (!QUICK_FIELDS[fieldId]) return null; const b = document.createElement('button'); const pinned = quickPinsGet().some(x => x.id === fieldId); b.textContent = pinned ? '📌' : '📍'; b.title = pinned ? '已在快捷设置里(点击取消固定)' : '固定到 ⚡ 快捷设置(高频项)'; b.style.cssText = 'flex:0 0 auto;margin-left:4px;padding:0 6px;cursor:pointer;border:1px solid ' + (pinned ? '#ffd54a' : 'var(--border,#444)') + ';border-radius:6px;background:transparent;color:' + (pinned ? '#ffd54a' : 'var(--fg-muted)') + ';font-size:10px;line-height:16px;'; b.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const on = quickPinsGet().some(x => x.id === fieldId); if (on) quickPinsRemove(fieldId); else quickPinsAdd(fieldId); const host = document.getElementById('ocd-quickpins-host'); if (host) renderQuickPins(host); const still = quickPinsGet().some(x => x.id === fieldId); b.textContent = still ? '📌' : '📍'; b.style.borderColor = still ? '#ffd54a' : 'var(--border,#444)'; b.style.color = still ? '#ffd54a' : 'var(--fg-muted)'; }); return b; } function openSettingsCenter() { const exist = document.getElementById('ocd-settings-center'); if (exist) { exist.remove(); return; } const panel = document.createElement('div'); panel.id = 'ocd-settings-center'; panel.style.cssText = [ 'position:fixed', 'top:60px', 'right:70px', 'z-index:9999999', 'width:780px', 'max-width:94vw', 'max-height:84vh', 'overflow:hidden', 'display:flex', 'flex-direction:column', 'background:var(--bg,rgba(24,26,32,.96))', 'color:var(--fg,#e8eaf0)', 'border:1px solid var(--border,rgba(255,255,255,.10))', 'border-radius:12px', 'box-shadow:0 12px 40px rgba(0,0,0,.45)', 'padding:10px 12px', 'font:12px/1.6 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif' ].join(';'); const head = document.createElement('div'); head.style.cssText = 'display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;cursor:move;user-select:none;'; head.innerHTML = '⚙️ 设置中心' + 'v' + VERSION + ' · …' + '' + '✕'; panel.appendChild(head); const body = document.createElement('div'); body.id = 'ocd-sc-body'; body.style.cssText = 'flex:1 1 auto;overflow-y:auto;overflow-x:auto;min-height:0;min-width:0;max-width:100%;overscroll-behavior:contain;padding-bottom:24px;'; panel.appendChild(body); /* 滚轮由 shMakeWheelScrollable 统一处理(它会滚动内容区、到底/到顶时才交给页面)—— 不要在这里再加 stopPropagation 的 wheel 监听,否则会把滚动整个掐断(v9.44 教训) */ shMakeWheelScrollable(panel, () => document.getElementById('ocd-sc-body')); const contactRow = document.createElement('div'); contactRow.style.cssText = 'display:flex;gap:8px;margin:8px 0;'; /* V9.76.10(用户 m10340):🏷️ 自动标签设置 已从这条左侧导航「搬」到 ⚡ 快捷设置(高频项)的第一项 —— 它是高频入口 ✓ 但挂在左导航里既没有归属分组 ✗ 也吃不到分组内的搜索 / 折叠 / 排序 ✓ 按钮 id(ocd-sc-autotag)与点击行为都没变 ✓ 只是换了落脚点(见下方 quickBox 内的卡片) */ const mkContactBtn = (text, subject, body_) => { const b = document.createElement('button'); b.textContent = text; b.title = '跳转站内邮箱,内容已预填;若站点不支持预填,请 Ctrl+V 粘贴剪贴板内容'; b.style.cssText = [ 'flex:1', 'padding:7px 10px', 'cursor:pointer', 'border-radius:9px', 'font-size:12px', 'font-weight:600', 'text-align:center', 'background:var(--bg-soft,rgba(255,255,255,.08))', 'border:1px solid var(--border,#444)', 'color:var(--fg,#e8eaf0)', 'transition: border-color .15s, color .15s' ].join(';'); b.addEventListener('mouseenter', () => { b.style.borderColor = 'var(--accent,#5b8cff)'; b.style.color = 'var(--accent,#5b8cff)'; }); b.addEventListener('mouseleave', () => { b.style.borderColor = 'var(--border,#444)'; b.style.color = 'var(--fg,#e8eaf0)'; }); b.addEventListener('click', () => { store.set('ocd_helper_mail_v1', { subject: '【' + subject + '】' + (body_ ? ' ' + body_ : ''), body: (body_ ? body_ + '\n\n' : '') + '版本: v' + VERSION + '\n时间: ' + new Date().toLocaleString(), ts: Date.now(), }); panel.remove(); window.open('https://open.cd/sendmessage.php?receiver=81966', '_blank'); }); return b; }; contactRow.appendChild(mkContactBtn('💬 意见反馈', '脚本反馈', '请描述你遇到的问题/现象(可附截图说明)')); contactRow.appendChild(mkContactBtn('✨ 功能请求', '功能请求', '请描述你希望新增/改进的功能')); panel.appendChild(contactRow); const docsDivider = document.createElement('div'); docsDivider.style.cssText = 'border-top:1px solid var(--border,#444);margin:0 -16px 8px;'; panel.appendChild(docsDivider); try { body.appendChild(buildAdvSettingsBox()); } catch (e) { console.warn('[Adv] 高级设置面板构建失败:', e && e.message); } const gBox = document.createElement('details'); gBox.style.cssText = 'margin:4px 0;border:1px solid #00b894;border-radius:8px;padding:4px 8px;'; gBox.innerHTML = '🌐 全局设置'; body.appendChild(gBox); { const row1 = document.createElement('div'); row1.style.cssText = 'display:flex;gap:6px;margin:6px 0;flex-wrap:wrap;'; const btnResetFab = document.createElement('button'); btnResetFab.textContent = '悬浮球位置重置'; btnResetFab.title = '恢复到屏幕右下角默认位置'; btnResetFab.style.cssText = 'padding:3px 8px;cursor:pointer;'; btnResetFab.addEventListener('click', () => { const fab = document.getElementById('ocd-fab'); if (fab) { fab.style.right = '20px'; fab.style.bottom = '20px'; fab.style.left = 'auto'; fab.style.top = 'auto'; } const s = getShellState(); s.x = null; s.y = null; saveShellState(s); btnResetFab.textContent = '✅ 已重置'; setTimeout(() => { btnResetFab.textContent = '悬浮球位置重置'; }, 1500); }); const btnTheme = document.createElement('button'); const refreshThemeBtn = () => { const cur = store.get(THEME_KEY, 'dark'); btnTheme.textContent = '主题:' + (THEME_NAMES[cur] || cur) + ' ♺'; }; btnTheme.style.cssText = 'padding:3px 8px;cursor:pointer;'; refreshThemeBtn(); btnTheme.addEventListener('click', () => { const cur = store.get(THEME_KEY, 'dark'); const idx = THEMES.indexOf(cur); applyTheme(THEMES[(idx + 1) % THEMES.length] || 'dark'); refreshThemeBtn(); syncThemeClass(panel); }); row1.appendChild(btnResetFab); row1.appendChild(btnTheme); { const mkT = (txt, title, fn) => { const b = document.createElement('button'); b.textContent = txt; b.title = title; b.style.cssText = 'padding:3px 8px;cursor:pointer;'; b.addEventListener('click', fn); return b; }; row1.appendChild(mkT('🎨 保存主题方案', '把当前主题保存为方案 A/B/C', async () => { const pick = await shConfirm({ title: '保存到哪个方案?', html: '当前主题:' + (THEME_NAMES[store.get(THEME_KEY, 'dark')] || '?'), buttons: [{ text: 'A', value: 'A' }, { text: 'B', value: 'B' }, { text: 'C', value: 'C' }, { text: '取消', value: false }] }); if (pick) shThemeSlotSave(pick); })); row1.appendChild(mkT('🖼 切换主题方案', '从方案 A/B/C 恢复主题', async () => { const list = shThemeSlotList(); const html = list.map(x => '方案 ' + x.slot + ':' + (x.pack ? (THEME_NAMES[x.pack.theme] || x.pack.theme) : '(空)')).join('
'); const pick = await shConfirm({ title: '切换到哪个方案?', html: html, buttons: list.map(x => ({ text: x.slot, value: x.slot })).concat([{ text: '取消', value: false }]) }); if (pick) { const p = list.find(x => x.slot === pick); if (p && p.pack) shThemePackApply(p.pack); else toastSafe('该方案为空', 'warn'); } })); row1.appendChild(mkT('📤 导出主题', '把当前主题导出为 JSON', () => shExportThemePack())); row1.appendChild(mkT('📥 导入主题', '从 JSON 恢复主题包', () => shImportThemePack())); row1.appendChild(mkT('🧿 悬浮球方案', '保存 / 切换 / 导入导出悬浮球样式(A/B/C)', () => shShowFabSlotsTool())); } gBox.appendChild(row1); const hint = document.createElement('div'); hint.style.cssText = 'color:var(--fg-muted);font-size:11px;'; hint.textContent = '悬浮球可拖拽移动,位置自动记忆;拖到屏幕边缘不会越界。'; gBox.appendChild(hint); /* V9.76.17(用户 m13487 ②):删除「颜色微调(状态色)」整行 —— 两个 color input + 保存状态色 + 还原主题色,以及 TWEAK_KEY 的读写;功能整体下线 ✓ */ const animRow = document.createElement('div'); animRow.style.cssText = 'display:flex;align-items:center;gap:6px;margin:6px 0;flex-wrap:wrap;'; const animLab = document.createElement('span'); animLab.style.cssText = 'font-size:12px;color:#555;'; animLab.textContent = '入场动画:'; const animSel = document.createElement('select'); animSel.style.cssText = 'padding:3px 8px;border-radius:6px;border:1px solid #ccc;background:#fff;color:#333;cursor:pointer;'; const OPEN_ANIM_CN = { slide: '滑入', zoom: '中心缩放', rise: '底部升起', flip3d: '3D翻转', drop: '顶部落下', blur: '模糊聚焦', bounce: '左侧弹入', elastic: '果冻伸缩', swing: '摆动入场', typewriter: '展开揭示', glide: '斜滑入场', floatup: '浮亮上升', rotate: '旋转放大', shakein: '震动入场', slideup: '底部滑入', zoomout: '远处推近', fadeedge: '字距展开', hangfall: '悬挂落下', split: '纵向展开', slideback: '右滑回正', heartbeat: '心跳入场', fadein: '纯淡入', rotate3d: '顶部翻下', slideboth: '对角滑入', popchain: '弹跳放大', slideflip: '侧向翻入', wobblein: '歪斜摆正', elevate: '悬浮升起', rollin: '滚入归正', squeezenoise: '聚焦收敛' }; animSel.innerHTML = '' + OPEN_ANIMS.map(k => '').join(''); animSel.value = alhPref.get().openAnim || 'slide'; if (!animSel.value && animSel.selectedIndex === -1) animSel.selectedIndex = 0; const animPrev = document.createElement('button'); animPrev.textContent = '🎬 预览'; animPrev.title = '预览当前选中的动画'; animPrev.style.cssText = 'padding:3px 10px;cursor:pointer;'; animPrev.addEventListener('click', () => { const key = animSel.value === 'random' ? OPEN_ANIM_KEY[openAnimRandom()] : (OPEN_ANIM_KEY[animSel.value] || 'ocd-dp-slide'); const fab = document.getElementById('ocd-fab'); if (fab) { fab.style.animation = 'none'; void fab.offsetWidth; fab.style.animation = key + ' .45s ease-out'; setTimeout(() => { fab.style.animation = ''; }, 600); } const targets = ['ocd-dc-panel', 'ocd-dp-panel', 'ocd-batch-panel', 'ocd-probe-panel', 'ocd-helper-panel', 'ocd-sh-panel']; let played = false; for (const tid of targets) { const el = document.getElementById(tid); if (el && getComputedStyle(el).display !== 'none') { el.style.animation = 'none'; void el.offsetWidth; el.style.animation = key + ' .45s ease-out'; played = true; } } toastSafe('🎬 预览:' + (animSel.value === 'random' ? '随机' : animSel.value) + (played ? '(悬浮球 + 已打开面板同步演示)' : '(悬浮球演示;模块面板打开时应用同款)'), 'ok'); }); animRow.appendChild(animLab); animRow.appendChild(animSel); animRow.appendChild(animPrev); gBox.appendChild(animRow); animSel.addEventListener('change', () => { alhPref.set({ openAnim: animSel.value }); flashBtn(animPrev, '✓ 已生效', 1200); }); const muRow = document.createElement('div'); muRow.style.cssText = 'display:flex;align-items:center;gap:8px;margin:8px 0;flex-wrap:wrap;'; const muChk = document.createElement('input'); muChk.type = 'checkbox'; muChk.id = 'ocd-multiui-chk'; muChk.checked = getMultiUI(); const muLab = document.createElement('label'); muLab.htmlFor = 'ocd-multiui-chk'; muLab.style.cssText = 'cursor:pointer;font-size:12px;color:var(--fg,#e8eaf0);'; muLab.textContent = '模块面板多开共存'; muLab.title = '开启后:打开新模块面板不再自动收起上一个,多个面板可同时显示(可拖动摆放)。\n关闭 = 互斥模式(同一时间只显示一个面板,默认)。\n切换后立即生效;已打开的面板不受影响。'; const muTip = document.createElement('span'); muTip.style.cssText = 'font-size:11px;color:var(--fg-muted);'; muTip.textContent = getMultiUI() ? '(当前:共存——多面板同时显示)' : '(当前:互斥——一次显示一个,默认)'; muChk.addEventListener('change', () => { setMultiUI(muChk.checked); muTip.textContent = muChk.checked ? '(当前:共存——多面板同时显示)' : '(当前:互斥——一次显示一个,默认)'; if (!muChk.checked) { const keep = _openModuleId; ['ocd-mod-probedl', 'ocd-mod-datapanel', 'ocd-mod-dupcheck', 'ocd-mod-batch'].forEach(id => { const c = document.getElementById(id); const modIdOf = id.replace('ocd-mod-', ''); if (c && modIdOf !== keep) { const m = getModule(modIdOf); if (m && m.onClose) m.onClose(); } }); } toastSafe(muChk.checked ? '🪟 已开启多面板共存:新开面板不再顶替旧的' : '🪟 已切换为互斥模式:新开面板收起其他面板', 'ok'); }); muRow.appendChild(muChk); muRow.appendChild(muLab); muRow.appendChild(muTip); gBox.appendChild(muRow); } const diyBox = document.createElement('details'); diyBox.style.cssText = 'margin:0 0 8px;border:1px solid #a259ff;border-radius:9px;padding:6px 10px;'; diyBox.innerHTML = '🧿 悬浮球样式 DIY'; diyBox.addEventListener('toggle', () => { if (diyBox.open) console.log('[FabDIY] 当前存储配置:', JSON.stringify(store.get(FABDIY_KEY, null))); }); body.insertBefore(diyBox, body.firstChild); buildFabDiyPanel(diyBox); const eBox = document.createElement('details'); eBox.style.cssText = 'margin:5px 0;border:1px solid #00b8d4;border-radius:12px;padding:6px 10px;'; eBox.innerHTML = '🔧 模块设置(全部模块:启用 / 排序 / 各项设置)'; body.appendChild(eBox); { const order = getModuleOrder() || getModuleRegistry().map(m => m.id); getModuleRegistry().forEach(m => { if (order.indexOf(m.id) === -1) order.push(m.id); }); const reorderRows = () => { const rows = Array.from(eBox.querySelectorAll('[data-mod-row]')); rows.sort((a, b) => order.indexOf(a.dataset.modRow) - order.indexOf(b.dataset.modRow)); rows.forEach(r => eBox.appendChild(r)); }; const mkModuleRow = (modId, icon, name, gotoHint, onGoto) => { const mod = getModule(modId); const wrap = document.createElement('div'); wrap.dataset.modRow = modId; wrap.style.cssText = 'margin:6px 0;border:1px solid var(--border,#444);border-radius:8px;overflow:hidden;'; const head = document.createElement('div'); head.style.cssText = 'display:flex;align-items:center;gap:6px;padding:6px 8px;cursor:pointer;user-select:none;'; const en = document.createElement('input'); en.type = 'checkbox'; en.checked = isModuleEnabled(modId); en.title = '启用 / 停用该模块'; en.addEventListener('click', (e) => e.stopPropagation()); en.addEventListener('change', () => { setModuleEnabled(modId, en.checked); wrap.style.opacity = en.checked ? '' : '.45'; nm.style.textDecoration = en.checked ? '' : 'line-through'; }); const mkMove = (txt, dir) => { const b = document.createElement('button'); b.textContent = txt; b.title = dir < 0 ? '上移' : '下移'; b.style.cssText = 'flex:0 0 auto;padding:0 5px;cursor:pointer;font-size:10px;line-height:16px;'; b.addEventListener('click', (e) => { e.stopPropagation(); const i = order.indexOf(modId); const j = i + dir; if (i < 0 || j < 0 || j >= order.length) return; const tmp = order[i]; order[i] = order[j]; order[j] = tmp; setModuleOrder(order.slice()); reorderRows(); }); return b; }; const up = mkMove('▲', -1); const down = mkMove('▼', 1); head.addEventListener('mouseenter', () => head.style.background = 'var(--bg-soft,rgba(255,255,255,.06))'); head.addEventListener('mouseleave', () => head.style.background = ''); const arrow = document.createElement('span'); arrow.style.cssText = 'flex:0 0 auto;font-size:10px;color:var(--fg-muted);transition:transform .15s;'; arrow.textContent = '▸'; const nm = document.createElement('span'); nm.style.cssText = 'font-size:13px;font-weight:600;'; nm.textContent = icon + ' ' + name; const pg = document.createElement('span'); pg.style.cssText = 'flex:1;font-size:11px;color:var(--fg-muted);'; pg.textContent = '(生效页面:' + (mod ? describePages(mod.pages, mod.id) : '—') + ')'; const btn = document.createElement('button'); btn.textContent = '前往设置'; btn.title = gotoHint; btn.style.cssText = 'flex:0 0 auto;padding:3px 10px;cursor:pointer;font-size:12px;'; btn.addEventListener('click', (e) => { e.stopPropagation(); if (mod && !routeMatch(mod)) { const targetUrl = mod.id === 'probedl' ? 'https://open.cd/torrents.php' : mod.id === 'uploader' ? 'https://open.cd/plugin_upload.php' : null; if (targetUrl) { const go = confirm('【' + name + '】当前页面未生效。\n请前往:' + describePages(mod.pages, mod.id) + '\n\n确定 = 前往该页面 取消 = 留在本页'); if (go) window.open(targetUrl, '_blank'); } else { shAlert('【' + name + '】当前页面未生效。\n' + gotoHint); } return; } onGoto(); }); head.appendChild(en); head.appendChild(arrow); head.appendChild(nm); head.appendChild(pg); head.appendChild(up); head.appendChild(down); head.appendChild(btn); if (!isModuleEnabled(modId)) { wrap.style.opacity = '.45'; nm.style.textDecoration = 'line-through'; } const pane = document.createElement('div'); pane.style.cssText = 'display:none;border-top:1px solid var(--border,#444);padding:8px 10px;background:var(--bg-soft,rgba(255,255,255,.03));'; head.addEventListener('click', () => { const open = pane.style.display === 'none'; pane.style.display = open ? 'block' : 'none'; arrow.textContent = open ? '▾' : '▸'; }); wrap.appendChild(head); wrap.appendChild(pane); eBox.appendChild(wrap); return { wrap, pane, head }; }; /* 通用内联设置块:把模块的核心设置直接放进「🔧 模块设置」里(无需单独前往模块界面) */ const inlineRow = (host, label, hint, ctrl) => { const w = document.createElement('div'); w.style.cssText = 'display:flex;align-items:center;gap:8px;margin:4px 0;font-size:11.5px;flex-wrap:wrap;'; const l = document.createElement('span'); l.style.cssText = 'flex:0 0 auto;min-width:132px;color:var(--fg-muted);'; l.textContent = label; l.title = hint || ''; w.appendChild(l); w.appendChild(ctrl); host.appendChild(w); return w; }; const inlineTitle = (host, text) => { const d = document.createElement('div'); d.style.cssText = 'font-size:11.5px;font-weight:700;margin:2px 0 4px;color:var(--fg);'; d.textContent = text; host.appendChild(d); }; const mkNum = (getv, setv, min, max, unit) => { const wrap2 = document.createElement('span'); wrap2.style.cssText = 'display:inline-flex;align-items:center;gap:4px;'; const i = document.createElement('input'); i.type = 'text'; i.value = String(getv()); i.style.cssText = 'width:74px;box-sizing:border-box;padding:2px 6px;border:1px solid var(--border,#444);border-radius:6px;background:var(--input-bg,#2a303c);color:var(--fg);font-size:11px;'; i.addEventListener('change', () => { let v = Number(String(i.value).trim()); if (!isFinite(v)) v = getv(); v = Math.max(min, Math.min(max, v)); i.value = String(v); setv(v); toastSafe('已保存,下次使用该功能时生效', 'ok'); }); const u = document.createElement('span'); u.style.cssText = 'color:var(--fg-muted);'; u.textContent = unit || ''; wrap2.appendChild(i); wrap2.appendChild(u); return wrap2; }; const mkBool = (getv, setv, text) => { const lab = document.createElement('label'); lab.style.cssText = 'display:inline-flex;align-items:center;gap:5px;cursor:pointer;'; const c = document.createElement('input'); c.type = 'checkbox'; c.checked = !!getv(); c.addEventListener('change', () => { setv(c.checked); toastSafe(text + ':' + (c.checked ? '已开启' : '已关闭'), 'ok'); }); const sp = document.createElement('span'); sp.textContent = text; lab.appendChild(c); lab.appendChild(sp); return lab; }; const up = mkModuleRow('uploader', '🎵', '发布页小助手', '发布页助手设置在上传页的助手面板内:请前往上传页,点击面板标题栏的 ⚙ 图标打开设置。', () => { panel.remove(); openModuleUI(getModule('uploader')); setTimeout(() => { const gear = document.getElementById('ocd-gear'); if (gear) gear.click(); }, 150); }); const upHint = document.createElement('div'); upHint.style.cssText = 'font-size:11px;color:var(--fg-muted);line-height:1.7;'; upHint.innerHTML = '面板内设置包含:界面布局 / 发布页预设值 / 种子频谱 / 音乐平台 / 发种查重 / 控件绑定。
本模块的专属设置在上传页面板 ⚙ 内,此处不重复提供。'; up.pane.appendChild(upHint); try { const nt = eBox.querySelector('[data-mod-row="notify"]') ? null : mkModuleRow('notify', '🔔', '通知中心', '通知阈值可直接在此修改;也可点「前往设置」打开通知面板。', () => { panel.remove(); shOpenModuleById('notify'); }); if (nt) { inlineTitle(nt.pane, '通知阈值与保留'); inlineRow(nt.pane, '魔力值提醒阈值', '魔力值高于该值时提醒(0 = 不提醒)', mkNum(() => advNum('notify', {}) && (advPrefs().notify || {}).magicMin, (v) => advSave({ notify: Object.assign({}, advPrefs().notify, { magicMin: v }) }), 0, 1000000, '点')); inlineRow(nt.pane, '通知保留条数', '超出后自动清理最旧的(0 = 不限)', mkNum(() => (advPrefs().notify || {}).retain, (v) => advSave({ notify: Object.assign({}, advPrefs().notify, { retain: v }) }), 0, 2000, '条')); inlineRow(nt.pane, 'H&R 提醒阈值', '剩余 H&R 达到该值提醒', mkNum(() => (advPrefs().notify || {}).hrMin, (v) => advSave({ notify: Object.assign({}, advPrefs().notify, { hrMin: v }) }), 1, 999, '个')); inlineRow(nt.pane, '断种提醒阈值', '做种数 ≤ 该值视为断种', mkNum(() => (advPrefs().notify || {}).deadMin, (v) => advSave({ notify: Object.assign({}, advPrefs().notify, { deadMin: v }) }), 1, 999, '个')); } } catch (e) { console.warn('[设置中心] 通知内联设置失败:', e && e.message); } try { const sb = eBox.querySelector('[data-mod-row="seedboard"]') ? null : mkModuleRow('seedboard', '🌱', '做种看板', '看板抓取设置可直接在此修改;也可点「前往设置」打开看板面板。', () => { panel.remove(); shOpenModuleById('seedboard'); }); if (sb) { inlineTitle(sb.pane, '抓取与判定'); inlineRow(sb.pane, '抓取页数上限', '每类列表最多抓多少页', mkNum(() => (advPrefs().seed || {}).pageCap, (v) => advSave({ seed: Object.assign({}, advPrefs().seed, { pageCap: v }) }), 1, 60, '页')); inlineRow(sb.pane, '断种阈值', '做种数 ≤ 该值计入断种清单(0 = 只看 0 做种)', mkNum(() => (advPrefs().seed || {}).deadMax, (v) => advSave({ seed: Object.assign({}, advPrefs().seed, { deadMax: v }) }), 0, 999, '个')); inlineRow(sb.pane, '做种守护', '低频巡检自己的做种数并在异常时提醒', mkBool(() => !!shSeedGuardPrefs().on, (v) => { shSeedGuardSave({ on: v }); }, '启用')); } } catch (e) { console.warn('[设置中心] 看板内联设置失败:', e && e.message); } try { const dp = eBox.querySelector('[data-mod-row="dupcheck"]') ? null : mkModuleRow('dupcheck', '🔍', '发种查重助手', '查重判定项可直接在此修改;也可点「前往设置」打开查重面板。', () => { panel.remove(); shOpenModuleById('dupcheck'); }); if (dp) { const dupHost = document.createElement('div'); dupHost.style.cssText = 'font-size:11.5px;'; dp.pane.appendChild(dupHost); try { renderDupSettings(dupHost); } catch (e) { dupHost.textContent = '查重设置渲染失败:' + ((e && e.message) || e); } } } catch (e) { console.warn('[设置中心] 查重内联设置失败:', e && e.message); } const bt = mkModuleRow('batch', '⚡', '批量发种助手', '批量发种全站生效:点「前往设置」打开批量面板,本行展开区也直接提供发布模式等核心设置。', () => { panel.remove(); openModuleUI(getModule('batch')); }); const modeW = document.createElement('div'); modeW.style.cssText = 'display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:8px;'; const modeLab = document.createElement('span'); modeLab.style.cssText = 'font-size:12px;font-weight:700;'; modeLab.textContent = '发布模式:'; const mkModeRadio = (val, text, title) => { const lab = document.createElement('label'); lab.style.cssText = 'display:inline-flex;align-items:center;gap:4px;cursor:pointer;font-size:12px;'; lab.title = title; const rd = document.createElement('input'); rd.type = 'radio'; rd.name = 'ocd-pubmode'; rd.value = val; rd.checked = getPublishMode() === val; rd.addEventListener('change', () => { if (rd.checked) savePublishMode(val); }); const sp = document.createElement('span'); sp.textContent = text; lab.appendChild(rd); lab.appendChild(sp); return lab; }; modeW.appendChild(modeLab); modeW.appendChild(mkModeRadio('manual', '人工跳转发布', '默认:前往上传页自动装载,人工核对后点站内发布')); modeW.appendChild(mkModeRadio('silent', '静默预览发布', '点击任务弹出预览框核对字段,确认后后台注入站内表单发布,发布后自动抓取种子下载')); bt.pane.appendChild(modeW); const quotaW = document.createElement('div'); quotaW.style.cssText = 'display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:8px;font-size:12px;'; quotaW.innerHTML = '频谱配额保护:' + '' + '剩余低于' + '' + '次时暂停(次日 0 点自动恢复)'; bt.pane.appendChild(quotaW); quotaW.querySelector('#ocd-quota-on').addEventListener('change', (e) => { store.set(QUOTA_ENABLE_KEY, e.target.checked); }); quotaW.querySelector('#ocd-quota-th').addEventListener('change', (e) => { const n = setQuotaThreshold(e.target.value); e.target.value = String(n); toastSafe('✅ 配额阈值已设为 ' + n + ' 次', 'ok'); }); const silentOrderW = document.createElement('div'); silentOrderW.style.cssText = 'display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:8px;'; const soLab = document.createElement('span'); soLab.style.cssText = 'font-size:12px;font-weight:700;'; soLab.textContent = '静默队列排序:'; soLab.title = '静默发布时,队列按此顺序执行(相同键时按原始顺序)'; const soSel = document.createElement('select'); soSel.style.cssText = 'padding:3px 8px;border-radius:6px;border:1px solid var(--border,#444);background:var(--input-bg,#2a303c);color:var(--fg,#e8eaf0);font-size:12px;cursor:pointer;'; soSel.innerHTML = '' + '' + '' + ''; soSel.value = getSilentOrder(); soSel.addEventListener('change', () => { setSilentOrder(soSel.value); toastSafe('✅ 静默队列排序已切换为「' + soSel.options[soSel.selectedIndex].text + '」', 'ok'); }); silentOrderW.appendChild(soLab); silentOrderW.appendChild(soSel); bt.pane.appendChild(silentOrderW); const dirW = document.createElement('div'); dirW.style.cssText = 'border:1px solid var(--border,#444);border-radius:7px;padding:8px 10px;margin-bottom:8px;'; const dirTitle = document.createElement('div'); dirTitle.style.cssText = 'font-size:12px;font-weight:700;margin-bottom:4px;'; dirTitle.textContent = '📁 本地目录状态(与发布页助手共用)'; dirW.appendChild(dirTitle); const mkDirLine = async (key, label) => { const line = document.createElement('div'); line.style.cssText = 'font-size:11px;color:var(--fg-muted);margin:2px 0;'; line.textContent = label + ':检测中…'; dirW.appendChild(line); try { const dir = await FSDB.get(key); if (!dir) { line.textContent = label + ':○ 未设置(发布页助手 ⚙ → 种子频谱设置 中选择)'; return; } const perm = await dir.queryPermission({ mode: 'read' }); if (perm === 'granted') line.innerHTML = label + ':✅ ' + esc(dir.name) + ' (已授权)'; else line.innerHTML = label + ':⚠️ ' + esc(dir.name) + ' (授权已失效,需重新选择)'; } catch (e) { line.textContent = label + ':状态读取失败'; } }; mkDirLine('torrent_dir', '种子文件目录'); mkDirLine('spec_dir', '频谱图目录'); const dirHint = document.createElement('div'); dirHint.style.cssText = 'font-size:10px;color:var(--fg-muted);margin-top:4px;line-height:1.6;'; dirHint.innerHTML = '💡 预览发布的种子/频谱自动匹配、批量目录导入均使用这两个目录。
设置/更换入口:上传页 → 助手面板 ⚙ → 种子频谱设置 → 「更换xx目录」按钮。'; dirW.appendChild(dirHint); bt.pane.appendChild(dirW); const keepW = document.createElement('div'); keepW.style.cssText = 'display:flex;align-items:center;gap:8px;margin-bottom:8px;font-size:12px;'; const keepLab = document.createElement('span'); keepLab.textContent = '已发布保留数量:'; const keepIp = document.createElement('input'); keepIp.type = 'text'; keepIp.value = String(store.get('ocd_helper_keeppub_v1', 0)); keepIp.style.cssText = 'width:52px;padding:3px 6px;border:1px solid var(--border,#444);border-radius:5px;background:var(--input-bg,#2a303c);color:var(--fg,#e8eaf0);'; keepIp.title = '已发布列表最多保留 N 条,超出自动清最旧的(清前自动导出 CSV 存档)。0 = 不自动清理'; const keepTip = document.createElement('span'); keepTip.style.cssText = 'font-size:11px;color:var(--fg-muted);'; keepTip.textContent = '(超出自动清最旧,清前自动导出 CSV;0=不清理)'; keepW.appendChild(keepLab); keepW.appendChild(keepIp); keepW.appendChild(keepTip); keepIp.addEventListener('change', () => { const v = Math.max(0, Math.min(500, Number(keepIp.value) || 0)); store.set('ocd_helper_keeppub_v1', v); keepIp.value = String(v); }); bt.pane.appendChild(keepW); const bpFold = document.createElement('div'); bpFold.style.cssText = 'border:1px solid var(--border,#444);border-radius:7px;overflow:hidden;'; const bpHead = document.createElement('div'); bpHead.style.cssText = 'display:flex;align-items:center;gap:6px;padding:6px 8px;cursor:pointer;user-select:none;font-size:12px;'; bpHead.addEventListener('mouseenter', () => bpHead.style.background = 'var(--bg-soft,rgba(255,255,255,.06))'); bpHead.addEventListener('mouseleave', () => bpHead.style.background = ''); const bpArrow = document.createElement('span'); bpArrow.style.cssText = 'font-size:10px;color:var(--fg-muted);transition:transform .15s;'; bpArrow.textContent = '▸'; const bpName = document.createElement('span'); bpName.style.cssText = 'font-weight:600;'; bpName.textContent = '⚙️ 批量预设(独立配置)'; const bpTip = document.createElement('span'); bpTip.style.cssText = 'flex:1;font-size:11px;color:var(--fg-muted);'; bpTip.textContent = '(批量任务统一使用的预设源,独立于发布页面板)'; bpHead.appendChild(bpArrow); bpHead.appendChild(bpName); bpHead.appendChild(bpTip); const bpPane = document.createElement('div'); bpPane.style.cssText = 'display:none;border-top:1px solid var(--border,#444);padding:8px;'; bpHead.addEventListener('click', () => { const open = bpPane.style.display === 'none'; bpPane.style.display = open ? 'block' : 'none'; bpArrow.textContent = open ? '▾' : '▸'; if (open) { try { buildBatchPresetEditor(null, bpPane); } catch (e) { console.error('[设置中心] 批量预设编辑器构建失败:', e.message || e); } } else { bpPane.innerHTML = ''; } }); bpFold.appendChild(bpHead); bpFold.appendChild(bpPane); bt.pane.appendChild(bpFold); const bpNote = document.createElement('div'); bpNote.style.cssText = 'font-size:11px;color:var(--fg-muted);margin-top:6px;line-height:1.7;'; bpNote.innerHTML = '💡 完整的批量面板(解析队列/任务列表/批量操作)请点上方「前往设置」打开。'; bt.pane.appendChild(bpNote); const covered = {}; eBox.querySelectorAll('[data-mod-row]').forEach(r => { covered[r.dataset.modRow] = 1; }); getModuleRegistry().forEach((m) => { if (covered[m.id]) return; const row = mkModuleRow(m.id, m.icon, m.name, '该模块暂无独立设置项:点「打开面板」进入模块界面,或在展开区查看状态。', () => { panel.remove(); openModuleUI(m); }); const hint = document.createElement('div'); hint.style.cssText = 'font-size:11px;color:var(--fg-muted);line-height:1.7;margin-bottom:6px;'; hint.innerHTML = '模块版本 v' + esc(m.ver) + ' · 生效页面:' + esc(describePages(m.pages, m.id)) + (m.status && m.status() ? ' · 当前状态:' + esc(m.status()) + '' : ''); row.pane.appendChild(hint); const openBtn = document.createElement('button'); openBtn.textContent = '打开面板'; openBtn.style.cssText = 'padding:3px 12px;cursor:pointer;font-size:12px;'; openBtn.addEventListener('click', (e) => { e.stopPropagation(); panel.remove(); openModuleUI(m); }); row.pane.appendChild(openBtn); }); reorderRows(); const tip = document.createElement('div'); tip.style.cssText = 'color:var(--fg-muted);font-size:11px;margin-top:6px;line-height:1.7;'; tip.textContent = '这里是模块的总入口:勾选框启用/停用,▲▼ 调整顺序(决定悬浮球菜单显示顺序,停用模块自动灰显排最后),点行标题展开该模块的设置或状态。'; eBox.appendChild(tip); } const quickBox = document.createElement('details'); quickBox.style.cssText = 'margin:4px 0;border:1px solid #ff6b81;border-radius:8px;padding:4px 8px;'; quickBox.innerHTML = '⚡ 快捷设置(高频项)'; body.appendChild(quickBox); { /* V9.76.10(用户 m10340):🏷️ 自动标签设置 作为 ⚡ 快捷设置的第一项 ✓ 高频入口放在高频分组里 ✓ 还能吃到分组内的搜索 / 折叠 / 与「📌 固定」项一起排序 ✓ 只搬位置:按钮 id(ocd-sc-autotag)与点击行为(openAutoTagSettings)一字未改 ✓ */ try { const atCard = document.createElement('div'); atCard.id = 'ocd-sc-autotag-card'; atCard.style.cssText = 'margin:6px 0;border:1px solid #ff6b81;border-radius:8px;padding:7px 9px;background:var(--bg-soft,rgba(255,255,255,.03));display:flex;align-items:center;gap:9px;flex-wrap:wrap;'; const atTitle = document.createElement('b'); atTitle.style.cssText = 'color:#ff6b81;font-size:12.5px;'; atTitle.textContent = '🏷️ 自动标签设置'; const atDesc = document.createElement('span'); atDesc.style.cssText = 'font-size:11px;color:var(--fg-muted);flex:1 1 200px;line-height:1.7;'; atDesc.textContent = '解析发种时按这里的规则自动打标签:流派 / 合并别名 / 发烧档位 / 数据源与置信度 / 标签→类别映射。'; const atBtn = document.createElement('button'); atBtn.id = 'ocd-sc-autotag'; atBtn.textContent = '🏷️ 打开自动标签设置'; atBtn.title = '流派 / 别名归类 / 发烧档位 / 数据源 等自动标签设置'; atBtn.style.cssText = 'cursor:pointer;padding:5px 14px;border:none;border-radius:8px;font-weight:600;font-size:12px;color:#fff;background:linear-gradient(135deg,#ff6b81,#ff9aa8);'; atBtn.addEventListener('click', function (e) { e.stopPropagation(); try { openAutoTagSettings(); } catch (e2) {} }); atCard.appendChild(atTitle); atCard.appendChild(atDesc); atCard.appendChild(atBtn); quickBox.appendChild(atCard); } catch (e) {} const qPinWrap = document.createElement('div'); qPinWrap.style.cssText = 'margin:6px 0;border:1px solid #ff6b81;border-radius:8px;padding:6px 9px;background:var(--bg-soft,rgba(255,255,255,.03));'; const qPinHead = document.createElement('div'); qPinHead.style.cssText = 'font-size:12px;font-weight:700;color:#ff6b81;margin-bottom:2px;'; qPinHead.textContent = '📌 我固定的设置'; const qPinHost = document.createElement('div'); qPinHost.id = 'ocd-quickpins-host'; qPinWrap.appendChild(qPinHead); qPinWrap.appendChild(qPinHost); quickBox.appendChild(qPinWrap); try { renderQuickPins(qPinHost); } catch (e) { console.warn('[设置中心] 固定项渲染失败:', e && e.message); } /* 🔔 智能订阅(工具,不是模块):高频设置直接放在快捷设置里 */ try { const qSub = document.createElement('details'); qSub.style.cssText = 'margin:6px 0;border:1px solid var(--border,#eee);border-radius:10px;padding:4px 9px;'; qSub.innerHTML = '🔔 智能订阅(自动扫描 / 翻页上限 / 命中保留)'; const qSubHost = document.createElement('div'); qSubHost.style.cssText = 'font-size:11.5px;padding:2px 0;'; qSub.appendChild(qSubHost); quickBox.appendChild(qSub); if (typeof inlineRow === 'function') { inlineRow(qSubHost, '每日自动扫描', '打开页面 45 秒后静默执行一次(距上次 ≥24 小时)', mkBool(() => shSubPrefs().auto, (v) => { shSubSave({ auto: v }); }, '启用')); inlineRow(qSubHost, '站内翻页上限', '与 🎚️ 高级设置「订阅页数上限」同一项;0 = 不限', mkNum(() => shSubPrefs().maxPages, (v) => advSave({ subPages: v }), 0, 500, '页')); inlineRow(qSubHost, '每条保留命中', '与 🎚️ 高级设置「订阅命中保留」同一项;0 = 不限', mkNum(() => shSubPrefs().maxHits, (v) => advSave({ subHits: v }), 0, 20000, '条')); inlineRow(qSubHost, '每列显示命中', '订阅面板里每列默认渲染多少条(0 = 全部)', mkNum(() => shSubPrefs().hitLimit, (v) => shSubSave({ hitLimit: v }), 0, 5000, '条')); } } catch (e) { console.warn('[设置中心] 订阅内联设置失败:', e && e.message); } const qPlat = document.createElement('details'); qPlat.style.cssText = 'margin:6px 0;border:1px solid var(--border,#eee);border-radius:6px;padding:4px 8px;'; qPlat.innerHTML = '🎵 音乐平台设置(自动抓取 / 自定义平台 / 开关 / 排序)'; quickBox.appendChild(qPlat); try { buildPlatformSettings(qPlat); } catch (e) { console.error('[设置中心] 平台设置构建失败:', e); } const qFs = document.createElement('details'); qFs.style.cssText = 'margin:6px 0;border:1px solid var(--border,#eee);border-radius:6px;padding:4px 8px;'; qFs.innerHTML = '📊 种子频谱设置(位置 / 匹配)'; quickBox.appendChild(qFs); try { buildFsSettingsPanel(qFs); } catch (e) { console.error('[设置中心] 种子频谱设置构建失败:', e); } } const uiBox = document.createElement('details'); uiBox.style.cssText = 'margin:4px 0;border:1px solid #a259ff;border-radius:8px;padding:4px 8px;'; uiBox.innerHTML = '🎨 界面与全局'; body.appendChild(uiBox); try { Array.from(gBox.children).forEach((el) => { if (el.tagName !== 'SUMMARY') uiBox.appendChild(el); }); gBox.remove(); } catch (e) { console.warn('[设置中心] 全局设置合并失败:', (e && e.message) || e); } { const mkChk = (label, title, get, set) => { const row = document.createElement('label'); row.style.cssText = 'display:flex;align-items:center;gap:8px;margin:6px 0;font-size:12px;cursor:pointer;'; const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !!get(); cb.addEventListener('change', () => { set(cb.checked); toastSafe('已' + (cb.checked ? '开启' : '关闭') + ':' + label, 'ok'); }); const sp = document.createElement('span'); sp.textContent = label; sp.title = title; row.appendChild(cb); row.appendChild(sp); uiBox.appendChild(row); return cb; }; /* V9.76.17(用户 m13487 ①):撤销「促销标签高亮」「促销行色条」两项设置 —— 功能固定开启,设置项移除 ✓(历史键 ocd_helper_promo_v1 不再使用 ✓) */ mkChk('窄屏适配', '窄窗口下自动收窄面板并重排位置', () => shResponsivePrefs().enabled, (v) => { const p = store.get(RESPONSIVE_KEY, {}) || {}; p.enabled = v; store.set(RESPONSIVE_KEY, p); toastSafe(v ? '刷新页面后完全生效' : '已停用', 'ok'); }); mkChk('快捷键(Alt+K 命令面板)', 'Alt+K 打开命令面板;输入框内不会误触', () => shShortcutPrefs().enabled, (v) => { const p = store.get(SHORTCUT_KEY, {}) || {}; p.enabled = v; store.set(SHORTCUT_KEY, p); }); const hint = document.createElement('div'); hint.style.cssText = 'font-size:11px;color:var(--fg-muted,#9aa3b2);line-height:1.8;margin-top:4px;'; hint.textContent = '命令面板:Alt+K(可搜索模块、自检、配置、日志、主题、发布页动作等);悬浮球可拖拽移动,位置自动记忆。'; uiBox.appendChild(hint); } /* 工具箱维护已独立为模块:设置中心只保留入口,工具清单与自定义在模块里维护 */ const maintBox = document.createElement('div'); maintBox.style.cssText = 'margin:4px 0;border:1px solid #0f9b8e;border-radius:8px;padding:8px 10px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;'; maintBox.innerHTML = '🛠️ 工具箱维护' + '已独立为模块:自检 / 配置与备份 / 数据与存储 / 工具面板 / 引导帮助,共 ' + toolboxItems().length + ' 个工具,支持搜索、排序、置顶、隐藏与布局切换。'; const maintOpen = document.createElement('button'); maintOpen.textContent = '🛠️ 打开工具箱维护'; maintOpen.style.cssText = 'padding:5px 14px;cursor:pointer;border:none;border-radius:8px;background:linear-gradient(135deg,#0f9b8e,#22c1a8);color:#fff;font-weight:600;font-size:12px;'; maintOpen.addEventListener('click', () => { try { openToolboxUI(); } catch (e) { toastSafe('打开失败:' + ((e && e.message) || e), 'err'); } }); maintBox.appendChild(maintOpen); body.appendChild(maintBox); /* 模块行去重:同名模块只保留一行,优先保留带设置控件的那一行(避免重复行与统计偏差) */ try { const seen = {}; Array.from(eBox.querySelectorAll('[data-mod-row]')).forEach((r) => { const id = r.dataset.modRow; const rich = () => !!r.querySelector('input, select, textarea'); if (!seen[id]) { seen[id] = r; return; } const keepNew = rich() && !seen[id].querySelector('input, select, textarea'); const drop = keepNew ? seen[id] : r; if (keepNew) seen[id] = r; drop.remove(); }); } catch (e) {} /* 设置中心布局整合:左导航 + 右内容 + 搜索 + 快捷操作(分组由下方通用登记自动纳管) */ { const bar = document.createElement('div'); bar.style.cssText = 'display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin:2px 0 8px;'; bar.innerHTML = /* V9.76.10:搜索框加 type=search + autocomplete=off —— 真机取证发现 Edge 会把站点用户名自动填进来 ✗ (自动填充还会触发一次 input 事件 → 7 个分组全被过滤隐藏 → 面板看起来"空的"✗ 见取证记录) */ '' + '' + ''; head.insertAdjacentElement('afterend', bar); /* 快捷操作 */ const quick = document.createElement('div'); quick.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap;margin:0 0 8px;'; const qbtn = (label, title, fn) => { const b = document.createElement('button'); b.textContent = label; b.title = title; b.style.cssText = 'padding:4px 10px;cursor:pointer;border:1px solid var(--border,#444);border-radius:7px;background:transparent;color:var(--fg);font-size:11px;'; b.addEventListener('click', () => { try { fn(); } catch (e) { toastSafe('执行失败:' + ((e && e.message) || e), 'err'); } }); return b; }; quick.appendChild(qbtn('🎛️ 高级设置', '跳到「高级设置」分区(限制 / 缓存 / 并发 / 通知等)', () => { const sec = body.querySelector('[data-ocd-sec="adv"]') || null; if (sec && sec.tagName === 'DETAILS') sec.open = true; const top = Math.max(0, ((sec && sec.offsetTop) || 0) - 8); try { body.scrollTop = top; } catch (e) {} const firstBtn = navBtns && navBtns.find(x => x.dataset.target === 'adv'); if (firstBtn) setActive(firstBtn); })); quick.appendChild(qbtn('🛠️ 工具箱维护', '打开工具箱维护模块(自检 / 备份 / 工具面板)', () => openToolboxUI())); quick.appendChild(qbtn('💾 导出配置', '把全部设置导出为 JSON 文件', () => shExportConfig())); quick.appendChild(qbtn('📂 导入配置', '从 JSON 文件恢复设置', () => shImportConfig())); quick.appendChild(qbtn('📖 使用手册', '打开使用手册(脚本同目录)', () => { try { openManual(); } catch (e) { toastSafe('手册位于脚本同目录:OpenCD 工具箱 使用手册.html', 'info'); } })); quick.appendChild(qbtn('🩺 一键自检', '汇总版本 / 数据 / 定时器 / 存储占用', () => shShowDiagnosis())); head.insertAdjacentElement('afterend', quick); /* 把「意见反馈 / 功能请求」「关于」等移入左栏底部 */ const nav = document.createElement('div'); nav.id = 'ocd-sc-nav'; nav.style.cssText = 'flex:0 1 clamp(140px, 24%, 200px);min-width:0;display:flex;flex-direction:column;gap:3px;overflow-y:auto;overflow-x:hidden;min-height:0;padding:6px;border:1px solid var(--border,#3a3f4b);border-radius:10px;background:var(--bg-soft,rgba(255,255,255,.04));box-sizing:border-box;'; const main = document.createElement('div'); /* min-height:0 是关键:否则纵向 flex 子项按内容高度撑开,内容区永远不滚动(v9.44 修复) */ main.style.cssText = 'flex:1 1 auto;min-height:0;min-width:0;display:flex;flex-direction:row;gap:10px;align-items:stretch;overflow:hidden;'; main.appendChild(body); panel.appendChild(main); main.insertBefore(nav, body); try { if (contactRow) { contactRow.style.flexDirection = 'column'; contactRow.style.margin = '8px 0 4px'; contactRow.style.width = '100%'; contactRow.style.boxSizing = 'border-box'; nav.appendChild(contactRow); Array.from(contactRow.children).forEach((b) => { b.style.width = '100%'; b.style.boxSizing = 'border-box'; }); } if (docsDivider) nav.appendChild(docsDivider); } catch (e) {} /* V9.76.0:第三方模块生态分组(必须在下面「通用登记」之前追加,才能吃到导航/搜索/折叠)✓ */ try { ecomodMountSection(body); } catch (e) {} /* 通用登记:把 body 的每个分组登记为导航项 */ const secs = Array.from(body.children).filter((el) => { const txt = (el.textContent || '').trim(); return (el.tagName === 'DETAILS' || /^(DIV|SECTION)$/.test(el.tagName)) && txt.length > 4; }); const catOf = (el) => { const sum = el.querySelector('summary'); let label = sum ? (sum.textContent || '').trim() : ''; if (!label) { const b = el.querySelector('b, strong, div'); label = b ? (b.textContent || '').trim() : (el.textContent || '').trim(); } /* 去掉括号注解(如「高级设置(限制 / 缓存 / 并发)」→「高级设置」),再按自然边界截断 */ label = label.replace(/[((【\[][^))】\]]*[))】\]]/g, '').replace(/[::·—-]+\s*$/, '').trim(); if (label.length > 14) label = label.slice(0, 14) + '…'; return label || '分区'; }; const navBtns = []; let advAssigned = false; secs.forEach((el, i) => { /* 用 data-ocd-sec 作为导航锚点,避免覆盖分组原有 id(如高级设置框的 ocd-adv-box) */ let key = 's' + i; if (!advAssigned && /高级设置|限制与阈值/.test(el.textContent || '')) { key = 'adv'; advAssigned = true; } el.setAttribute('data-ocd-sec', key); if (!el.id) el.id = 'ocd-sc-sec-' + i; const b = document.createElement('button'); b.type = 'button'; b.dataset.target = key; b.textContent = catOf(el); b.style.cssText = 'text-align:left;width:100%;box-sizing:border-box;padding:6px 9px;cursor:pointer;border:1px solid transparent;border-radius:9px;background:transparent;color:var(--fg-muted);font-size:11.5px;line-height:1.4;white-space:normal;word-break:break-word;overflow-wrap:anywhere;transition:background .12s,border-color .12s,color .12s;'; b.addEventListener('mouseenter', () => { if (b.style.borderColor !== 'var(--accent,#5b8cff)' && b.style.background !== 'var(--bg-soft,rgba(255,255,255,.08))') { b.style.background = 'var(--bg-soft,rgba(255,255,255,.06))'; b.style.borderColor = 'var(--border,#444)'; b.style.color = 'var(--fg)'; } }); b.addEventListener('mouseleave', () => { if (b.style.borderColor !== 'var(--accent,#5b8cff)') { b.style.background = 'transparent'; b.style.borderColor = 'transparent'; b.style.color = 'var(--fg-muted)'; } }); b.addEventListener('click', () => { const el2 = body.querySelector('[data-ocd-sec="' + b.dataset.target + '"]'); if (!el2) return; if (el2.tagName === 'DETAILS') el2.open = true; /* 只在内容区内滚动:scrollIntoView 会连带滚动整页或其它祖先容器,导致「点完就滚不动」的错觉 */ try { body.scrollTop = Math.max(0, (el2.offsetTop || 0) - 8); } catch (e) {} setActive(b); }); nav.appendChild(b); navBtns.push(b); }); /* 导航按常用度排序:快捷 → 界面 → 悬浮球 → 模块 → 高级 → 工具箱 → 其他 */ try { const weightOf = (label) => { const rules = [ [/快捷/, 0], [/界面|全局|主题|外观/, 1], [/悬浮球|DIY|小球/, 2], [/模块/, 3], [/高级|限制|并发|缓存/, 4], [/工具箱|维护/, 5], [/通知/, 6], [/快捷键|命令/, 7], [/关于|其他|说明/, 9], ]; for (const [re, w] of rules) { if (re.test(label)) return w; } return 8; }; const pairs = navBtns.map((b, i) => ({ b, i, w: weightOf(b.textContent), label: b.textContent })); pairs.sort((x, y) => (x.w - y.w) || (x.i - y.i)); pairs.forEach((p) => nav.appendChild(p.b)); } catch (e) {} const cnt = panel.querySelector('#ocd-sc-count'); if (cnt) cnt.textContent = secs.length + ' 个分组'; const setActive = (btn) => navBtns.forEach((x) => { const on = x === btn; x.style.background = on ? 'var(--bg-soft,rgba(255,255,255,.10))' : 'transparent'; x.style.borderColor = on ? 'var(--accent,#5b8cff)' : 'transparent'; x.style.color = on ? 'var(--fg)' : 'var(--fg-muted)'; x.style.fontWeight = on ? '700' : '400'; }); /* 滚动时高亮当前分区 */ let _navTick = null; body.addEventListener('scroll', () => { if (_navTick) return; _navTick = setTimeout(() => { _navTick = null; const top = body.scrollTop + 12; let cur = null; secs.forEach((el, i) => { if (el.offsetTop <= top) cur = navBtns[i]; }); if (cur) setActive(cur); }, 120); }); /* 搜索过滤:按分组文本过滤并高亮 */ const q = bar.querySelector('#ocd-sc-q'); /* V9.76.10(真机取证):Edge 会把本站保存的用户名自动填进这个搜索框 ✗ —— 而且自动填充还会触发一次 input ✓ 结果:7 个分组全被过滤成 display:none ✗ 面板看起来"空的"(计数显示 0 / 7)→ 用户会以为设置中心坏了 / 搬进 ⚡ 快捷设置的那个入口不见了 ✗ 对策:只有"用户真的打了字"(键盘 / 粘贴 / 剪切 / 输入法组词)才拿它当过滤条件 ✓ 其余(自动填充)一律清空忽略 ✓ */ let qTyped = false; const markTyped = () => { qTyped = true; }; ['keydown', 'paste', 'cut', 'compositionstart'].forEach((ev) => q.addEventListener(ev, markTyped, true)); const applyScFilter = () => { const kw = String(q.value || '').trim().toLowerCase(); let shown = 0; secs.forEach((el, i) => { const hitSec = !kw || (el.textContent || '').toLowerCase().indexOf(kw) !== -1; el.style.display = hitSec ? '' : 'none'; if (navBtns[i]) navBtns[i].style.display = hitSec ? '' : 'none'; if (hitSec) shown++; if (kw && hitSec && el.tagName === 'DETAILS') el.open = true; }); if (cnt) cnt.textContent = kw ? (shown + ' / ' + secs.length + ' 个分组匹配') : (secs.length + ' 个分组'); }; const dropAutofill = () => { if (qTyped) return; if (!String(q.value || '')) return; q.value = ''; applyScFilter(); }; q.addEventListener('input', () => { if (!qTyped) { dropAutofill(); return; } /* 自动填充不算用户输入 ✓ */ applyScFilter(); }); setTimeout(dropAutofill, 300); setTimeout(dropAutofill, 1200); setTimeout(dropAutofill, 2500); /* 自动填充来得晚(真机实测有时 2s 后才填)→ 多补一次 ✓ */ q.addEventListener('focus', dropAutofill); /* 用户刚点进来还没打字 → 顺手把自动填充的残留清掉 ✓ 免得他打一个字后变成"搜不到任何分组" ✗ */ bar.querySelector('#ocd-sc-expand').addEventListener('click', () => { secs.forEach((el) => { if (el.tagName === 'DETAILS') el.open = true; el.querySelectorAll('details').forEach((d) => { d.open = true; }); /* 含嵌套分组 */ }); toastSafe('已展开全部分组(共 ' + secs.length + ' 个,含嵌套项)', 'info'); }); bar.querySelector('#ocd-sc-fold').addEventListener('click', () => { secs.forEach((el) => { if (el.tagName === 'DETAILS') el.open = false; el.querySelectorAll('details').forEach((d) => { d.open = false; }); }); toastSafe('已收起全部分组', 'info'); }); } document.body.appendChild(panel); syncThemeClass(panel); makeDraggable(head, 'ocd_helper_settings_win_v1', panel); panel.querySelector('#ocd-sc-close').addEventListener('click', () => panel.remove()); setTimeout(() => { document.addEventListener('click', function scOutside(e) { if (!document.getElementById('ocd-settings-center')) { document.removeEventListener('click', scOutside); return; } if (!document.contains(e.target)) return; if (e.target.closest('#ocd-settings-center') || e.target.closest('#ocd-fab') || e.target.closest('#ocd-shell-menu')) return; panel.remove(); document.removeEventListener('click', scOutside); }); }, 0); } function btnLoading(btn, loadingText) { if (btn.disabled) return () => {}; const old = btn.textContent; btn.disabled = true; btn.textContent = loadingText || '处理中…'; let settled = false; return (doneText) => { if (settled) return; settled = true; if (doneText) { btn.textContent = doneText; setTimeout(() => { btn.textContent = old; btn.disabled = false; }, 1200); } else { btn.textContent = old; btn.disabled = false; } }; } const OPEN_ANIMS = ['slide','zoom','rise','flip3d','drop','blur','bounce','elastic','swing','typewriter','glide','floatup','rotate','shakein', 'slideup','zoomout','fadeedge','hangfall','split','slideback','heartbeat','fadein', 'rotate3d','slideboth','popchain','slideflip','wobblein','elevate','rollin','squeezenoise']; const OPEN_ANIM_KEY = { slide: 'ocd-dp-slide', zoom: 'dc-in-zoom', rise: 'dc-in-rise', flip3d: 'dc-in-flip3d', drop: 'dc-in-drop', blur: 'dc-in-blur', bounce: 'dc-in-bounce', elastic: 'dc-in-elastic', swing: 'dc-in-swing', typewriter: 'dc-in-typewriter', glide: 'dc-in-glide', floatup: 'dc-in-floatup', rotate: 'dc-in-rotate', shakein: 'dc-in-shakein', slideup: 'dc-in-slideup', zoomout: 'dc-in-zoomout', fadeedge: 'dc-in-fadeedge', hangfall: 'dc-in-hangfall', split: 'dc-in-split', slideback: 'dc-in-slideback', heartbeat: 'dc-in-heartbeat', fadein: 'dc-in-fadein', rotate3d: 'dc-in-rotate3d', slideboth: 'dc-in-slideboth', popchain: 'dc-in-popchain', slideflip: 'dc-in-slideflip', wobblein: 'dc-in-wobblein', elevate: 'dc-in-elevate', rollin: 'dc-in-rollin', squeezenoise: 'dc-in-squeezenoise' }; const OPEN_ANIM_RANDOM_EXCLUDE = ['slide', 'fadein']; function openAnimRandom() { let pool = store.get('ocd_helper_animpool_v1', null); if (!Array.isArray(pool) || !pool.length) { pool = OPEN_ANIMS.filter(k => OPEN_ANIM_RANDOM_EXCLUDE.indexOf(k) === -1); for (let i = pool.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); const tmp = pool[i]; pool[i] = pool[j]; pool[j] = tmp; } } const pick = pool.shift(); store.set('ocd_helper_animpool_v1', pool); return pick; } function openAnimKeyOf() { const pref = alhPref.get().openAnim || 'slide'; return pref === 'random' ? OPEN_ANIM_KEY[openAnimRandom()] : (OPEN_ANIM_KEY[pref] || 'ocd-dp-slide'); } function applyOpenAnim(el, dur) { if (!el) return; const key = openAnimKeyOf() || 'ocd-dp-slide'; el.style.animation = 'none'; void el.offsetWidth; el.style.animation = key + ' ' + (dur || '.45s') + ' ease-out'; } function flashBtn(btn, okText, ms) { const old = btn.textContent; btn.textContent = okText; btn.disabled = true; setTimeout(() => { btn.textContent = old; btn.disabled = false; }, ms || 2000); } function syncThemeClass(el) { const main = document.getElementById('ocd-helper-panel'); let cls; if (main && Array.from(main.classList).some(c => c.indexOf('ocd-theme-') === 0)) { cls = Array.from(main.classList).find(c => c.indexOf('ocd-theme-') === 0); } else { cls = 'ocd-theme-' + store.get(THEME_KEY, 'dark'); } el.classList.remove(...Array.from(el.classList).filter(c => c.indexOf('ocd-theme-') === 0)); el.classList.add(cls); } /* ================================================================ * §46 发布页助手面板 * ================================================================ */ function getLayout() { return store.get(LAYOUT_KEY, { float: false, width: 380, x: 80, y: 16, opacity: 100, panelBlur: 14, panelRadius: 14, panelBorderW: 1, panelBorderC: '' }); } function saveLayout(l) { store.set(LAYOUT_KEY, l); } function applyLayout(panel, layout) { if (layout.float) { panel.style.left = (layout.x || 80) + 'px'; panel.style.right = 'auto'; panel.style.top = (layout.y || 80) + 'px'; } else { panel.style.right = '16px'; panel.style.left = 'auto'; panel.style.top = '80px'; } const w = Math.max(300, Math.min(800, Number(layout.width) || 380)); panel.style.width = w + 'px'; const op = Math.max(30, Math.min(100, Number(layout.opacity) || 100)); panel.style.opacity = String(op / 100); applyPanelSkin(panel); } function applyPanelSkin(el) { const l = getLayout(); el.style.setProperty('--panel-radius', (Math.max(6, Math.min(24, Number(l.panelRadius) || 14))) + 'px'); el.style.setProperty('--panel-bw', (Math.max(0, Math.min(4, l.panelBorderW != null ? Number(l.panelBorderW) : 1))) + 'px'); if (l.panelBorderC) el.style.setProperty('--panel-bc', l.panelBorderC); else el.style.removeProperty('--panel-bc'); const blur = Math.max(0, Math.min(30, l.panelBlur != null ? Number(l.panelBlur) : 14)); el.style.backdropFilter = blur > 0 ? 'blur(' + blur + 'px)' : 'none'; } function buildPanel() { if (document.getElementById('ocd-helper-panel')) return; const panel = document.createElement('div'); panel.id = 'ocd-helper-panel'; const savedTheme = store.get(THEME_KEY, 'dark'); panel.className = 'ocd-theme-' + (THEMES.indexOf(savedTheme) !== -1 ? savedTheme : 'dark'); panel.style.cssText = [ 'position:fixed', 'top:80px', 'right:16px', 'z-index:999999', 'width:380px', 'max-height:85vh', 'overflow:hidden', 'display:flex', 'flex-direction:column', 'background:#fff', 'color:#222', 'border:1px solid #d0d0d0', 'border-radius:10px', 'box-shadow:0 4px 16px rgba(0,0,0,.25)', 'padding:10px 12px', 'font:13px/1.6 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif' ].join(';'); panel.innerHTML = '
' + ' OpenCD 工具箱 · 发布页小助手' + ' ' + ' ♺' + ' ⚙' + ' —' + ' ✕' + ' ' + '
' + '
' + ' ' + ' ' + '
' + ' ' + ' ' + ' ' + '
' + ' ' + '
' + ' ' + '
' + ' ' + '' + ' ' + ' ' + '
' + '' + ''; document.body.appendChild(panel); const input = panel.querySelector('#ocd-input'); const preview = panel.querySelector('#ocd-preview'); const mainBody = panel.querySelector('#ocd-main'); const settingsBody = panel.querySelector('#ocd-settings'); { const head = panel.querySelector('#ocd-head'); const fillRow = panel.querySelector('#ocd-fill').parentElement; const topFix = document.createElement('div'); topFix.id = 'ocd-topfix'; topFix.style.cssText = 'flex:0 0 auto;'; const scrollWrap = document.createElement('div'); scrollWrap.id = 'ocd-scroll'; scrollWrap.style.cssText = 'flex:1 1 auto;min-height:0;overflow-y:auto;margin:0 -12px;padding:0 12px;overscroll-behavior:contain;'; preview.style.maxHeight = '45vh'; preview.style.overflowY = 'auto'; topFix.appendChild(head); topFix.appendChild(input); topFix.appendChild(fillRow); topFix.appendChild(preview); const mainWrap = document.createElement('div'); while (mainBody.firstChild) mainWrap.appendChild(mainBody.firstChild); scrollWrap.appendChild(mainWrap); while (mainBody.nextSibling) scrollWrap.appendChild(mainBody.nextSibling); panel.insertBefore(topFix, mainBody); mainBody.after(scrollWrap); mainBody.remove(); input.style.width = '100%'; input.style.boxSizing = 'border-box'; const edge = document.createElement('div'); edge.style.cssText = 'position:absolute;left:0;top:0;bottom:0;width:6px;cursor:ew-resize;z-index:30;'; panel.appendChild(edge); let resizing = false, rs = 0, startW = 0; edge.addEventListener('mousedown', (e) => { resizing = true; rs = e.clientX; startW = panel.getBoundingClientRect().width; e.preventDefault(); e.stopPropagation(); }); window.addEventListener('mousemove', (e) => { if (!resizing) return; const w = Math.max(320, Math.min(800, startW + (rs - e.clientX))); panel.style.width = w + 'px'; }); window.addEventListener('mouseup', () => { if (!resizing) return; resizing = false; layout.width = Math.round(panel.getBoundingClientRect().width); saveLayout(layout); }); edge.addEventListener('dblclick', () => { layout.width = 380; applyLayout(panel, layout); saveLayout(layout); }); } const multiBtn = panel.querySelector('#ocd-multi-btn'); const multiPanel = panel.querySelector('#ocd-multi-panel'); const layout = getLayout(); applyLayout(panel, layout); const head = panel.querySelector('#ocd-head'); let dragging = false, dragOff = { x: 0, y: 0 }; head.classList.add('ocd-drag-handle'); head.style.cssText += ';cursor:move !important;'; ['ocd-theme', 'ocd-gear', 'ocd-toggle', 'ocd-panel-close'].forEach(id => { const el = panel.querySelector('#' + id); if (el) el.style.cssText += ';cursor:pointer !important;'; }); head.addEventListener('mousedown', (e) => { if (e.target.id === 'ocd-theme' || e.target.id === 'ocd-gear' || e.target.id === 'ocd-toggle') return; if (!layout.float) { layout.float = true; applyLayout(panel, layout); saveLayout(layout); } const rect = panel.getBoundingClientRect(); dragOff.x = e.clientX - rect.left; dragOff.y = e.clientY - rect.top; dragging = true; }); window.addEventListener('mousemove', (e) => { if (!dragging) return; if (!panel.classList.contains('ocd-dragging')) panel.classList.add('ocd-dragging'); panel.style.left = Math.max(0, e.clientX - dragOff.x) + 'px'; panel.style.right = 'auto'; panel.style.top = Math.max(0, e.clientY - dragOff.y) + 'px'; }); window.addEventListener('mouseup', () => { if (!dragging) return; dragging = false; panel.classList.remove('ocd-dragging'); const rect = panel.getBoundingClientRect(); layout.x = Math.round(rect.left); layout.y = Math.round(rect.top); saveLayout(layout); }); head.addEventListener('dblclick', (e) => { if (e.target.id === 'ocd-theme' || e.target.id === 'ocd-gear' || e.target.id === 'ocd-toggle') return; panel.querySelector('#ocd-toggle').click(); }); const setLayoutFloat = (on) => { layout.float = !!on; if (layout.float) { const rect = panel.getBoundingClientRect(); layout.x = Math.round(rect.left); layout.y = Math.round(rect.top); } applyLayout(panel, layout); saveLayout(layout); }; const setLayoutWidth = (wv) => { const w = Number(wv); if (w >= 300 && w <= 800) { layout.width = w; applyLayout(panel, layout); saveLayout(layout); } }; const setLayoutOpacity = (ov) => { const o = Number(ov); if (o >= 30 && o <= 100) { layout.opacity = o; applyLayout(panel, layout); saveLayout(layout); } }; const parsedNow = () => parseFolderName(input.value); function renderFsButtons() { const row = document.getElementById('ocd-fs-btns'); if (!row) return; if (typeof showDirectoryPicker !== 'function') { row.style.display = 'none'; return; } const ok = !!parsedNow(); row.style.display = ok ? 'flex' : 'none'; row.querySelectorAll('button').forEach(b => { b.disabled = !ok; }); } let autoFsRunning = false; function autoFsExec() { if (autoFsRunning) return; autoFsRunning = true; (async () => { try { for (let i = 0; i < 20; i++) { await new Promise(res => setTimeout(res, 500)); const specBtn = document.getElementById('ocd-fs-spec'); const torBtn = document.getElementById('ocd-fs-tor'); if (specBtn && torBtn && !specBtn.disabled && !torBtn.disabled) { specBtn.click(); torBtn.click(); return; } } } finally { autoFsRunning = false; } })(); } function formCheck() { const issues = []; const scope = getFormScope(); const tor = document.getElementById('torrent') || scope.querySelector('input[type="file"][name="file"]'); if (!tor || !tor.files || !tor.files.length) issues.push('❌ 种子文件:未选择'); const ta = findDescr(); const descr = ta ? String(ta.value || '') : ''; if (!descr.trim()) issues.push('❌ 简介:为空'); else if (!/^FROM:/m.test(descr)) issues.push('⚠️ 简介:缺少 FROM 行'); else if (!/专辑简介:/.test(descr)) issues.push('⚠️ 简介:缺少「专辑简介:」分区'); const tl = resolveControl('tracklist'); if (!tl || !String(tl.el.value || '').trim()) issues.push('❌ 曲目列表:为空'); const cov = resolveControl('cover'); if (!cov || !String(cov.el.value || '').trim()) issues.push('❌ 封面图片:为空'); const spec = resolveControl('spectrogram'); if (!spec || !String(spec.el.value || '').trim()) issues.push('⚠️ 频谱图:为空'); const sub = resolveControl('subtitle'); if (!sub || !String(sub.el.value || '').trim()) issues.push('💡 副标题:为空(如有版本说明建议填写)'); const tagsInput = document.getElementById('tags'); if (!tagsInput || !String(tagsInput.value || '').trim()) { issues.push('⚠️ 分类标签:未选择(预览框右侧 🏷️分类标签 中勾选)'); } return issues; } { const clipBtn = document.createElement('button'); clipBtn.id = 'ocd-clip'; clipBtn.textContent = '📋'; clipBtn.title = '读取剪贴板内容并自动解析填充'; clipBtn.style.cssText = 'flex:0 0 44px;padding:5px 0;cursor:pointer;'; clipBtn.style.display = getFsOpts().clipBtn ? '' : 'none'; const fillBtn = panel.querySelector('#ocd-fill'); if (fillBtn) fillBtn.parentElement.appendChild(clipBtn); clipBtn.addEventListener('click', async () => { try { const text = await navigator.clipboard.readText(); if (!text || !text.trim()) { preview.style.display = 'block'; fromStatusEl('剪贴板').innerHTML = '剪贴板为空'; return; } input.value = text.trim(); clipBtn.textContent = '✅'; setTimeout(() => { clipBtn.textContent = '📋'; }, 1200); doFill(); } catch (e) { preview.style.display = 'block'; fromStatusEl('剪贴板').innerHTML = '❌ 剪贴板读取失败:' + esc(e.message || '') + ''; } }); } if (!window.__ocdQuickOutsideBound) { window.__ocdQuickOutsideBound = true; document.addEventListener('click', (e) => { document.querySelectorAll('#ocd-quick-pop').forEach(pop => { if (pop.style.display !== 'block') return; if (e.target.closest('#ocd-quick-pop') || e.target.closest('.ocd-quick-act') || e.target.closest('#ocd-quick-multi')) return; pop.style.display = 'none'; }); quickPopFn = null; }); } let activeTags = []; let tagSyncing = false; let quickPopFn = null; function syncTagsToForm() { tagSyncing = true; const addBox = document.getElementById('divTags'); const addedBox = document.getElementById('divAddedTags'); if (!addBox || !addedBox) { tagSyncing = false; return; } const curSel = Array.from(addedBox.querySelectorAll('a.removetag')).map(a => String(a.getAttribute('value') || a.id || '')).filter(Boolean); activeTags.forEach(v => { if (curSel.indexOf(v) !== -1) return; const a = addBox.querySelector('a.tag[value="' + v + '"]'); if (a) a.click(); }); curSel.forEach(v => { if (activeTags.indexOf(v) !== -1) return; const a = addedBox.querySelector('a.removetag[value="' + v + '"]'); if (a) a.click(); }); tagSyncing = false; } function readTagsFromForm() { const addedBox = document.getElementById('divAddedTags'); if (!addedBox) return []; return Array.from(addedBox.querySelectorAll('a.removetag')) .map(a => String(a.getAttribute('value') || a.id || '')).filter(Boolean); } function toggleTagPop(anchorBtn) { let pop = document.getElementById('ocd-tag-pop'); if (!pop) { pop = document.createElement('div'); pop.id = 'ocd-tag-pop'; pop.style.cssText = 'display:none;position:fixed;z-index:' + ocdTopZ(10) + ';border:1px solid var(--border);border-radius:10px;padding:8px;background:var(--bg);color:var(--fg);box-shadow:var(--shadow);max-width:340px;'; document.body.appendChild(pop); } if (pop.style.display === 'block') { pop.style.display = 'none'; return; } activeTags = readTagsFromForm(); pop.innerHTML = ''; TAG_GROUPS.forEach(group => { const row = document.createElement('div'); row.style.cssText = 'display:flex;flex-wrap:wrap;gap:4px;margin:3px 0;'; group.row.forEach(([name, label]) => { const on = activeTags.indexOf(name) !== -1; const item = document.createElement('span'); item.textContent = label; item.dataset.tag = name; item.style.cssText = 'cursor:pointer;padding:3px 8px;border-radius:6px;font-size:11px;' + (on ? 'border:1px solid var(--ok);color:var(--ok);background:var(--bg-soft);' : 'border:1px solid var(--border);color:var(--fg);'); item.addEventListener('click', () => { const i = activeTags.indexOf(name); if (i === -1) activeTags.push(name); else activeTags.splice(i, 1); const nowOn = activeTags.indexOf(name) !== -1; item.style.border = nowOn ? '1px solid var(--ok)' : '1px solid var(--border)'; item.style.color = nowOn ? 'var(--ok)' : 'var(--fg)'; item.style.background = nowOn ? 'var(--bg-soft)' : ''; syncTagsToForm(); }); row.appendChild(item); }); pop.appendChild(row); }); const panelEl = document.getElementById('ocd-helper-panel'); if (panelEl) { pop.className = Array.from(panelEl.classList).filter(c => c.indexOf('ocd-theme-') === 0).join(' '); } const pvRect = preview.getBoundingClientRect(); const btnRect = anchorBtn.getBoundingClientRect(); pop.style.display = 'block'; const pw = Math.max(300, pop.offsetWidth); let x = btnRect.left - pw - 8; if (x < 8) x = Math.min(btnRect.right + 8, window.innerWidth - pw - 8); let y = pvRect.top + 2; if (y + pop.offsetHeight > window.innerHeight - 8) y = window.innerHeight - pop.offsetHeight - 8; pop.style.left = Math.max(8, x) + 'px'; pop.style.top = Math.max(8, y) + 'px'; } document.addEventListener('click', (e) => { if (tagSyncing) return; const pop = document.getElementById('ocd-tag-pop'); if (!pop || pop.style.display !== 'block') return; if (e.target.closest('#ocd-tag-pop') || e.target.closest('#ocd-tag-btn')) return; pop.style.display = 'none'; }); function bindQuickZone() { const multiBtnQ = document.getElementById('ocd-quick-multi'); if (multiBtnQ) { multiBtnQ.addEventListener('click', () => { if (multiBtnQ.dataset.cd) return; multiBtnQ.dataset.cd = '1'; multiBtnQ.textContent = '🔍检测中···'; runMultiDetect(null); }); } document.querySelectorAll('.ocd-quick-act').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const fn = btn.dataset.fn; const pop = document.getElementById('ocd-quick-pop'); if (!pop) return; if (quickPopFn === fn) { pop.style.display = 'none'; quickPopFn = null; return; } quickPopFn = fn; try { renderQuickPop(fn, pop, btn); } catch (err) { console.error('[OCD Helper] renderQuickPop 异常:', err.message, err.stack && err.stack.split('\n')[1]); } pop.style.display = 'block'; }); }); const tagBtn = document.getElementById('ocd-tag-btn'); const autoTagBtn = document.getElementById('ocd-autotag-btn'); if (autoTagBtn) { /* V9.73.43:改由文档级事件委托统一处理 ✓ 此处不再直绑,避免重复触发 ✗ */ } if (tagBtn) { tagBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleTagPop(tagBtn); }); } } function renderQuickPop(fn, pop, anchorBtn) { const platforms = getPlatforms().filter(x => x.enabled !== false && !x.manual); let list = platforms; if (fn === '简介') list = platforms.filter(p => p.api !== 'apple' && p.api !== 'deezer'); else if (fn === '曲目') list = platforms.filter(p => TRACK_APIS.indexOf(p.api) !== -1); else if (fn === '封面') list = platforms.filter(p => p.api !== 'amazon'); else if (fn === '跳转' || fn === '替换') list = platforms; list = list.length ? list : platforms; pop.innerHTML = ''; if (pop.parentElement !== document.body) { document.querySelectorAll('#ocd-quick-pop').forEach(el => { if (el !== pop) el.remove(); }); document.body.appendChild(pop); const panelEl = document.getElementById('ocd-helper-panel'); if (panelEl) { const themeCls = Array.from(panelEl.classList).find(c => c.indexOf('ocd-theme-') === 0); if (themeCls) pop.classList.add(themeCls); } } const parsed = lastDetectParsed || parseFolderNameLoose(input.value); if (!platforms.length) { pop.innerHTML = '
无启用平台
'; return; } if (!parsed) { pop.innerHTML = '
请先在上方输入文件夹名
'; return; } list.forEach(p => { const rec = multiRows[p.api]; const state = rec ? rec.state : 'idle'; const hasHit = state === 'ok' && rec.result && rec.result.url; let stColor, stMark; if (fn === '简介' || fn === '曲目' || fn === '封面') { const btn = rec && rec.actions ? Array.from(rec.actions.querySelectorAll('button')).find(b => b.textContent.trim().indexOf(fn) === 0) : null; const cls = btn ? btn.className : ''; if (cls.indexOf('ocd-btn-ok') !== -1) { stColor = 'var(--ok)'; stMark = '●'; } else if (cls.indexOf('ocd-btn-err') !== -1) { stColor = 'var(--err)'; stMark = '●'; } else if (btn) { stColor = 'var(--accent)'; stMark = '◐'; } else { stColor = 'var(--fg-muted)'; stMark = '○'; } } else { stColor = state === 'ok' ? 'var(--ok)' : state === 'fail' ? 'var(--err)' : state === 'running' ? 'var(--accent)' : 'var(--fg-muted)'; stMark = state === 'ok' ? '●' : state === 'fail' ? '●' : state === 'running' ? '◐' : '○'; } const row = document.createElement('div'); row.style.cssText = 'display:flex;align-items:center;gap:6px;padding:5px 8px;margin:2px 0;' + 'border:1px solid ' + stColor + ';border-radius:7px;font-size:11px;cursor:pointer;' + 'color:' + stColor + ';transition: background .15s;'; row.addEventListener('mouseenter', () => { row.style.background = 'var(--bg-soft)'; }); row.addEventListener('mouseleave', () => { row.style.background = ''; }); const mark = document.createElement('span'); mark.textContent = stMark; mark.style.cssText = 'flex:0 0 auto;'; row.appendChild(mark); const name = document.createElement('span'); name.textContent = p.name + (hasHit ? '' : '(未检测)'); name.title = hasHit ? rec.result.url : (state === 'fail' ? '该平台检测失败:' + (rec.status ? rec.status.textContent : '') : '点击执行时自动搜索该平台'); name.style.cssText = 'flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600;'; row.appendChild(name); row.addEventListener('click', async () => { row.style.pointerEvents = 'none'; try { const parsedQ = lastDetectParsed || parseFolderNameLoose(input.value); if (!parsedQ) return; let r = hasHit ? rec.result : await ensureHitFix(p, parsedQ); if (!r || !r.url) { preview.style.display = 'block'; fromStatusEl('快捷').innerHTML = '❌ ' + esc(p.name) + ' 未搜到匹配专辑'; return; } if (fn === '跳转') { window.open(r.url, '_blank'); return; } if (fn === '替换') { if (applyFromLine(p.name, r.url)) { fromStatusEl('FROM').innerHTML = '✅ FROM 已替换为:' + esc(p.name) + ' · ' + esc(r.name) + ''; } return; } const ck2 = p.api + '|' + r.url; let d2 = detailCache[ck2]; if (!d2) { d2 = await fetchAlbumDetail(p, r, parsedQ); detailCache[ck2] = d2; } preview.style.display = 'block'; if (fn === '简介') { if (d2.intro && isJunkIntro(d2.intro)) { const queue2 = introFallbackQueue(parsedQ, p.api); for (const qp of queue2) { try { const qpHit = await ensureHitFix(qp, parsedQ); if (!qpHit || !qpHit.url) continue; const ck3 = qp.api + '|' + qpHit.url; let d3 = detailCache[ck3]; if (!d3) { d3 = await fetchAlbumDetail(qp, qpHit, parsedQ); detailCache[ck3] = d3; } if (d3.intro) { d2.intro = d3.intro; break; } } catch (e) {} } if (!d2.intro || isJunkIntro(d2.intro)) { fromStatusEl('简介').innerHTML = '⚠️ ' + esc(p.name) + ' 无有效简介(占位文案已拒),各平台换源也未取得'; return; } } const ins = applyDescIntro(d2.intro); if (rec && rec.actions) { const b = Array.from(rec.actions.querySelectorAll('button')).find(x => x.textContent.trim().indexOf('简介') === 0); if (b) setBtnState(b, ins.ok ? 'ok' : 'err'); } fromStatusEl('简介').innerHTML = ins.ok ? '✅ 简介 已追加(' + esc(p.name) + ')' : '❌ 简介 追加失败:' + esc(d2.introNote || '') + ''; } else if (fn === '曲目') { const ins = applyTracklist(d2.tracks); fromStatusEl('曲目').innerHTML = ins.ok ? '✅ 曲目 已填入(' + esc(p.name) + ',' + d2.tracks.length + ' 首)' : '❌ 曲目 填入失败:' + esc(d2.tracksNote || '') + ''; } else if (fn === '封面') { let cover = d2.cover; if (!cover) { try { cover = await getCoverUrl(p, r, parsedQ); } catch (e) {} if (d2) d2.cover = cover; } if (cover) { const ctrl = resolveControl('cover'); const okFill = ctrl ? applyValue(ctrl, cover) : false; fromStatusEl('封面').innerHTML = okFill ? '✅ 封面 已填入(' + esc(p.name) + ')' : '⚠️ 封面已获取但未找到「封面图片」控件'; } else { fromStatusEl('封面').innerHTML = '❌ 封面 获取失败(' + esc(p.name) + ')'; } } } finally { row.style.pointerEvents = ''; } }); pop.appendChild(row); }); const place = () => { const pvRect = preview.getBoundingClientRect(); const btnRect = anchorBtn.getBoundingClientRect(); const pw = Math.max(180, pop.offsetWidth); const ph = pop.offsetHeight; let x = btnRect.left - pw - 8; if (x < 8) x = Math.min(btnRect.right + 8, window.innerWidth - pw - 8); let y = pvRect.top + 2; if (y + ph > window.innerHeight - 8) y = window.innerHeight - ph - 8; y = Math.max(8, y); pop.style.left = x + 'px'; pop.style.top = y + 'px'; }; pop.style.position = 'fixed'; pop.style.display = 'block'; pop.style.zIndex = '1000001'; place(); requestAnimationFrame(place); } function bindFsButtonEvents() { const specBtn = document.getElementById('ocd-fs-spec'); const torBtn = document.getElementById('ocd-fs-tor'); if (specBtn && !specBtn.dataset.bound) { specBtn.dataset.bound = '1'; specBtn.addEventListener('click', async () => { const p = parsedNow(); if (!p) return; specBtn.disabled = true; specBtn.textContent = '⏳选择目录…'; const dir = await pickDir('spec_dir', '频谱图目录'); specBtn.disabled = false; specBtn.textContent = '📊频谱图'; if (!dir) return; specBtn.textContent = '⏳匹配中…'; const fo = getFsOpts(); const file = await matchFileInDir(dir, [p.year, p.artist, p.album], fo.specExts, fo.specKw); specBtn.textContent = '📊频谱图'; preview.style.display = 'block'; const st = fromStatusEl('频谱图'); if (!file) { st.innerHTML = '❌ 频谱图目录中未找到匹配文件(需文件夹名含专辑名,或文件名含匹配词)'; return; } let fi = findSpecFileInput(); for (let wait = 0; !fi && wait < 2; wait++) { await new Promise(res => setTimeout(res, 1000)); fi = findSpecFileInput(); } if (fi && injectFileFix(fi, file)) { st.innerHTML = '✅ 频谱图已选入:' + esc(file.name) + '(请点击站内「上传」按钮完成上传)'; } else { st.innerHTML = '❌ 未找到频谱图上传控件(input#file),请手动选择文件'; } }); } if (torBtn && !torBtn.dataset.bound) { torBtn.dataset.bound = '1'; torBtn.addEventListener('click', async () => { const p = parsedNow(); if (!p) return; torBtn.disabled = true; torBtn.textContent = '⏳选择目录…'; const dir = await pickDir('torrent_dir', '种子目录'); torBtn.disabled = false; torBtn.textContent = '🌱种子文件'; if (!dir) return; torBtn.textContent = '⏳匹配中…'; const fo = getFsOpts(); const file = await matchTorrentInDir(dir, matchKwOf(p), fo.torKw, (p && (p.raw || p.title)) || ''); torBtn.textContent = '🌱种子文件'; preview.style.display = 'block'; const st = fromStatusEl('种子文件'); const torInput = document.getElementById('torrent') || getFormScope().querySelector('input[type="file"][name="file"]'); if (!file) { st.innerHTML = '❌ 种子目录中未找到匹配的 .torrent(需文件夹名含专辑名,或文件名含匹配词)'; return; } if (torInput && injectFileFix(torInput, file)) { st.innerHTML = '✅ 种子文件已选入:' + esc(file.name) + ''; } else { st.innerHTML = '❌ 种子文件控件注入失败,请手动选择'; } }); } } function showParsedPreview(raw) { const s = String(raw || '').trim(); if (!s) { preview.style.display = 'none'; return; } const parsed = parseFolderName(s); preview.style.display = 'block'; if (!parsed) { preview.innerHTML = '解析失败,请检查格式:年份 艺人 - 专辑 WEB …(查重仍按容错规则继续)' + (getDupOpts().enabled ? '
检测状态⏳ 查重中…
' : ''); return; } const p = detectPlatform(platformTailOf(parsed.subtitle)); const rightOn = getFsOpts().quickZone !== false; const _decodeEl = document.createElement('textarea'); const shDecodeEntities = (s) => { try { _decodeEl.innerHTML = String(s); return _decodeEl.value; } catch (e) { return String(s); } }; const pvRow = (k, v) => { let rawText = shDecodeEntities(v).replace(/\s+/g, ' ').trim(); const titleAttr = rawText.length > 24 ? ' title="' + esc(rawText) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') + '"' : ''; return '
' + '' + k + '' + '' + v + '' + '
'; }; const leftHtml = pvRow('发行年份', esc(parsed.year)) + pvRow('艺术家名', esc(parsed.artist)) + pvRow('专辑名称', esc(parsed.album)) + pvRow('识别平台', (p ? esc(p.name) : '未识别')) + (getDupOpts().enabled ? pvRow('检测状态', '⏳ 查重中…') : ''); let rightHtml = ''; if (rightOn) { rightHtml = '
' + ' ' + '
' + '
' + ['跳转', '替换', '简介', '曲目', '封面'].map(f => ' ' ).join('') + '
' + ''; } preview.innerHTML = '
' + '
' + leftHtml + '
' + (rightOn ? '
' + '
' + rightHtml + '
' + ' ' + ' ' + ' ' + ' ' + '
' + '
' : '') + '
'; if (getDupOpts().enabled) { const kwNow = dupKeywordOf(input.value); setTimeout(() => renderDupStatusRow(kwNow), 100); } renderFsButtons(); bindFsButtonEvents(); const oldFlow = document.getElementById('ocd-from-status'); if (oldFlow) oldFlow.innerHTML = ''; if (getFsOpts().quickZone !== false) { bindQuickZone(); document.querySelectorAll('#ocd-quick-pop').forEach(el => { if (!preview.contains(el)) el.remove(); }); quickPopFn = null; } } const showResult = (r) => { const st = fromStatusEl('填充'); let html = '✅ 已填入:' + esc(r.ok.join('、') || '(无)') + ''; if (r.skip && r.skip.length) html += '
跳过(不填充):' + esc(r.skip.join('、')) + ''; if (r.fail.length) html += '
⚠️ 未找到:' + esc(r.fail.join('、')) + '(点 ⚙ → 探测页面字段,再到高级控件绑定里修正)'; st.innerHTML = html; preview.style.display = 'block'; }; async function autoAfterFill(parsed, hit) { const opts = getFromOpts(); const p = detectPlatform(platformTailOf(parsed.subtitle)); const st = fromStatusEl(); const queue = platformQueue(parsed, null); const primary = p && p.api ? p : null; if (!queue.length) { st.innerHTML = '没有可用的自动平台(⚙ → 音乐平台设置 中启用)'; return; } if (!primary) { st.innerHTML = '未识别到 WEB 平台备注,按内容特征自动选择平台(' + (/[\u4e00-\u9fff]/.test(parsed.artist + parsed.album) ? '中文→国内优先' : '外语→Qobuz/Apple优先') + ')'; } async function ensureHit(qp) { if (fromPlat && qp.api === fromPlat.api && result && result.url) return result; if (multiRows[qp.api] && multiRows[qp.api].state === 'ok' && multiRows[qp.api].result) return multiRows[qp.api].result; const ck = qp.api + '|' + (parsed.artist || '') + '|' + (parsed.album || '') + '|' + (parsed.year || ''); if (!searchCache[ck] || Date.now() - (searchCache[ck].ts || 0) > 60000) { searchCache[ck] = { ts: Date.now(), p: searchAlbum(qp, parsed.artist, parsed.album, parsed.year) }; } const sr = await searchCache[ck].p; return (sr && !sr.error && sr.url) ? sr : null; } let result = hit; let fromPlat = primary; if (!result) { const tryOrder = primary ? [primary].concat(queue.filter(q => q.api !== primary.api)) : queue; for (const qp of tryOrder) { st.innerHTML = '⏳ 正在从 ' + esc(qp.name) + ' 搜索专辑「' + esc(parsed.artist + ' ' + parsed.album) + '」…'; const ck = qp.api + '|' + (parsed.artist || '') + '|' + (parsed.album || '') + '|' + (parsed.year || ''); if (!searchCache[ck] || Date.now() - (searchCache[ck].ts || 0) > 60000) { searchCache[ck] = { ts: Date.now(), p: searchAlbum(qp, parsed.artist, parsed.album, parsed.year) }; } const r = await searchCache[ck].p; if (r && !r.error && r.url) { result = r; fromPlat = qp; break; } } if (!result || !result.url) { fromStatusEl('FROM').innerHTML = '❌ 所有平台均未搜到匹配专辑'; return; } } if (!fromPlat) fromPlat = p || queue[0]; if (applyFromLine(fromPlat.name, result.url)) { fromStatusEl('FROM').innerHTML = '✅ FROM 已填入:' + esc(fromPlat.name) + ' · ' + esc(result.name) + (result.subtitleHint ? '(' + esc(result.subtitleHint) + ')' : '') + (result.artist ? ' / ' + esc(result.artist) : '') + ''; } else { fromStatusEl('FROM').innerHTML = '⚠️ 未找到简介输入框,FROM 未填入'; return; } if (opts.desc) { const tryPlats = []; if (primary && ['apple', 'deezer'].indexOf(primary.api) === -1) tryPlats.push(primary); const cnOnly = isChineseParsed(parsed); for (const q of queue) { if (['apple', 'deezer'].indexOf(q.api) !== -1) continue; if (cnOnly && INTRO_CN_ONLY.indexOf(q.api) === -1) continue; if (!tryPlats.some(t => t.api === q.api)) tryPlats.push(q); } let introDone = false; for (const qp of tryPlats) { const stI = fromStatusEl('简介'); stI.innerHTML = '⏳ 正在抓取 ' + esc(qp.name) + ' 专辑简介…'; try { const qpHit = await ensureHit(qp); if (!qpHit || !qpHit.url) continue; const r = await fetchAlbumDetail(qp, qpHit, parsed); if (!r.intro) continue; const ins = applyDescIntro(r.intro); if (ins.ok) { stI.innerHTML = '✅ 简介 已追加(' + esc(qp.name) + ')'; introDone = true; break; } } catch (e) {} } if (!introDone) fromStatusEl('简介').innerHTML = '❌ 简介各平台均未取得'; } if (opts.tracks) { const tryPlats = []; if (primary && TRACK_APIS.indexOf(primary.api) !== -1) tryPlats.push(primary); for (const q of queue) { if (TRACK_APIS.indexOf(q.api) === -1) continue; if (!tryPlats.some(t => t.api === q.api)) tryPlats.push(q); } let trkDone = false; for (const qp of tryPlats) { const stT = fromStatusEl('曲目'); stT.innerHTML = '⏳ 正在抓取 ' + esc(qp.name) + ' 专辑曲目…'; try { const qpHit = await ensureHit(qp); if (!qpHit || !qpHit.url) continue; const r = await fetchAlbumDetail(qp, qpHit, parsed); const ins = applyTracklist(r.tracks, r.tracksHtml); if (ins.ok) { stT.innerHTML = '✅ 曲目 已填入(' + esc(qp.name) + ')'; trkDone = true; break; } } catch (e) {} } if (!trkDone) { const stT = fromStatusEl('曲目'); if (stT.innerHTML.indexOf('⏳ 正在抓取') !== -1) stT.innerHTML = '❌ 曲目各平台均未取得'; } } { let cover = '', coverPlat = null; for (const qp of tryPlatsOf()) { const stC = fromStatusEl('封面'); stC.innerHTML = '⏳ 正在获取 ' + esc(qp.name) + ' 封面图…'; try { const qpHit = await ensureHit(qp); if (!qpHit || !qpHit.url) continue; let c = ''; const cached = detailCache[qp.api + '|' + qpHit.url]; if (cached && cached.cover) { c = cached.cover; } else { c = await getCoverUrl(qp, qpHit, parsed); if (cached) cached.cover = c; } if (c) { cover = c; coverPlat = qp; break; } } catch (e) {} } const stC = fromStatusEl('封面'); if (cover) { const ctrl = resolveControl('cover'); const okFill = ctrl ? applyValue(ctrl, cover) : false; stC.innerHTML = okFill ? '✅ 封面 已填入(' + esc(coverPlat.name) + ')' : '⚠️ 封面 已获取但未找到「封面图片」控件'; } else { stC.innerHTML = '❌ 封面 获取失败(各平台)'; } } function tryPlatsOf() { const arr = []; if (primary) arr.push(primary); for (const q of queue) if (!arr.some(t => t.api === q.api)) arr.push(q); return arr; } } let fillBusy = false; function doFill() { if (fillBusy) return; const checkBtn = panel.querySelector('#ocd-fill'); autoRetryStopped = true; const parsed = parseFolderName(input.value); showParsedPreview(input.value); if (!parsed) return; fillBusy = true; if (checkBtn) { checkBtn.disabled = true; checkBtn.textContent = '⏳ 处理中…'; } setTimeout(() => { if (checkBtn) { checkBtn.disabled = false; checkBtn.textContent = '解析并填充'; } fillBusy = false; }, 5000); const r1 = fillParsed(parsed); if (getFsOpts().auto && typeof showDirectoryPicker === 'function') { autoFsExec(); } applyPresetsWithRetry(3, 400, (r2) => { showResult({ ok: [...r1.ok, ...r2.ok], fail: [...r1.fail, ...r2.fail], skip: r2.skip }); addHistory({ title: parsed.year + ' ' + parsed.artist + ' - ' + parsed.album, artist: parsed.artist, album: parsed.album, year: parsed.year }); saveDraft(); let cachedHit = null; const plat = detectPlatform(platformTailOf(parsed.subtitle)); if (plat && multiRows[plat.api] && multiRows[plat.api].state === 'ok' && multiRows[plat.api].result) { cachedHit = multiRows[plat.api].result; } else { for (const k of Object.keys(multiRows)) { if (multiRows[k].state === 'ok' && multiRows[k].result && multiRows[k].result.url) { cachedHit = multiRows[k].result; break; } } } autoAfterFill(parsed, cachedHit); }); } const pvFloat = document.createElement('div'); pvFloat.id = 'ocd-pv-float'; pvFloat.style.cssText = [ 'display:none', 'position:fixed', 'z-index:1000010', 'max-width:420px', 'max-height:320px', 'overflow-y:auto', 'background:#1e1a14', 'color:#f5f0e8', 'border:1px solid var(--border,#ccc)', 'border-radius:10px', 'box-shadow:0 8px 28px rgba(0,0,0,.35)', 'padding:8px 10px', 'font-size:12px', 'line-height:1.6', 'white-space:pre-wrap', 'word-break:break-all', 'pointer-events:none', 'transition:opacity .15s ease' ].join(';'); document.body.appendChild(pvFloat); function showPvFloat(content, type, anchor) { pvFloat.style.zIndex = '10000010'; if (type === 'img') { pvFloat.innerHTML = ''; } else { pvFloat.textContent = String(content || '').slice(0, 1200) || '(无内容)'; } pvFloat.style.display = 'block'; pvFloat.style.opacity = '1'; const place = () => { const rect = anchor.getBoundingClientRect(); const w = pvFloat.offsetWidth || 200, h = pvFloat.offsetHeight || 120; let y = rect.top - h - 8; if (y < 8 && rect.bottom + h + 8 < window.innerHeight - 8) y = rect.bottom + 8; y = Math.max(8, Math.min(y, window.innerHeight - h - 8)); let x = rect.left; if (x + w > window.innerWidth - 8) x = window.innerWidth - w - 8; x = Math.max(8, x); pvFloat.style.left = x + 'px'; pvFloat.style.top = y + 'px'; pvFloat.style.visibility = 'visible'; }; pvFloat.style.visibility = 'hidden'; requestAnimationFrame(place); const img = pvFloat.querySelector('img'); if (img) { img.onload = place; img.onerror = place; } } function hidePvFloat() { pvFloat.style.opacity = '0'; setTimeout(() => { if (pvFloat.style.opacity === '0') pvFloat.style.display = 'none'; }, 160); } let multiOpened = false; function multiRowOf(p) { if (multiRows[p.api]) return multiRows[p.api]; const row = document.createElement('div'); row.style.cssText = 'margin:6px 0;border:1px solid var(--border);border-radius:8px;font-size:12px;display:flex;align-items:stretch;overflow:hidden;'; const left = document.createElement('div'); left.style.cssText = 'flex:0 0 auto;min-width:88px;padding:6px 8px;display:flex;flex-direction:column;align-items:center;justify-content:flex-start;gap:4px;'; const nameBox = document.createElement('span'); nameBox.textContent = p.name; nameBox.title = p.name; nameBox.style.cssText = 'font-weight:700;color:var(--fg-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:center;align-self:stretch;'; const candBtn = document.createElement('button'); candBtn.textContent = '候选列表'; candBtn.title = '展开/收起该平台的候选专辑(匹配候选在前、被否决候选殿后;点击候选即手动定版)'; candBtn.style.cssText = 'padding:2px 8px;cursor:pointer;font-size:11px;max-width:96px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;'; left.appendChild(nameBox); left.appendChild(candBtn); const divider = document.createElement('div'); divider.style.cssText = 'flex:0 0 0;border-left:1px solid var(--border);margin:0 2px;border-right:1px solid var(--border);'; const right = document.createElement('div'); right.style.cssText = 'flex:1;min-width:0;padding:6px 8px;'; const status = document.createElement('div'); status.style.cssText = 'color:var(--fg-muted);word-break:break-all;'; status.textContent = '待检测'; const actions = document.createElement('div'); actions.style.cssText = 'display:none;margin-top:4px;gap:6px;flex-wrap:wrap;'; right.appendChild(status); right.appendChild(actions); row.appendChild(left); row.appendChild(divider); row.appendChild(right); panel.querySelector('#ocd-multi-box').appendChild(row); const rec = { row, left, nameBox, candBtn, status, actions, right, state: 'idle', result: null, candOpen: false, cands: null, candLoading: false, candBox: null }; multiRows[p.api] = rec; return rec; } const mkBtn = (text) => { const b = document.createElement('button'); b.textContent = text; return b; }; function setBtnState(btn, state) { btn.classList.remove('ocd-btn-ok', 'ocd-btn-err'); if (state === 'ok') btn.classList.add('ocd-btn-ok'); else if (state === 'err') btn.classList.add('ocd-btn-err'); } const FEAT_INTRO = { apple: false, deezer: false }; const FEAT_TRACKS = {}; function renderMultiResult(p, rec, r) { const introOn = FEAT_INTRO[p.api] !== false; const tracksOn = FEAT_TRACKS[p.api] !== false; const parsed = lastDetectParsed; rec.actions.innerHTML = ''; if (r && !r.error && r.url) { rec.state = 'ok'; rec.result = r; rec.row.style.borderColor = 'var(--ok)'; rec.nameBox.style.color = 'var(--ok)'; rec.status.style.color = 'var(--ok)'; const showYear = isValidYear(r.year) ? r.year : (parsed && isValidYear(parsed.year) ? parsed.year : ''); const showArtist = r.artist || (parsed ? parsed.artist : ''); const info = []; if (showYear) info.push(showYear); if (showArtist) info.push(showArtist); if (r.name) info.push(r.name + (r.subtitleHint ? ' (' + r.subtitleHint + ')' : '')); rec.status.textContent = '✅ 匹配成功' + (info.length ? ' · ' + info.join(' · ') : ''); rec.status.title = r.url; rec.actions.style.display = 'flex'; const jump = mkBtn('跳转'); jump.title = '在新窗口打开专辑链接'; setBtnState(jump, 'ok'); jump.addEventListener('click', () => window.open(r.url, '_blank')); rec.actions.appendChild(jump); const rep = mkBtn('替换'); rep.title = '把简介首行替换为 FROM:[url=专辑链接]' + p.name + '[/url]'; setBtnState(rep, 'ok'); rep.addEventListener('click', () => { if (applyFromLine(p.name, r.url)) { rep.textContent = '已替换'; preview.style.display = 'block'; if (r.subtitleHint) { const subCtrl2 = resolveControl('subtitle'); const curSub2 = subCtrl2 && subCtrl2.el ? String(subCtrl2.el.value || '').trim() : ''; if (subCtrl2 && !curSub2) setNativeValue(subCtrl2.el, r.subtitleHint); } fromStatusEl('FROM').innerHTML = '✅ FROM 已替换为:' + esc(p.name) + ' · ' + esc(r.name) + (r.subtitleHint ? '(' + esc(r.subtitleHint) + ')' : '') + (showArtist ? ' / ' + esc(showArtist) : '') + ''; setTimeout(() => { rep.textContent = '替换'; }, 2000); } else { preview.style.display = 'block'; fromStatusEl('FROM').innerHTML = '⚠️ 未找到简介输入框,替换失败'; } }); rec.actions.appendChild(rep); const introBtn = mkBtn('简介'); introBtn.title = '抓取该平台专辑页的简介信息(绿=抓取成功,点击填入简介;红=失败)'; if (introOn) rec.actions.appendChild(introBtn); const runIntro = async () => { introBtn.disabled = true; introBtn.textContent = '抓取中…'; setBtnState(introBtn, ''); let cached = detailCache[p.api + '|' + r.url]; if (!cached) { cached = await fetchAlbumDetail(p, r, parsed); detailCache[p.api + '|' + r.url] = cached; } if (!cached.intro) { const queue2 = introFallbackQueue(parsed, p.api); for (const qp of queue2) { try { const qpHit = await ensureHitFix(qp, parsed); if (!qpHit || !qpHit.url) continue; const ck2 = qp.api + '|' + qpHit.url; let d2 = detailCache[ck2]; if (!d2) { d2 = await fetchAlbumDetail(qp, qpHit, parsed); detailCache[ck2] = d2; } const arOk = !qpHit.artist || artistMatch(parsed.artist, qpHit.artist, true); if (d2.intro && arOk) { cached.intro = d2.intro; cached.introNote = ''; cached.introFrom = qp.name; setBtnState(introBtn, 'ok'); introBtn.textContent = '简介↺'; introBtn.title = '本平台无简介,已自动换源至 ' + qp.name + ' 取得(点击填入)'; break; } } catch (e) {} } } introBtn.disabled = false; introBtn.textContent = '简介'; if (cached.intro) { setBtnState(introBtn, 'ok'); introBtn.title = (cached.introFrom ? '简介来自 ' + cached.introFrom + '(本平台无有效简介),点击填入简介第二行(悬浮可预览)' : '简介抓取成功,点击填入简介第二行(悬浮可预览)'); introBtn.onmouseenter = () => showPvFloat(cached.intro, 'text', introBtn); introBtn.onmouseleave = hidePvFloat; } else { setBtnState(introBtn, 'err'); introBtn.title = '简介抓取失败:' + (cached.introNote || '无数据'); } }; if (introOn) runIntro().catch(() => setBtnState(introBtn, 'err')); introBtn.addEventListener('click', async () => { if (introBtn.disabled) return; let cached = detailCache[p.api + '|' + r.url]; if (!cached || !cached.intro) { await runIntro(); cached = detailCache[p.api + '|' + r.url]; } if (cached && cached.intro) { const ins = applyDescIntro(cached.intro); preview.style.display = 'block'; if (rec && rec.actions) { const b = Array.from(rec.actions.querySelectorAll('button')).find(x => x.textContent.trim().indexOf('简介') === 0); if (b) setBtnState(b, ins.ok ? 'ok' : 'err'); } fromStatusEl('简介').innerHTML = ins.ok ? '✅ 简介 已追加(' + esc(p.name) + ')' : '❌ 简介 追加失败:' + esc(ins.reason || '') + ''; } else { preview.style.display = 'block'; fromStatusEl('简介').innerHTML = '❌ 简介 获取失败:' + esc((cached && cached.introNote) || '无数据') + ''; } }); if (TRACK_APIS.indexOf(p.api) !== -1 && tracksOn) { const trkBtn = mkBtn('曲目'); trkBtn.title = '抓取该平台专辑的曲目列表(绿=成功,点击填入曲目列表;红=失败)'; trkBtn.dataset.base = '曲目'; rec.actions.appendChild(trkBtn); const runTrk = async () => { trkBtn.disabled = true; trkBtn.textContent = '抓取中…'; setBtnState(trkBtn, ''); let cached = detailCache[p.api + '|' + r.url]; if (!cached) { cached = await fetchAlbumDetail(p, r, parsed); detailCache[p.api + '|' + r.url] = cached; } trkBtn.disabled = false; trkBtn.textContent = trkBtn.dataset.base || '曲目'; if (cached.tracks && cached.tracks.length) { setBtnState(trkBtn, 'ok'); trkBtn.textContent = '曲目(' + cached.tracks.length + ')'; trkBtn.title = '曲目抓取成功(' + cached.tracks.length + ' 首),点击填入曲目列表(悬浮可预览)'; trkBtn.onmouseenter = () => showPvFloat( shRenderTracksToText(cached.tracks), 'text', trkBtn); trkBtn.onmouseleave = hidePvFloat; } else { const queue = platformQueue(parsed, p.api).filter(q => TRACK_APIS.indexOf(q.api) !== -1); let got = null, gotPlat = null; for (const qp of queue) { try { const qpHit = await ensureHitFix(qp, parsed); if (!qpHit || !qpHit.url) continue; const ck2 = qp.api + '|' + qpHit.url; let d2 = detailCache[ck2]; if (!d2) { d2 = await fetchAlbumDetail(qp, qpHit, parsed); detailCache[ck2] = d2; } if (d2.tracks && d2.tracks.length) { got = d2.tracks; gotPlat = qp.name; break; } } catch (e) {} } if (got) { cached.tracks = got; cached.tracksFrom = gotPlat; setBtnState(trkBtn, 'ok'); trkBtn.textContent = '曲目(' + got.length + ')'; trkBtn.title = '曲目来自 ' + gotPlat + '(本平台无曲目数据),点击填入曲目列表'; trkBtn.onmouseenter = () => showPvFloat( shRenderTracksToText(got), 'text', trkBtn); trkBtn.onmouseleave = hidePvFloat; } else { setBtnState(trkBtn, 'err'); trkBtn.title = '曲目抓取失败(含换源):' + (cached.tracksNote || '无数据'); } } }; runTrk().catch(() => setBtnState(trkBtn, 'err')); trkBtn.addEventListener('click', async () => { if (trkBtn.disabled) return; let cached = detailCache[p.api + '|' + r.url]; if (!cached || !(cached.tracks && cached.tracks.length)) { await runTrk(); cached = detailCache[p.api + '|' + r.url]; } if (cached && cached.tracks && cached.tracks.length) { const ins = applyTracklist(cached.tracks, cached.tracksHtml); preview.style.display = 'block'; fromStatusEl('曲目').innerHTML = ins.ok ? '✅ 曲目 已填入(' + esc(p.name) + ',' + cached.tracks.length + ' 首)' : '❌ 曲目 填入失败:' + esc(ins.reason || '') + ''; } else { preview.style.display = 'block'; fromStatusEl('曲目').innerHTML = '❌ 曲目 获取失败:' + esc((cached && cached.tracksNote) || '无数据') + ''; } }); } const covBtn = mkBtn('封面'); covBtn.title = '获取该专辑无损/最高分辨率封面图(绿=成功,点击填入「封面图片」栏;红=失败)'; rec.actions.appendChild(covBtn); const runCover = async () => { covBtn.disabled = true; covBtn.textContent = '抓取中…'; setBtnState(covBtn, ''); let cached = detailCache[p.api + '|' + r.url]; if (!cached) { cached = {}; detailCache[p.api + '|' + r.url] = cached; } if (cached.cover === undefined) { try { cached.cover = await getCoverUrl(p, r, parsed); } catch (e) { cached.cover = ''; } } covBtn.disabled = false; covBtn.textContent = '封面'; if (cached.cover) { setBtnState(covBtn, 'ok'); covBtn.title = '封面获取成功,点击填入「封面图片」栏(悬浮可预览)\n' + cached.cover; covBtn.onmouseenter = () => showPvFloat(cached.cover, 'img', covBtn); covBtn.onmouseleave = hidePvFloat; } else { const queue = platformQueue(parsed, p.api); for (const qp of queue) { const qpHit = await ensureHitFix(qp, parsed); if (!qpHit || !qpHit.url) continue; let c = ''; try { c = await getCoverUrl(qp, qpHit, parsed); } catch (e) {} if (c) { cached.cover = c; cached.coverFrom = qp.name; setBtnState(covBtn, 'ok'); covBtn.title = '封面来自 ' + qp.name + '(本平台无封面数据),点击填入\n' + c; covBtn.onmouseenter = () => showPvFloat(c, 'img', covBtn); covBtn.onmouseleave = hidePvFloat; break; } } if (!cached.cover) { setBtnState(covBtn, 'err'); covBtn.title = '封面获取失败(含换源)'; } } }; runCover().catch(() => setBtnState(covBtn, 'err')); covBtn.addEventListener('click', async () => { if (covBtn.disabled) return; let cached = detailCache[p.api + '|' + r.url]; if (!cached || !cached.cover) { await runCover(); cached = detailCache[p.api + '|' + r.url]; } if (cached && cached.cover) { const ctrl = resolveControl('cover'); const ins = ctrl ? (applyValue(ctrl, cached.cover) ? { ok: true } : { ok: false, reason: '填入失败' }) : { ok: false, reason: '页面未找到封面图片控件(⚙ → 高级控件绑定 中可手动绑定)' }; preview.style.display = 'block'; fromStatusEl('封面').innerHTML = ins.ok ? '✅ 封面 已填入(' + esc(p.name) + ')' : '❌ 封面 填入失败:' + esc(ins.reason || '') + ''; } else { preview.style.display = 'block'; fromStatusEl('封面').innerHTML = '❌ 封面 获取失败:' + esc((cached && cached.coverNote) || '各通道未取得封面链接') + ''; } }); } else { rec.state = 'fail'; rec.result = null; rec.row.style.borderColor = 'var(--err)'; rec.nameBox.style.color = 'var(--err)'; rec.status.style.color = 'var(--err)'; rec.status.textContent = '❌ 匹配失败' + (r && r.error ? '(' + r.error + ')' : ''); rec.actions.style.display = 'flex'; const url = String(p.searchUrl || '').replace('{kw}', encodeURIComponent((parsed ? (parsed.artist + ' ' + parsed.album) : '').trim())); if (url) { const s = mkBtn('搜索预览'); s.title = '在新窗口打开该平台的搜索页(手动核对)'; s.addEventListener('click', () => window.open(url, '_blank')); rec.actions.appendChild(s); } } } async function fetchCandidates(p, parsed) { ocdCandSink.list = []; ocdCandSink.seen = {}; try { await searchAlbum(p, parsed.artist, parsed.album, parsed.year); } catch (e) {} const list = ocdCandSink.list ? ocdCandSink.list.slice(0, 10) : []; ocdCandSink.list = null; return list; } function renderCandList(p, rec, parsed) { const box = rec.candBox; box.innerHTML = ''; const simScore = (name) => { const a = normKey(parsed && parsed.album); const n = normKey(name); if (!a || !n) return 0; if (n === a) return 100; if (n.indexOf(a) === 0 || a.indexOf(n) === 0) return 90; if (n.includes(a) || a.includes(n)) return 80; return Math.round(lcsLen(a, n) / Math.max(a.length, n.length) * 100); }; const items = (rec.cands || []).slice().sort((x, y) => simScore(y.name) - simScore(x.name)); if (!items.length) { box.innerHTML = '
未获取到候选(该平台无匹配数据)
'; return; } const head = document.createElement('div'); head.className = 'ocd-cand-note'; head.textContent = '候选 ' + items.length + ' 条 · 点击即采用为该平台结果'; box.appendChild(head); const mk = (it) => { const row = document.createElement('div'); row.className = 'ocd-cand-item'; const parts = []; if (it.year || (parsed && parsed.year)) parts.push(it.year || parsed.year); if (it.artist || (parsed && parsed.artist)) parts.push(it.artist || parsed.artist); if (it.name) parts.push(it.name); row.textContent = parts.join(' · '); if (!it.url) { row.style.opacity = '.5'; row.title = '该候选无可用链接'; return; } row.title = '点击采用该候选为「' + p.name + '」匹配结果\n' + it.url; row.addEventListener('click', () => { renderMultiResult(p, rec, { name: it.name, url: it.url, artist: it.artist || (parsed && parsed.artist) || '', year: it.year || (parsed && parsed.year) || '', }); rec.candOpen = false; box.style.display = 'none'; }); box.appendChild(row); }; const curUrl = rec.result && rec.result.url; items.filter(it => it.url !== curUrl).forEach(it => mk(it)); } function bindCandBtn(p, rec) { if (rec.candBound) return; rec.candBound = true; rec.candBtn.addEventListener('click', async () => { const parsed = lastDetectParsed || parseFolderNameLoose(input.value); if (parsed) console.log('[OCD Helper] 候选入口parsed:', JSON.stringify(parsed)); if (!parsed || (!String(parsed.artist || '').trim() && !String(parsed.album || '').trim())) { preview.style.display = 'block'; fromStatusEl().innerHTML = '请先在上方输入并解析文件夹名(当前解析结果无艺人/专辑信息)'; return; } rec.candOpen = !rec.candOpen; if (!rec.candOpen) { if (rec.candBox) rec.candBox.style.display = 'none'; return; } if (!rec.candBox) { rec.candBox = document.createElement('div'); rec.candBox.style.cssText = 'margin-top:4px;border-top:1px solid var(--border);'; rec.right.appendChild(rec.candBox); } rec.candBox.style.display = 'block'; if (rec.cands) { renderCandList(p, rec, parsed); return; } if (rec.candLoading) return; rec.candLoading = true; rec.candBox.innerHTML = '
⏳ 正在获取候选…
'; const list = await fetchCandidates(p, parsed); rec.candLoading = false; rec.cands = list; if (rec.candOpen) renderCandList(p, rec, parsed); }); } async function runMultiDetect(targets) { const parsed = parseFolderNameLoose(input.value); const box = panel.querySelector('#ocd-multi-box'); if (!parsed) { box.style.display = 'block'; box.innerHTML = '
请先在上方输入框输入内容(如:年份 艺人 - 专辑 或任意关键词)
'; return; } lastDetectParsed = parsed; odesliCache = { key: '', ts: 0, promise: null }; const all = getPlatforms(); const enabled = all.filter(x => x.enabled !== false); const skipped = all.filter(x => x.enabled === false); let targets_ = enabled; if (targets && targets.length) { targets_ = enabled.filter(p => targets.indexOf(p.api) !== -1); } else { for (const k of Object.keys(multiRows)) delete multiRows[k]; box.innerHTML = ''; } box.style.display = 'block'; for (const p of targets_) { const rec = multiRowOf(p); bindCandBtn(p, rec); if (p.manual) { rec.state = 'fail'; rec.result = null; rec.row.style.borderColor = 'var(--border)'; renderMultiResult(p, rec, { error: '手动模式:自动匹配已关闭,请点「搜索预览」人工核对' }); continue; } rec.state = 'running'; rec.result = null; rec.row.style.borderColor = 'var(--border)'; rec.nameBox.style.color = 'var(--fg-muted)'; rec.status.style.color = 'var(--fg-muted)'; rec.status.textContent = '⏳ 检测中…'; rec.actions.style.display = 'none'; rec.actions.innerHTML = ''; hidePvFloat(); searchAlbum(p, parsed.artist, parsed.album, parsed.year).then(r => { renderMultiResult(p, rec, r); }).catch(() => renderMultiResult(p, rec, { error: '请求异常' })).finally(() => { const allRec = Object.values(multiRows); const done = allRec.filter(x => x.state === 'ok' || x.state === 'fail').length; window.__ocdBar && window.__ocdBar(done < allRec.length, done, allRec.length); if (done >= allRec.length) { const qb = document.getElementById('ocd-quick-multi'); if (qb) { delete qb.dataset.cd; qb.textContent = '音乐多平台检测'; } const rb = panel.querySelector('#ocd-multi-run'); if (rb) { delete rb.dataset.cd; rb.textContent = '批量检测'; } } }); } if (!targets && skipped.length) { let note = box.querySelector('.ocd-skip-note'); if (!note) { note = document.createElement('div'); note.className = 'ocd-skip-note'; note.style.cssText = 'margin-top:6px;color:var(--fg-muted);font-size:11px;'; box.appendChild(note); } note.textContent = '⏸ 已跳过未启用平台:' + skipped.map(x => x.name).join('、') + '(⚙ → 音乐平台设置 中可启用)'; } const checkSort = setInterval(() => { const allRec = Object.values(multiRows); if (!allRec.length) return; const running = allRec.some(r => r.state === 'running' || (r.state === 'idle' && r.status.textContent === '⏳ 检测中…')); if (running) return; clearInterval(checkSort); const boxEl = panel.querySelector('#ocd-multi-box'); const skipNote = boxEl.querySelector('.ocd-skip-note'); const orderMap = {}; getPlatforms().forEach((p, i) => { orderMap[p.api] = i; }); const sorted = Object.keys(multiRows).sort((ka, kb) => { const fa = multiRows[ka].state === 'ok' ? 0 : 1, fb = multiRows[kb].state === 'ok' ? 0 : 1; if (fa !== fb) return fa - fb; return ((orderMap[ka] != null ? orderMap[ka] : 99) - (orderMap[kb] != null ? orderMap[kb] : 99)); }); sorted.forEach(k => { const r = multiRows[k]; if (skipNote) boxEl.insertBefore(r.row, skipNote); else boxEl.appendChild(r.row); }); }, 800); } multiBtn.addEventListener('click', () => { multiOpened = !multiOpened; multiPanel.style.display = multiOpened ? 'block' : 'none'; multiBtn.textContent = multiOpened ? '音乐多平台检测 ▾' : '音乐多平台检测 ▸'; if (multiOpened) { const parsed = parseFolderName(input.value); if (parsed) lastDetectParsed = parsed; getPlatforms().filter(x => x.enabled !== false).forEach(p => { const rec = multiRowOf(p); bindCandBtn(p, rec); if (rec.state === 'idle') rec.status.textContent = '待检测'; }); } }); const multiRunBtn = panel.querySelector('#ocd-multi-run'); multiRunBtn.addEventListener('click', () => { if (multiRunBtn.dataset.cd) return; multiRunBtn.dataset.cd = '1'; multiRunBtn.textContent = '🔍检测中···'; runMultiDetect(null); }); panel.querySelector('#ocd-multi-retry').addEventListener('click', () => { const manualApis = getPlatforms().filter(p => p.manual).map(p => p.api); const failed = Object.keys(multiRows).filter(k => multiRows[k].state === 'fail' && manualApis.indexOf(k) === -1); if (failed.length) runMultiDetect(failed); let retriedSub = 0; for (const k of Object.keys(multiRows)) { const rec = multiRows[k]; if (!rec.actions) continue; rec.actions.querySelectorAll('button.ocd-btn-err').forEach(btn => { const label = btn.textContent.trim(); if (label === '简介' || label.indexOf('曲目') === 0 || label === '封面') { btn.disabled = false; btn.click(); retriedSub++; } }); } if (!failed.length && !retriedSub) { preview.style.display = 'block'; fromStatusEl().innerHTML = '没有失败项需要重试'; return; } if (retriedSub) { preview.style.display = 'block'; fromStatusEl().innerHTML = '⏳ 正在重试 ' + retriedSub + ' 个失败子项(简介/曲目/封面)…'; } }); function buildFeedbackText() { const parsed = lastDetectParsed || parseFolderName(input.value); const lines = ['【脚本反馈】', '版本: v' + VERSION]; if (parsed) lines.push('输入: ' + (parsed.year ? parsed.year + ' ' : '') + (parsed.artist ? parsed.artist + ' - ' : '') + parsed.album); else lines.push('输入: ' + String(input.value || '').trim().slice(0, 80)); lines.push('时间: ' + new Date().toLocaleString()); lines.push(''); const fails = Object.keys(multiRows) .filter(k => multiRows[k].state === 'fail') .map(k => getPlatforms().find(p => p.api === k)) .filter(p => p && !p.manual); if (!fails.length) { lines.push('(无失败平台)'); return lines.join('\n'); } for (const p of fails) { const rec = multiRows[p.api]; const err = rec && rec.status ? rec.status.textContent.replace(/^❌ 匹配失败/, '').replace(/[()]/g, '').trim() : ''; lines.push('---'); lines.push('平台: ' + p.name); lines.push('错误: ' + (err || '无匹配结果')); } return lines.join('\n'); } function copyFeedback() { copyText(buildFeedbackText(), () => { preview.style.display = 'block'; fromStatusEl('反馈').innerHTML = '✅ 反馈内容已复制,请粘贴发送给作者'; }); } const fbBtn = panel.querySelector('#ocd-fb'); if (fbBtn) fbBtn.title = '自动提取错误平台信息到剪贴板\n点击跳转站内[Ctrl+V]发送给我'; panel.querySelector('#ocd-fb').addEventListener('click', () => { const fails = Object.keys(multiRows).filter(k => multiRows[k].state === 'fail'); if (!fails.length) { preview.style.display = 'block'; fromStatusEl('反馈').innerHTML = '当前没有匹配失败的平台,无需反馈'; return; } copyFeedback(); const subject = encodeURIComponent('【脚本反馈】匹配失败'); const body = encodeURIComponent(buildFeedbackText()); window.open('https://open.cd/sendmessage.php?receiver=81966&subject=' + subject + '&body=' + body, '_blank'); }); const on = (sel, fn) => { const el = panel.querySelector(sel); if (el) el.addEventListener('click', fn); }; on('#ocd-check', () => publishFlow(false)); async function publishFlow(isRetry) { const issues = formCheck(); const fatal = issues.filter(i => i.indexOf('❌') === 0); const warns = issues.filter(i => i.indexOf('⚠️') === 0); try { if (typeof shChecklistData === 'function') { const miss = shChecklistData().filter(i => i.lv === 'err' && !i.ok); if (miss.length) fatal.push('❌ ' + miss.map(i => i.cn).join('、') + '(发布自检)'); } } catch (e) {} if (fatal.length) { preview.style.display = 'block'; fromStatusEl('自检').innerHTML = '🚫 发布被阻止,存在必填项缺失:
' + fatal.map(i => esc(i)).join('
') + '
(' + esc(warns.join(';') || '无其他警告') + ')'; return; } const specEmpty = warns.some(w => w.indexOf('频谱图') !== -1); if (specEmpty && !isRetry) { showSpecUploadDialog(() => publishFlow(true)); return; } if (!isRetry) { showConfirmDialog('确认发布?\n' + (warns.length ? '仍有警告:' + warns.join(';') : '自检全部通过'), () => { doPublish(); }); return; } doPublish(); } function doPublish() { const scope = getFormScope(); let pubBtn = null; scope.querySelectorAll('button[type="submit"], input[type="submit"]').forEach(b => { const t = (b.value || b.textContent || '').trim(); if (!pubBtn && /发布|upload|submit/i.test(t)) pubBtn = b; }); if (!pubBtn) pubBtn = scope.querySelector('input[type="submit"], button[type="submit"]'); if (pubBtn) { preview.style.display = 'block'; fromStatusEl('发布').innerHTML = '✅ 已触发发布'; pubBtn.click(); } else { preview.style.display = 'block'; fromStatusEl('发布').innerHTML = '❌ 未找到站内发布按钮,请手动点击'; } } function showSpecUploadDialog(onDone) { const mask = document.createElement('div'); mask.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:' + ocdTopZ(20) + ';display:flex;align-items:center;justify-content:center;'; const dlg = document.createElement('div'); dlg.style.cssText = 'background:var(--bg,#fff);color:var(--fg,#222);border:1px solid var(--border);border-radius:12px;padding:16px;max-width:360px;box-shadow:var(--shadow);'; dlg.innerHTML = '
频谱图尚未上传
' + '
检测到频谱图栏为空。若已有本地频谱图,可点击「上传」自动选入并提交;或选择「稍后」继续发布。
'; const btnRow = document.createElement('div'); btnRow.style.cssText = 'display:flex;gap:8px;justify-content:flex-end;'; const btnUp = document.createElement('button'); btnUp.textContent = '上传'; btnUp.style.cssText = 'padding:5px 14px;cursor:pointer;'; const btnLater = document.createElement('button'); btnLater.textContent = '稍后'; btnLater.style.cssText = 'padding:5px 14px;cursor:pointer;'; btnRow.appendChild(btnUp); btnRow.appendChild(btnLater); dlg.appendChild(btnRow); mask.appendChild(dlg); document.body.appendChild(mask); const close = () => mask.remove(); btnLater.addEventListener('click', () => { close(); onDone(); }); btnUp.addEventListener('click', async () => { btnUp.disabled = true; btnUp.textContent = '选入中…'; const parsed = parseFolderNameLoose(input.value); let ok = false; if (parsed) { const dir = await pickDir('spec_dir', '频谱图目录'); if (dir) { const fo = getFsOpts(); const file = await matchFileInDir(dir, matchKwOf(parsed), fo.specExts, fo.specKw, (parsed && (parsed.raw || parsed.title)) || ''); if (file) { const iframes = document.querySelectorAll('iframe[src*="attachment.php"]'); for (const fr of iframes) { const m = String(fr.src || '').match(/[?&]target=([a-z_]+)/i); if (!m || m[1].toLowerCase() !== 'spectrogram') continue; try { const doc = fr.contentDocument; const fi = doc && doc.querySelector('input[type="file"]'); if (fi) { injectFileFix(fi, file); setTimeout(() => { const sb = doc.getElementById('submit') || doc.querySelector('input[type="submit"]'); if (sb) sb.click(); }, 300); ok = true; } } catch (e) {} break; } } } } btnUp.disabled = false; btnUp.textContent = '上传'; close(); if (ok) { preview.style.display = 'block'; fromStatusEl('频谱图').innerHTML = '✅ 频谱图已选入并触发上传(站点回填 URL 需数秒)'; const specText = getFormScope().querySelector('input[name="spectrogram"]'); const startVal = specText ? specText.value : ''; let ticks = 0; fromStatusEl('频谱图').innerHTML = '⏳ 频谱图上传中(等待站点回填 URL,最长 60 秒)…'; const poll = setInterval(() => { ticks++; const cur = specText ? specText.value : ''; if (cur && cur !== startVal) { clearInterval(poll); fromStatusEl('频谱图').innerHTML = '✅ 频谱图上传完成:' + esc(cur.slice(0, 60)) + ''; setTimeout(() => publishFlow(true), 2000); } else if (ticks > 120) { clearInterval(poll); fromStatusEl('频谱图').innerHTML = '⚠️ 频谱图上传等待超时(60 秒),继续发布流程——如频谱图未上传成功请手动处理'; publishFlow(true); } }, 500); } else { preview.style.display = 'block'; fromStatusEl('频谱图').innerHTML = '❌ 频谱图选入失败,已中断发布(可手动上传后再点发布)'; } }); } function showConfirmDialog(msg, onOk) { const mask = document.createElement('div'); mask.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:' + ocdTopZ(20) + ';display:flex;align-items:center;justify-content:center;'; const dlg = document.createElement('div'); dlg.style.cssText = 'background:var(--bg,#fff);color:var(--fg,#222);border:1px solid var(--border);border-radius:12px;padding:16px;max-width:360px;box-shadow:var(--shadow);white-space:pre-line;'; dlg.innerHTML = '
发布确认
' + esc(msg) + '
'; const btnRow = document.createElement('div'); btnRow.style.cssText = 'display:flex;gap:8px;justify-content:flex-end;'; const btnOk = document.createElement('button'); btnOk.textContent = '发布'; btnOk.style.cssText = 'padding:5px 14px;cursor:pointer;'; const btnNo = document.createElement('button'); btnNo.textContent = '稍后'; btnNo.style.cssText = 'padding:5px 14px;cursor:pointer;'; btnNo.addEventListener('click', () => { mask.remove(); }); btnOk.addEventListener('click', () => { mask.remove(); onOk(); }); btnRow.appendChild(btnOk); btnRow.appendChild(btnNo); dlg.appendChild(btnRow); mask.appendChild(dlg); document.body.appendChild(mask); } let previewTimer = null; input.addEventListener('input', () => { clearTimeout(previewTimer); previewTimer = setTimeout(() => showParsedPreview(input.value), 200); }); input.addEventListener('paste', () => setTimeout(() => { showParsedPreview(input.value); renderFsButtons(); saveDraft(); }, 150)); input.addEventListener('paste', () => setTimeout(async () => { if (!getDupOpts().enabled) return; const kw = dupKeywordOf(input.value); if (!kw) return; const doOne = async () => { if (dupResultCache[kw]) { renderDupStatusRow(kw); return; } const r = await Promise.race([ dcDegradedCheck(kw, input.value.trim()).then(x => x.r), new Promise(res => setTimeout(() => res({ state: 'error', url: dupSearchUrl(kw, getDupOpts()), count: 0, note: '检测超时,可点跳转预览手动确认' }), 30000)), ]); dupResultCache[kw] = r; renderDupStatusRow(kw); }; if (dupState.running) { const waitEnd = setInterval(() => { if (!dupState.running) { clearInterval(waitEnd); doOne(); } }, 1500); } else { doOne(); } if (getFsOpts().auto && typeof showDirectoryPicker === 'function') { autoFsExec(); } }, 200)); { const bar = document.createElement('div'); bar.id = 'ocd-global-bar'; bar.style.cssText = 'display:none;height:3px;background:linear-gradient(90deg,var(--accent),var(--accent2));border-radius:2px;margin:2px 0;transition:width .3s,opacity .3s;width:0%;'; const topFix = document.getElementById('ocd-topfix'); if (topFix) topFix.appendChild(bar); window.__ocdBar = (active, done, total) => { if (active && total > 0) { bar.style.display = 'block'; bar.style.width = Math.round(done / total * 100) + '%'; } else { bar.style.width = '100%'; setTimeout(() => { bar.style.display = 'none'; bar.style.width = '0%'; }, 400); } }; } on('#ocd-fill', doFill); on('#ocd-check', () => shShowChecklist()); { const drop = document.createElement('div'); drop.id = 'ocd-input-hist'; drop.style.cssText = 'display:none;position:absolute;left:0;right:0;top:100%;z-index:20;background:var(--bg,#fff);border:1px solid var(--border);border-radius:8px;box-shadow:var(--shadow);max-height:180px;overflow-y:auto;font-size:12px;'; const topFix = document.getElementById('ocd-topfix'); if (topFix) topFix.style.position = 'relative'; (topFix || panel).appendChild(drop); input.addEventListener('focus', () => { const hist = getHistory().slice(0, 5); if (!hist.length) { drop.style.display = 'none'; return; } drop.innerHTML = hist.map((h, i) => '
' + esc(h.title || '') + '
').join(''); drop.style.display = 'block'; drop.querySelectorAll('.ocd-hist-item').forEach(item => { item.addEventListener('mousedown', (e) => { const h = getHistory()[Number(item.dataset.i)]; if (h) { input.value = (h.year || '') + ' ' + (h.artist || '') + ' - ' + (h.album || '') + ' WEB'; doFill(); } drop.style.display = 'none'; e.preventDefault(); }); }); }); input.addEventListener('blur', () => setTimeout(() => { drop.style.display = 'none'; }, 200)); input.addEventListener('input', () => { drop.style.display = 'none'; }); } { const histWrap = panel.querySelector('#ocd-hist-wrap'); const histList = panel.querySelector('#ocd-hist-list'); const draftBtn = panel.querySelector('#ocd-draft-restore'); const hist = getHistory(); const draft = loadDraft(); if (hist.length || (draft && (draft.descr || draft.tracks))) histWrap.style.display = 'block'; if (draft && (Date.now() - draft.ts < 2 * 3600 * 1000) && (draft.descr || draft.tracks)) { draftBtn.style.display = ''; draftBtn.textContent = '恢复草稿(' + new Date(draft.ts).toTimeString().slice(0, 5) + ')'; draftBtn.addEventListener('click', () => { const inp = document.getElementById('ocd-input'); if (draft.input && inp && !inp.value.trim()) inp.value = draft.input; const ta = findDescr(); if (ta && draft.descr && !ta.value.trim()) setNativeValue(ta, draft.descr); const tl = resolveControl('tracklist'); if (tl && draft.tracks && !String(tl.el.value || '').trim()) setNativeValue(tl.el, draft.tracks); const sub = resolveControl('subtitle'); if (sub && draft.subtitle && !String(sub.el.value || '').trim()) setNativeValue(sub.el, draft.subtitle); clearDraft(); draftBtn.style.display = 'none'; preview.style.display = 'block'; fromStatusEl('草稿').innerHTML = '✅ 草稿已恢复(空字段才回填,未覆盖现有内容)'; }); } if (hist.length) { histList.innerHTML = hist.slice(0, 10).map(h => '
' + '' + esc(h.title || '') + '' + '' + new Date(h.ts).toDateString().slice(4, 10) + '
').join(''); } } setInterval(saveDraft, 30000); window.addEventListener('beforeunload', saveDraft); const dupBtn = panel.querySelector('#ocd-dup-btn'); const dupPanel = panel.querySelector('#ocd-dup-panel'); const dupInput = panel.querySelector('#ocd-dup-input'); const dupProgress = panel.querySelector('#ocd-dup-progress'); const dupDetails = panel.querySelector('#ocd-dup-list'); const dupAllDetails = panel.querySelector('#ocd-dup-all'); const dupDupListEl = panel.querySelector('#ocd-dup-duplist'); const dupTaskListEl = panel.querySelector('#ocd-dup-tasklist'); const dupDupCountEl = panel.querySelector('#ocd-dup-dupcount'); let dupOpened = false; const dupStateColor = { clean: 'var(--ok)', dup: '#c8a415', error: 'var(--fg-muted)', running: 'var(--accent)', pending: 'var(--fg-muted)' }; const dupStateText = { clean: '✅暂无重复', dup: '❓疑似重复', error: '⏳检测失败', running: '⏳检测中…', pending: '· 待检测' }; dupState.ui = { renderAll() { if (!dupOpened) return; const total = dupState.tasks.length; const finished = dupState.tasks.filter(t => t.state === 'clean' || t.state === 'dup' || t.state === 'error').length; dupProgress.style.display = total ? 'block' : 'none'; const allDone = finished === total && total > 0; const borderColor = dupState.dup > 0 ? '#c8a415' : (allDone ? 'var(--ok)' : 'var(--border)'); dupProgress.style.border = '1px solid ' + borderColor; dupProgress.style.borderRadius = '8px'; dupProgress.innerHTML = '查询进度:' + dupState.done + '/' + total + '' + ' | ✅暂无重复 ' + dupState.clean + '' + ' | ❓疑似重复 ' + dupState.dup + '' + (dupState.stopFlag && dupState.running ? ' | 正在停止…' : (dupState.running ? ' | 检测中(可点停止)' : (allDone ? ' | 已完成' : ''))); const dups = dupState.tasks.filter(t => t.state === 'dup'); dupDupCountEl.textContent = String(dups.length); dupDupListEl.innerHTML = ''; dups.forEach(t => { const row = document.createElement('div'); row.style.cssText = 'border:1px solid #c8a415;border-radius:7px;padding:5px 8px;margin:3px 0;font-size:12px;'; row.innerHTML = '' + esc(t.kw) + '
❓疑似重复 ' + esc(t.note || '') + ''; const btn = document.createElement('button'); btn.textContent = '跳转预览'; btn.style.cssText = 'margin-left:6px;padding:1px 8px;cursor:pointer;font-size:11px;'; btn.addEventListener('click', () => window.open(t.url, '_blank')); row.appendChild(btn); dupDupListEl.appendChild(row); }); dupTaskListEl.innerHTML = ''; dupState.tasks.forEach((t) => { const row = document.createElement('div'); const c = dupStateColor[t.state] || 'var(--fg-muted)'; const borderC = t.state === 'clean' ? 'var(--ok)' : t.state === 'dup' ? '#c8a415' : 'var(--border)'; row.style.cssText = 'border:1px solid ' + borderC + ';border-radius:7px;padding:5px 8px;margin:3px 0;font-size:12px;color:' + c + ';'; row.innerHTML = '' + esc(t.kw) + '
' + (dupStateText[t.state] || t.state) + (t.note ? ' ' + esc(t.note) + '' : ''); if (t.url) { const btn = document.createElement('button'); btn.textContent = '跳转预览'; btn.style.cssText = 'margin-left:6px;padding:1px 8px;cursor:pointer;font-size:11px;'; btn.addEventListener('click', () => window.open(t.url, '_blank')); row.appendChild(btn); } dupTaskListEl.appendChild(row); }); dupDetails.style.display = dups.length ? '' : 'none'; dupAllDetails.style.display = total ? '' : 'none'; } }; dupState.notify = (msg, level) => { preview.style.display = 'block'; fromStatusEl('查重', level).innerHTML = '' + esc(msg) + ''; }; dupBtn.addEventListener('click', () => { dupOpened = !dupOpened; dupPanel.style.display = dupOpened ? 'block' : 'none'; dupBtn.textContent = dupOpened ? '发种查重批量检测 ▾' : '发种查重批量检测 ▸'; if (dupOpened) dupState.ui.renderAll(); }); panel.querySelector('#ocd-dup-run').addEventListener('click', () => { const lines = dupInput.value.split('\n').map(s => s.trim()).filter(Boolean); if (!lines.length) { preview.style.display = 'block'; fromStatusEl().innerHTML = '请先在输入框粘贴待查重的文件夹名(每行一个)'; return; } dupSetTasks(lines); dupRunBatch(); }); panel.querySelector('#ocd-dup-stop').addEventListener('click', dupStopBatch); on('#ocd-theme', () => { const cur = store.get(THEME_KEY, 'dark'); const idx = THEMES.indexOf(cur); applyTheme(THEMES[(idx + 1) % THEMES.length] || 'dark'); }); on('#ocd-panel-close', () => { closeUploaderUI(); }); on('#ocd-toggle', () => { const topFix = document.getElementById('ocd-topfix'); const sc = document.getElementById('ocd-scroll'); if (!topFix || !sc) return; const collapsing = sc.style.display !== 'none'; topFix.classList.toggle('ocd-collapsed', collapsing); /* 收起时记住原始 inline display,展开时精确还原(否则 flex 容器会退化为 block,卡片/工具条错位) */ shSetCollapsed(sc, collapsing); if (!collapsing) { sc.insertBefore(settingsBody, sc.firstChild); settingsBody.style.display = 'none'; } }); on('#ocd-gear', () => { autoRetryStopped = true; const sc = document.getElementById('ocd-scroll'); const topFix = document.getElementById('ocd-topfix'); const collapsed = sc && sc.style.display === 'none'; if (collapsed) { const show = settingsBody.style.display === 'none' || settingsBody.parentElement !== topFix; if (show) { renderSettings(); topFix.appendChild(settingsBody); settingsBody.style.display = 'block'; } else settingsBody.style.display = 'none'; return; } const show = settingsBody.style.display === 'none'; if (show) renderSettings(); settingsBody.style.display = show ? '' : 'none'; }); function renderSettings() { const saved = store.get(PRESET_KEY, {}) || {}; const selSaved = store.get(SEL_KEY, {}) || {}; settingsBody.innerHTML = ''; const layBox = document.createElement('details'); layBox.style.cssText = 'margin:6px 0;border:1px solid #eee;border-radius:6px;padding:4px 8px;'; layBox.innerHTML = '界面布局设置(悬浮 / 宽度 / 透明度)'; settingsBody.appendChild(layBox); const layHint = document.createElement('div'); layHint.style.cssText = 'color:#666;margin-bottom:6px;white-space:pre-line;'; layHint.textContent = '启用悬浮后可拖动标题栏至任意位置;\n窗口宽度数值范围 300~800,保存后生效;\n透明度修改值范围 30~100,保存后生效;'; layBox.appendChild(layHint); const layWrap = document.createElement('div'); layWrap.style.cssText = 'display:flex;align-items:center;gap:8px;flex-wrap:wrap;'; const layFloatChk = document.createElement('input'); layFloatChk.type = 'checkbox'; layFloatChk.id = 'ocd-lay-float2'; layFloatChk.checked = !!layout.float; const layFloatLab = document.createElement('label'); layFloatLab.htmlFor = 'ocd-lay-float2'; layFloatLab.style.cssText = 'cursor:pointer;font-size:12px;color:#555;'; layFloatLab.textContent = '助手悬浮窗口'; const layWidth = document.createElement('input'); layWidth.type = 'text'; layWidth.value = String(layout.width || 380); layWidth.placeholder = '宽度'; layWidth.style.cssText = 'width:70px;box-sizing:border-box;padding:3px 6px;border:1px solid #ccc;border-radius:5px;'; const layOpacity = document.createElement('input'); layOpacity.type = 'text'; layOpacity.value = String(layout.opacity != null ? layout.opacity : 100); layOpacity.placeholder = '透明度'; layOpacity.style.cssText = 'width:70px;box-sizing:border-box;padding:3px 6px;border:1px solid #ccc;border-radius:5px;'; layWrap.appendChild(layFloatChk); layWrap.appendChild(layFloatLab); layWrap.appendChild(layWidth); layWrap.appendChild(layOpacity); layBox.appendChild(layWrap); layFloatChk.addEventListener('change', () => setLayoutFloat(layFloatChk.checked)); layWidth.addEventListener('change', () => setLayoutWidth(layWidth.value)); layOpacity.addEventListener('change', () => setLayoutOpacity(layOpacity.value)); const skinW = document.createElement('div'); skinW.style.cssText = 'display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:4px 0;'; const mkSkinInput = (ph, val, w) => { const ip = document.createElement('input'); ip.type = 'text'; ip.value = val != null ? val : ''; ip.placeholder = ph; ip.style.cssText = 'width:' + w + 'px;box-sizing:border-box;padding:3px 6px;border:1px solid #ccc;border-radius:5px;'; return ip; }; const skinBlur = mkSkinInput('模糊px', layout.panelBlur != null ? layout.panelBlur : 14, 64); const skinRad = mkSkinInput('圆角px', layout.panelRadius != null ? layout.panelRadius : 14, 64); const skinBw = mkSkinInput('边框px', layout.panelBorderW != null ? layout.panelBorderW : 1, 64); const skinBc = mkSkinInput('边框色#可空', layout.panelBorderC || '', 90); const skinLab = (t) => { const s = document.createElement('span'); s.style.cssText = 'font-size:11px;color:#555;'; s.textContent = t; return s; }; skinW.appendChild(skinLab('面板模糊:')); skinW.appendChild(skinBlur); skinW.appendChild(skinLab('圆角:')); skinW.appendChild(skinRad); skinW.appendChild(skinLab('边框宽:')); skinW.appendChild(skinBw); skinW.appendChild(skinLab('边框色:')); skinW.appendChild(skinBc); layBox.appendChild(skinW); const applySkin = () => { layout.panelBlur = Math.max(0, Math.min(30, Number(skinBlur.value) || 0)); layout.panelRadius = Math.max(6, Math.min(24, Number(skinRad.value) || 14)); layout.panelBorderW = Math.max(0, Math.min(4, Number(skinBw.value) || 0)); layout.panelBorderC = /^#[0-9a-fA-F]{6}$/.test(skinBc.value.trim()) ? skinBc.value.trim() : ''; saveLayout(layout); applyLayout(panel, layout); const bp = document.getElementById('ocd-batch-panel'); if (bp) applyPanelSkin(bp); }; [skinBlur, skinRad, skinBw, skinBc].forEach(ip => ip.addEventListener('change', applySkin)); const qzW = document.createElement('div'); qzW.style.cssText = 'display:flex;align-items:center;gap:6px;margin:4px 0;'; const qzChk = document.createElement('input'); qzChk.type = 'checkbox'; qzChk.id = 'ocd-quick-zone'; qzChk.checked = getFsOpts().quickZone !== false; const qzLab = document.createElement('label'); qzLab.htmlFor = 'ocd-quick-zone'; qzLab.style.cssText = 'cursor:pointer;font-size:12px;color:#555;'; qzLab.textContent = '平台预选快捷区(预览框右侧:批量检测 + 跳转/替换/简介/曲目/封面合集)'; qzChk.addEventListener('change', () => { const cur = getFsOpts(); cur.quickZone = qzChk.checked; saveFsOpts(cur); showParsedPreview(input.value); }); qzW.appendChild(qzChk); qzW.appendChild(qzLab); layBox.appendChild(qzW); const blurW = document.createElement('div'); blurW.style.cssText = 'display:flex;align-items:center;gap:6px;margin:4px 0;flex-wrap:wrap;'; const blurChk = document.createElement('input'); blurChk.type = 'checkbox'; blurChk.id = 'ocd-blur-fade'; blurChk.checked = !!getFsOpts().blurFade; const blurLab = document.createElement('label'); blurLab.htmlFor = 'ocd-blur-fade'; blurLab.style.cssText = 'cursor:pointer;font-size:12px;color:#555;'; blurLab.textContent = '失焦界面透明'; const blurOp = document.createElement('input'); blurOp.type = 'text'; blurOp.value = String(getFsOpts().blurOpacity); blurOp.placeholder = '30'; blurOp.style.cssText = 'width:50px;box-sizing:border-box;padding:3px 6px;border:1px solid #ccc;border-radius:5px;'; blurOp.title = '数值 5~90,面板失焦时的不透明度百分比'; blurW.appendChild(blurChk); blurW.appendChild(blurLab); blurW.appendChild(blurOp); layBox.appendChild(blurW); const setBlurOpts = () => { const cur = getFsOpts(); cur.blurFade = blurChk.checked; cur.blurOpacity = Math.max(5, Math.min(90, Number(blurOp.value) || 30)); saveFsOpts(cur); }; blurChk.addEventListener('change', setBlurOpts); blurOp.addEventListener('change', setBlurOpts); const presetBox = document.createElement('details'); presetBox.style.cssText = 'margin:6px 0;border:1px solid #eee;border-radius:6px;padding:4px 8px;'; presetBox.innerHTML = '发布页预设值(预设注入)'; settingsBody.appendChild(presetBox); const hint = document.createElement('div'); hint.style.cssText = 'color:#666;margin-bottom:6px;white-space:pre-line;'; hint.textContent = '选项读取自页面真实控件;选择后后续默认引用填充;\n右侧显示实际绑定的控件;'; presetBox.appendChild(hint); const profRow = document.createElement('div'); profRow.style.cssText = 'display:flex;align-items:center;gap:6px;margin:6px 0;flex-wrap:wrap;'; const profSel = document.createElement('select'); profSel.style.cssText = 'flex:0 0 120px;box-sizing:border-box;padding:3px 6px;'; const refreshProfSel = () => { profSel.innerHTML = ''; Object.keys(getProfiles()).forEach(n => { const op = document.createElement('option'); op.value = n; op.textContent = n + (getActiveProfile() === n ? ' ✓' : ''); profSel.appendChild(op); }); if (getActiveProfile()) profSel.value = getActiveProfile(); }; refreshProfSel(); const profApply = document.createElement('button'); profApply.textContent = '应用方案'; profApply.style.cssText = 'padding:3px 10px;cursor:pointer;'; profApply.addEventListener('click', () => { if (applyProfile(profSel.value)) flashBtn(profApply, '应用成功~'); renderSettings(); }); const profSave = document.createElement('button'); profSave.textContent = '存为方案'; profSave.title = '把当前下方各预设项的值保存为所选方案(覆盖)'; profSave.style.cssText = 'padding:3px 10px;cursor:pointer;'; profSave.addEventListener('click', () => { const profs = getProfiles(); const vals = {}; for (const k of PROF_FIELDS) { const ctl = controls[k]; if (ctl) vals[k] = ctl.value.trim(); } profs[profSel.value] = vals; saveProfiles(profs); setActiveProfile(profSel.value); refreshProfSel(); flashBtn(profSave, '保存成功~'); }); const profNew = document.createElement('input'); profNew.type = 'text'; profNew.placeholder = '新方案名'; profNew.style.cssText = 'width:90px;box-sizing:border-box;padding:3px 6px;'; const profAdd = document.createElement('button'); profAdd.textContent = '+'; profAdd.title = '新增方案'; profAdd.style.cssText = 'padding:3px 8px;cursor:pointer;'; profAdd.addEventListener('click', () => { const n = profNew.value.trim(); if (!n) return; const profs = getProfiles(); if (!profs[n]) { profs[n] = {}; saveProfiles(profs); } refreshProfSel(); profSel.value = n; profNew.value = ''; flashBtn(profAdd, '✓', 1500); }); profRow.appendChild(profSel); profRow.appendChild(profApply); profRow.appendChild(profSave); profRow.appendChild(profNew); profRow.appendChild(profAdd); presetBox.appendChild(profRow); const profTip = document.createElement('div'); profTip.style.cssText = 'color:#888;font-size:11px;margin-bottom:6px;'; profTip.textContent = '流程:调好下方预设项 → 选方案名 → 「存为方案」。之后发不同类型时选方案 → 「应用方案」一键切换。'; presetBox.appendChild(profTip); const controls = {}; const optSnapshot = {}; for (const [key, cfg] of Object.entries(FIELDS)) { if (!cfg.preset) continue; const row = document.createElement('div'); row.style.cssText = 'display:flex;align-items:center;gap:6px;margin-bottom:5px;'; const label = document.createElement('span'); label.style.cssText = 'flex:0 0 72px;text-align:right;'; label.textContent = cfg.cn + ':'; const ctrl = resolveControl(key); const want = Object.prototype.hasOwnProperty.call(saved, key) ? saved[key] : cfg.defaultOption; let opts2 = null; if (ctrl && ctrl.kind === 'select') opts2 = [...new Set(Array.from(ctrl.el.options).map(o => o.text.trim()).filter(Boolean))]; else if (ctrl && ctrl.kind === 'radio') opts2 = [...new Set(ctrl.radios.map(labelOf).filter(Boolean))]; if (opts2 && opts2.length) optSnapshot[key] = opts2; let ctl; if (opts2 && opts2.length) { ctl = document.createElement('select'); const mk = (val, text) => { const o = document.createElement('option'); o.value = val; o.textContent = text; ctl.appendChild(o); }; if (opts2.indexOf('--- 请选择 ---') !== -1) mk('--- 请选择 ---', '--- 请选择 ---'); else mk('', '(不填充)'); for (const t of opts2) if (t !== '--- 请选择 ---') mk(t, t); if (want && !opts2.includes(want) && want !== '--- 请选择 ---') mk(want, want); ctl.value = (want && want !== '--- 请选择 ---') ? want : (opts2.indexOf('--- 请选择 ---') !== -1 ? '--- 请选择 ---' : ''); } else { ctl = document.createElement('input'); ctl.type = 'text'; ctl.placeholder = ctrl ? '文本项,留空待用户选择' : '未找到控件,可手动输入'; ctl.value = want || ''; } ctl.style.cssText = 'flex:1;box-sizing:border-box;padding:3px 6px;border:1px solid #ccc;border-radius:5px;'; const kindLabel = !ctrl ? '(未找到)' : ctrl.kind === 'select' ? '(下拉' : ctrl.kind === 'radio' ? '(单选' : '(文本'; const st = document.createElement('span'); st.style.cssText = 'flex:0 0 auto;color:#999;font-size:11px;max-width:110px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'; st.title = ctrl ? (ctrl.sel || '') : ''; st.textContent = ctrl ? kindLabel + ' → ' + (ctrl.sel || '') + ')' : kindLabel; controls[key] = ctl; row.appendChild(label); row.appendChild(ctl); row.appendChild(st); presetBox.appendChild(row); } if (Object.keys(optSnapshot).length) savePresetOptions(optSnapshot); const fsBox = document.createElement('details'); fsBox.style.cssText = 'margin:6px 0;border:1px solid #eee;border-radius:6px;padding:4px 8px;'; fsBox.innerHTML = '种子频谱设置(位置 / 匹配)'; settingsBody.appendChild(fsBox); buildFsSettingsPanel(fsBox); const plat = document.createElement('details'); plat.style.cssText = 'margin:6px 0;border:1px solid #eee;border-radius:6px;padding:4px 8px;'; plat.innerHTML = '音乐平台设置(自动抓取 / 自定义平台 / 开关 / 排序)'; buildPlatformSettings(plat); settingsBody.appendChild(plat); const dupBox = document.createElement('details'); dupBox.style.cssText = 'margin:6px 0;border:1px solid #eee;border-radius:6px;padding:4px 8px;'; dupBox.innerHTML = '发种查重设置(匹配条件)'; settingsBody.appendChild(dupBox); { const o = getDupOpts(); const enW = document.createElement('div'); enW.style.cssText = 'display:flex;align-items:center;gap:6px;margin:4px 0;'; const enChk = document.createElement('input'); enChk.type = 'checkbox'; enChk.id = 'ocd-dup-en'; enChk.checked = o.enabled; const enLab = document.createElement('label'); enLab.htmlFor = 'ocd-dup-en'; enLab.style.cssText = 'cursor:pointer;font-size:12px;color:#555;'; enLab.textContent = '启用发种查重批量检测(启用后界面展示功能区)'; enChk.addEventListener('change', () => { const cur = getDupOpts(); cur.enabled = enChk.checked; saveDupOpts(cur); const w = document.getElementById('ocd-dup-wrap'); if (w) w.style.display = enChk.checked ? '' : 'none'; }); enW.appendChild(enChk); enW.appendChild(enLab); dupBox.appendChild(enW); { const w = document.getElementById('ocd-dup-wrap'); if (w) w.style.display = o.enabled ? '' : 'none'; } const mkSelRow = (labelText, val, options, cssExtra) => { const w = document.createElement('div'); w.style.cssText = 'display:flex;align-items:center;gap:6px;margin:4px 0;'; const lb = document.createElement('span'); lb.style.cssText = 'flex:0 0 62px;font-size:12px;color:#555;text-align:right;'; lb.textContent = labelText; const sel = document.createElement('select'); sel.style.cssText = 'flex:1;box-sizing:border-box;padding:3px 6px;' + (cssExtra || ''); for (const [v, t] of options) { const op = document.createElement('option'); op.value = v; op.textContent = t; sel.appendChild(op); } sel.value = String(val); w.appendChild(lb); w.appendChild(sel); dupBox.appendChild(w); return { w, sel }; }; const areaS = mkSelRow('搜索范围:', o.area, [[0, '标题'], [1, '简介'], [3, '发布者'], [4, 'IMDb链接'], [5, '曲目列表'], [6, '艺术家名']]); const modeS = mkSelRow('匹配模式:', o.mode, [[0, '与'], [1, '或'], [2, '精准']]); const fltW = document.createElement('div'); fltW.style.cssText = 'display:flex;align-items:center;gap:6px;margin:4px 0;'; const fltChk = document.createElement('input'); fltChk.type = 'checkbox'; fltChk.id = 'ocd-dup-filter'; fltChk.checked = o.filter; const fltLab = document.createElement('label'); fltLab.htmlFor = 'ocd-dup-filter'; fltLab.style.cssText = 'cursor:pointer;font-size:12px;color:#555;'; fltLab.textContent = '二次过滤(种子标题规范化比对,疑似列表更干净)'; fltW.appendChild(fltChk); fltW.appendChild(fltLab); dupBox.appendChild(fltW); const ck = o.checks || {}; const ckRefs = {}; for (const g of DUP_CHECK_GROUPS) { const gWrap = document.createElement('div'); gWrap.style.cssText = 'margin:4px 0;display:flex;align-items:flex-start;gap:6px;'; const gLab = document.createElement('span'); gLab.style.cssText = 'flex:0 0 62px;font-size:12px;color:#555;text-align:right;padding-top:2px;'; gLab.textContent = g.label + ':'; const boxWrap = document.createElement('div'); boxWrap.style.cssText = 'flex:1;display:flex;flex-wrap:wrap;gap:2px 10px;'; ckRefs[g.key] = []; for (const [name, label] of g.items) { const cw = document.createElement('label'); cw.style.cssText = 'display:inline-flex;align-items:center;gap:3px;font-size:11px;color:#555;cursor:pointer;white-space:nowrap;'; const cb = document.createElement('input'); cb.type = 'checkbox'; cb.value = name; cb.checked = (ck[g.key] || []).indexOf(name) !== -1; cw.appendChild(cb); cw.appendChild(document.createTextNode(label)); boxWrap.appendChild(cw); ckRefs[g.key].push(cb); } gWrap.appendChild(gLab); gWrap.appendChild(boxWrap); dupBox.appendChild(gWrap); } const selRefs = {}; for (const s of DUP_SELECTS) { selRefs[s.key] = mkSelRow(s.label + ':', o.selects[s.key] || '0', s.items, 'max-width:200px;').sel; } const mkTxtRow = (labelText, val, ph) => { const w = document.createElement('div'); w.style.cssText = 'display:flex;align-items:center;gap:6px;margin:4px 0;'; const lb = document.createElement('span'); lb.style.cssText = 'flex:0 0 62px;font-size:12px;color:#555;text-align:right;'; lb.textContent = labelText; const ip = document.createElement('input'); ip.type = 'text'; ip.value = val || ''; ip.placeholder = ph || ''; ip.style.cssText = 'flex:1;box-sizing:border-box;padding:3px 6px;max-width:200px;'; w.appendChild(lb); w.appendChild(ip); dupBox.appendChild(w); return ip; }; const seedersIp = mkTxtRow('做种数≤:', o.seeders, '留空不限'); const yearIp = mkTxtRow('年份:', o.year, '留空不限'); const saveDupBtn = document.createElement('button'); saveDupBtn.textContent = '保存查重设置'; saveDupBtn.style.cssText = 'padding:4px 10px;cursor:pointer;margin-top:4px;'; saveDupBtn.addEventListener('click', () => { const newChecks = {}; for (const g of DUP_CHECK_GROUPS) { newChecks[g.key] = ckRefs[g.key].filter(cb => cb.checked).map(cb => cb.value); } const newSelects = {}; for (const s of DUP_SELECTS) newSelects[s.key] = selRefs[s.key].value; saveDupOpts({ enabled: enChk.checked, area: Number(areaS.sel.value), mode: Number(modeS.sel.value), filter: fltChk.checked, checks: newChecks, selects: newSelects, seeders: seedersIp.value.trim(), year: yearIp.value.trim(), }); console.log('[DupCheck调试] 保存后的回读:', store.get(DUP_KEY, '{}')); flashBtn(saveDupBtn, '保存成功~'); }); dupBox.appendChild(saveDupBtn); } const probe = document.createElement('details'); probe.style.cssText = 'margin:6px 0;border:1px solid #eee;border-radius:6px;padding:4px 8px;'; probe.innerHTML = '探测页面字段(列出上传表单内所有真实控件)' + '' + '
结果第一列是可复制的选择器,粘到下方「高级控件绑定」对应字段即可。
' + ''; settingsBody.appendChild(probe); probe.querySelector('#ocd-probe-btn').addEventListener('click', () => { const ta = probe.querySelector('#ocd-probe-out'); ta.value = probeFields().join('\n') || '(未找到任何控件,请确认当前页面是上传页)'; ta.onclick = () => ta.select(); }); const adv = document.createElement('details'); adv.style.cssText = 'margin:6px 0;border:1px solid #eee;border-radius:6px;padding:4px 8px;'; adv.innerHTML = '高级控件绑定(探测后填写,留空用内置绑定)'; const selInputs = {}; for (const [key, cfg] of Object.entries(FIELDS)) { if (cfg.preset) continue; const row = document.createElement('div'); row.style.cssText = 'display:flex;align-items:center;gap:6px;margin:4px 0;'; const lb = document.createElement('span'); lb.style.cssText = 'flex:0 0 72px;text-align:right;'; lb.textContent = cfg.cn + ':'; const ip = document.createElement('input'); ip.type = 'text'; ip.placeholder = '如 #standard 或 input[name="frname"]'; ip.value = selSaved[key] || ''; ip.style.cssText = 'flex:1;box-sizing:border-box;padding:3px 6px;border:1px solid #ccc;border-radius:5px;'; selInputs[key] = ip; row.appendChild(lb); row.appendChild(ip); adv.appendChild(row); } settingsBody.appendChild(adv); const btnRow = document.createElement('div'); btnRow.style.cssText = 'display:flex;gap:8px;margin-top:6px;'; const saveBtn = document.createElement('button'); saveBtn.textContent = '保存并应用'; saveBtn.style.cssText = 'flex:2;padding:5px 0;cursor:pointer;'; saveBtn.addEventListener('click', () => { const p = {}; for (const [k, ctl] of Object.entries(controls)) p[k] = ctl.value.trim(); store.set(PRESET_KEY, p); const s = {}; for (const [k, ip] of Object.entries(selInputs)) if (ip.value.trim()) s[k] = ip.value.trim(); store.set(SEL_KEY, s); const cur = getLayout(); cur.float = layFloatChk.checked; const wv = Number(layWidth.value); if (wv >= 300 && wv <= 800) cur.width = wv; const ov = Number(layOpacity.value); if (ov >= 30 && ov <= 100) cur.opacity = ov; saveLayout(cur); applyLayout(panel, cur); flashBtn(saveBtn, '保存成功~'); applyPresetsWithRetry(3, 400, () => {}); }); const resetBtn = document.createElement('button'); resetBtn.textContent = '恢复默认'; resetBtn.style.cssText = 'flex:1;padding:5px 0;cursor:pointer;'; resetBtn.addEventListener('click', () => { store.set(PRESET_KEY, {}); store.set(SEL_KEY, {}); renderSettings(); flashBtn(resetBtn, '恢复默认~'); }); btnRow.appendChild(saveBtn); btnRow.appendChild(resetBtn); settingsBody.appendChild(btnRow); } } /* ================================================================ * §47 设置面板:音乐平台设置 * ================================================================ */ function buildPlatformSettings(parent) { const mkInput = (value, placeholder, css) => { const ip = document.createElement('input'); ip.type = 'text'; ip.value = value || ''; ip.placeholder = placeholder || ''; ip.style.cssText = 'box-sizing:border-box;padding:3px 6px;border:1px solid #ccc;border-radius:5px;' + (css || ''); return ip; }; const mkChk = (labelText, checked, title) => { const wrap = document.createElement('div'); wrap.style.cssText = 'display:flex;align-items:center;gap:6px;margin:4px 0;'; const chk = document.createElement('input'); chk.type = 'checkbox'; chk.id = 'ocd-chk-' + Math.random().toString(36).slice(2, 8); chk.checked = checked; if (title) chk.title = title; const lab = document.createElement('label'); lab.htmlFor = chk.id; lab.style.cssText = 'cursor:pointer;font-size:12px;color:#555;'; lab.textContent = labelText; wrap.appendChild(chk); wrap.appendChild(lab); parent.appendChild(wrap); return { wrap, chk }; }; const opts = getFromOpts(); const swFrom = mkChk('「解析并填充」后自动搜索专辑链接并写入『简介』首行并换行', opts.auto); const swDesc = mkChk('「解析并填充」后自动搜索专辑简介并写入『简介』第二行', opts.desc); const swTrk = mkChk('「解析并填充」后自动搜索专辑曲目并写入『曲目列表』', opts.tracks, '需要目标平台支持曲目抓取(网易/酷我/Qobuz/Apple/Deezer/Tidal/QQ/酷狗/AllMusic)'); const foSwitch = document.createElement('details'); foSwitch.style.cssText = 'margin:6px 0;border:1px solid #eee;border-radius:6px;padding:4px 8px;'; foSwitch.innerHTML = '自动换源检测顺序(自定义 FROM/简介/曲目/封面 换源顺序)'; parent.appendChild(foSwitch); const foHint = document.createElement('div'); foHint.style.cssText = 'color:#888;font-size:11px;margin:4px 0;line-height:1.5;'; foHint.textContent = '拖动条目调整顺序,或用 ▲▼ 微调。未列出的平台不参与自动换源;识别到 WEB 平台备注时仍最优先。'; foSwitch.appendChild(foHint); const intlWrap = document.createElement('div'); const intlLb = document.createElement('div'); intlLb.style.cssText = 'font-size:12px;color:var(--fg-muted,#555);margin-top:6px;font-weight:600;'; intlLb.textContent = '外语优先顺序'; intlWrap.appendChild(intlLb); const intlEd = buildOrderEditor(intlWrap, 'intlOrder'); foSwitch.appendChild(intlWrap); const cnWrap = document.createElement('div'); const cnLb = document.createElement('div'); cnLb.style.cssText = 'font-size:12px;color:var(--fg-muted,#555);margin-top:6px;font-weight:600;'; cnLb.textContent = '中文优先顺序'; cnWrap.appendChild(cnLb); const cnEd = buildOrderEditor(cnWrap, 'cnOrder'); foSwitch.appendChild(cnWrap); const keyWrap = document.createElement('div'); keyWrap.style.cssText = 'display:flex;align-items:center;gap:6px;margin:4px 0;'; const keyLab = document.createElement('label'); keyLab.style.cssText = 'cursor:pointer;font-size:12px;color:#555;flex:0 0 auto;'; keyLab.textContent = 'song.link API Key(可选):'; keyLab.title = 'Amazon/Tidal 匹配服务近期要求 API Key;到 song.link/api 免费获取后粘贴到此处;不填则用 MusicBrainz 兜底'; const keyIp = mkInput(store.get(ODESLI_KEY, ''), '留空 = 不使用(用 MusicBrainz 兜底)', 'flex:1;min-width:0;font-size:11px;'); keyWrap.appendChild(keyLab); keyWrap.appendChild(keyIp); parent.appendChild(keyWrap); const tHint = document.createElement('div'); tHint.style.cssText = 'color:#888;font-size:11px;margin:6px 0 2px;line-height:1.5;'; tHint.innerHTML = '获取 Tidal 开发者凭据:
developer.tidal.com 注册 → Dashboard → 创建 Application → 拿 Client ID/Secret(免费)。
获取 Qobuz App ID:浏览器登录 qobuz.com → F12 → Network → 搜索任意专辑 → 从 api.json 请求 URL 中复制 app_id 的值。
未填写 App ID 时仅走 HTML 搜索页兜底(可正常匹配,但速度略慢;填 App ID 可走官方 API 提速)。'; parent.appendChild(tHint); const tidalIdIp = mkInput(getFsOpts().tidalClientId, 'Tidal Client ID', 'flex:1;min-width:0;'); const tidalSecIp = mkInput(getFsOpts().tidalClientSecret, 'Tidal Client Secret', 'flex:1;min-width:0;'); tidalSecIp.type = 'password'; const mkTidalRow = (labelText, ip) => { const w = document.createElement('div'); w.style.cssText = 'display:flex;align-items:center;gap:6px;margin:4px 0;'; const l = document.createElement('span'); l.style.cssText = 'flex:0 0 auto;font-size:11px;color:#555;'; l.textContent = labelText; w.appendChild(l); w.appendChild(ip); parent.appendChild(w); }; mkTidalRow('Tidal Client ID:', tidalIdIp); mkTidalRow('Tidal Client Secret:', tidalSecIp); const qobuzIdIp = mkInput(getFsOpts().qobuzAppId, 'Qobuz App ID(未填则 Qobuz 通道不生效)', 'flex:1;min-width:0;'); mkTidalRow('Qobuz App ID:', qobuzIdIp); const neHint = document.createElement('div'); neHint.style.cssText = 'color:#888;font-size:11px;margin:6px 0 2px;line-height:1.6;'; neHint.innerHTML = '网易云 Cookie(可选,解决部分专辑简介仅登录态可见的问题):
' + '获取方法:浏览器打开 music.163.com 并登录 → F12 → Application(应用)→ Cookies → music.163.com → 复制 MUSIC_U 的值粘贴到下面。'; parent.appendChild(neHint); const neCkIp = mkInput(store.get('ocd_helper_necookie_v1', ''), '粘贴 MUSIC_U 的值(32位hex),留空 = 不使用', 'flex:1;min-width:0;font-size:11px;'); neCkIp.type = 'password'; mkTidalRow('网易 MUSIC_U:', neCkIp); const rows = []; const listWrap = document.createElement('div'); parent.appendChild(listWrap); const moveRow = (rec, dir) => { const i = rows.indexOf(rec); const j = i + dir; if (i === -1 || j < 0 || j >= rows.length) return; const tmp = rows[i]; rows[i] = rows[j]; rows[j] = tmp; rows.forEach(r => listWrap.appendChild(r.row)); }; const addRow = (p) => { const rec = { p }; const row = document.createElement('div'); rec.row = row; row.style.cssText = 'display:flex;align-items:center;gap:4px;margin:4px 0;'; rec.en = document.createElement('input'); rec.en.type = 'checkbox'; rec.en.checked = p.enabled !== false; rec.en.title = '启用 / 停用该平台(停用后:关键词识别、多平台检测均跳过;自动换源不受影响)'; rec.nm = mkInput(p.name, '平台名', 'width:74px;flex:0 0 auto;'); rec.kw = mkInput((p.keywords || []).join(','), '识别关键词,逗号分隔(匹配 WEB 后备注)', 'flex:1;min-width:60px;'); rec.su = mkInput(p.searchUrl, '搜索页URL({kw}=搜索词)', 'flex:1.4;min-width:88px;'); row.appendChild(rec.en); row.appendChild(rec.nm); row.appendChild(rec.kw); row.appendChild(rec.su); const badge = document.createElement('span'); badge.textContent = p.api ? '自动' : '跳转'; badge.title = p.api ? '内置自动搜索接口:' + p.api + '(多平台检测可直接抓取专辑链接)' : '未配置自动搜索接口,多平台检测时仅提供搜索页跳转'; badge.style.cssText = 'flex:0 0 auto;font-size:11px;color:#999;border:1px solid #ddd;border-radius:4px;padding:1px 4px;'; row.appendChild(badge); const sortBox = document.createElement('span'); sortBox.style.cssText = 'flex:0 0 auto;display:flex;flex-direction:column;gap:1px;'; const mkSortBtn = (text, title, dir) => { const b = document.createElement('button'); b.textContent = text; b.title = title; b.style.cssText = 'flex:0 0 auto;padding:0 4px;cursor:pointer;font-size:9px;line-height:13px;'; b.addEventListener('click', () => moveRow(rec, dir)); return b; }; sortBox.appendChild(mkSortBtn('▲', '上移', -1)); sortBox.appendChild(mkSortBtn('▼', '下移', 1)); row.appendChild(sortBox); if (!p.builtin) { rec.del = document.createElement('button'); rec.del.textContent = '删'; rec.del.title = '删除该自定义平台'; rec.del.style.cssText = 'flex:0 0 auto;padding:3px 8px;cursor:pointer;'; rec.del.addEventListener('click', () => { const i = rows.indexOf(rec); if (i !== -1) rows.splice(i, 1); row.remove(); }); row.appendChild(rec.del); } rows.push(rec); listWrap.appendChild(row); }; getPlatforms().forEach(addRow); const btns = document.createElement('div'); btns.style.cssText = 'display:flex;gap:6px;margin:6px 0;flex-wrap:wrap;'; const addBtn = document.createElement('button'); addBtn.textContent = '+ 添加平台'; addBtn.style.cssText = 'padding:4px 10px;cursor:pointer;'; addBtn.addEventListener('click', () => addRow({ id: 'custom_' + Date.now().toString(36), name: '', enabled: true, api: '', keywords: [], searchUrl: '' })); const savePlatBtn = document.createElement('button'); savePlatBtn.textContent = '保存平台设置'; savePlatBtn.style.cssText = 'padding:4px 10px;cursor:pointer;'; savePlatBtn.addEventListener('click', () => { const cfg = rows.map(r => ({ id: r.p.id || ('custom_' + Date.now().toString(36)), name: r.nm.value.trim() || '未命名', enabled: r.en.checked, builtin: !!r.p.builtin, manual: !!r.p.manual, api: r.p.api || '', keywords: r.kw.value.split(/[,,、]/).map(s => s.trim()).filter(Boolean), searchUrl: r.su.value.trim(), })); savePlatforms(cfg); const intlOrder = intlEd ? intlEd.getValue() : []; const cnOrder = cnEd ? cnEd.getValue() : []; const fromOpts = { auto: swFrom.chk.checked, desc: swDesc.chk.checked, tracks: swTrk.chk.checked }; if (intlOrder.length) fromOpts.intlOrder = intlOrder; if (cnOrder.length) fromOpts.cnOrder = cnOrder; store.set(FROM_KEY, fromOpts); store.set(ODESLI_KEY, keyIp.value.trim()); const fsCur = getFsOpts(); fsCur.tidalClientId = tidalIdIp.value.trim(); fsCur.tidalClientSecret = tidalSecIp.value.trim(); fsCur.qobuzAppId = qobuzIdIp.value.trim(); saveFsOpts(fsCur); store.set('ocd_helper_necookie_v1', neCkIp.value.trim()); flashBtn(savePlatBtn, '保存成功~'); }); const resetPlatBtn = document.createElement('button'); resetPlatBtn.textContent = '恢复内置'; resetPlatBtn.title = '清空自定义平台与开关状态,恢复内置默认(网易云/QQ/酷狗/酷我/AllMusic 启用,海外平台停用)'; resetPlatBtn.style.cssText = 'padding:4px 10px;cursor:pointer;'; resetPlatBtn.addEventListener('click', () => { store.set(PLATFORM_KEY, null); store.set(FROM_KEY, { auto: true, desc: true, tracks: true }); swFrom.chk.checked = true; swDesc.chk.checked = true; swTrk.chk.checked = true; listWrap.innerHTML = ''; rows.length = 0; getPlatforms().forEach(addRow); toastSafe('已恢复内置,关闭本面板再打开可看到排序区重置', 'ok'); }); btns.appendChild(addBtn); btns.appendChild(savePlatBtn); btns.appendChild(resetPlatBtn); parent.appendChild(btns); const tip = document.createElement('div'); tip.style.cssText = 'color:#888;font-size:12px;line-height:1.5;white-space:pre-line;'; tip.textContent = '说明:平台左侧□可随时启停;\n' + '▲▼ 可调整检测顺序布局;\n' + 'Amazon/Tidal 优先经 MusicBrainz 解析,失败时走 song.link(需在上方填 API Key,免费申请);\n' + '「专辑简介」/「曲目信息」按钮结果会缓存,点击按钮触发写入/替换发布页对应信息;'; parent.appendChild(tip); } function buildFsSettingsPanel(parent) { const preview = document.getElementById('ocd-preview'); const fo = getFsOpts(); const autoW = document.createElement('div'); autoW.style.cssText = 'display:flex;align-items:center;gap:6px;margin:4px 0;'; const autoChk = document.createElement('input'); autoChk.type = 'checkbox'; autoChk.id = 'ocd-fs-auto-' + Math.random().toString(36).slice(2, 6); autoChk.checked = fo.auto; const autoLab = document.createElement('label'); autoLab.htmlFor = autoChk.id; autoLab.style.cssText = 'cursor:pointer;font-size:12px;color:var(--fg-muted,#888);'; autoLab.textContent = '粘贴文件夹名后自动填入并上传(未勾选则手动在预览区点击执行)'; autoW.appendChild(autoChk); autoW.appendChild(autoLab); parent.appendChild(autoW); const clipW = document.createElement('div'); clipW.style.cssText = 'display:flex;align-items:center;gap:6px;margin:4px 0;'; const clipChk = document.createElement('input'); clipChk.type = 'checkbox'; clipChk.id = 'ocd-fs-clip-' + Math.random().toString(36).slice(2, 6); clipChk.checked = fo.clipBtn; const clipLab = document.createElement('label'); clipLab.htmlFor = clipChk.id; clipLab.style.cssText = 'cursor:pointer;font-size:12px;color:var(--fg-muted,#888);'; clipLab.textContent = '显示📋剪贴板按钮(点击读取剪贴板并自动解析填充)'; clipChk.addEventListener('change', () => { const b = document.getElementById('ocd-clip'); if (b) b.style.display = clipChk.checked ? '' : 'none'; }); clipW.appendChild(clipChk); clipW.appendChild(clipLab); parent.appendChild(clipW); const mkInputRow = (labelText, val, ph) => { const w = document.createElement('div'); w.style.cssText = 'display:flex;align-items:center;gap:6px;margin:4px 0;'; const lb = document.createElement('span'); lb.style.cssText = 'flex:0 0 110px;font-size:12px;color:var(--fg-muted,#888);text-align:right;'; lb.textContent = labelText; const ip = document.createElement('input'); ip.type = 'text'; ip.value = val || ''; ip.placeholder = ph || ''; ip.style.cssText = 'flex:1;box-sizing:border-box;padding:3px 6px;border:1px solid var(--border,#ccc);border-radius:5px;background:var(--input-bg,#2a303c);color:var(--fg,#e8eaf0);'; w.appendChild(lb); w.appendChild(ip); parent.appendChild(w); return ip; }; const specKwIp = mkInputRow('频谱图匹配词:', fo.specKw, '附加关键词,分号分隔'); const torKwIp = mkInputRow('种子匹配词:', fo.torKw, '附加关键词,分号分隔'); const specExtIp = mkInputRow('频谱图后缀:', fo.specExts.join(';'), '分号分隔,如 .png;.jpg'); const torExtIp = mkInputRow('种子后缀:', fo.torExts.join(';'), '分号分隔,如 .torrent'); const dirW = document.createElement('div'); dirW.style.cssText = 'display:flex;gap:8px;margin:6px 0;flex-wrap:wrap;'; const mkDirBtn = (text, key, title) => { const b = document.createElement('button'); b.textContent = text; b.title = title; b.style.cssText = 'padding:4px 10px;cursor:pointer;'; b.addEventListener('click', async () => { if (typeof showDirectoryPicker !== 'function') { if (preview) { preview.style.display = 'block'; preview.innerHTML = '当前浏览器不支持目录选择(需 Edge/Chrome)'; } return; } try { const dir = await showDirectoryPicker({ id: key, mode: 'read' }); await FSDB.set(key, dir); flashBtn(b, '✅ 已更新:' + dir.name.slice(0, 8)); } catch (e) {} }); return b; }; const saveFsBtn = document.createElement('button'); saveFsBtn.textContent = '保存DIY匹配词'; saveFsBtn.style.cssText = 'padding:4px 10px;cursor:pointer;'; dirW.appendChild(saveFsBtn); dirW.appendChild(mkDirBtn('更换频谱图目录', 'spec_dir', '重新选择频谱图所在目录')); dirW.appendChild(mkDirBtn('更换种子目录', 'torrent_dir', '重新选择种子文件所在目录')); parent.appendChild(dirW); saveFsBtn.addEventListener('click', () => { const splitList = (s) => s.split(/[;;]/).map(x => x.trim()).filter(x => x); const cur = getFsOpts(); cur.auto = autoChk.checked; cur.specKw = specKwIp.value.trim(); cur.torKw = torKwIp.value.trim(); cur.specExts = splitList(specExtIp.value); cur.torExts = splitList(torExtIp.value); cur.clipBtn = clipChk.checked; saveFsOpts(cur); flashBtn(saveFsBtn, '保存成功~'); }); } function buildOrderEditor(parent, key) { const ALL = [ { api: 'qobuz', name: 'Qobuz' }, { api: 'apple', name: 'Apple Music' }, { api: 'deezer', name: 'Deezer' }, { api: 'allmusic', name: 'AllMusic' }, { api: 'tidal', name: 'Tidal' }, { api: 'netease', name: '网易云音乐' }, { api: 'qq', name: 'QQ 音乐' }, { api: 'kugou', name: '酷狗音乐' }, { api: 'kuwo', name: '酷我音乐' }, ]; const initial = (key === 'intlOrder' ? getFromOpts().intlOrder : getFromOpts().cnOrder).slice(); const cur = initial.filter(a => ALL.some(p => p.api === a)); const rest = ALL.map(p => p.api).filter(a => cur.indexOf(a) === -1); let dragSrc = null; const wrap = document.createElement('div'); wrap.style.cssText = 'border:1px solid var(--border,#444);border-radius:8px;padding:6px;margin:6px 0;background:var(--input-bg,#1a1f2a);'; const tip = document.createElement('div'); tip.style.cssText = 'font-size:11px;color:var(--fg-muted,#888);margin-bottom:4px;'; tip.textContent = '启用中(☰ 可拖动排序,▲▼ 微调,✕ 移除):'; wrap.appendChild(tip); const listEl = document.createElement('div'); listEl.dataset.role = 'list'; wrap.appendChild(listEl); const offTip = document.createElement('div'); offTip.style.cssText = 'font-size:11px;color:var(--fg-muted,#888);margin:6px 0 4px;'; offTip.textContent = '未启用(+ 点击加入):'; wrap.appendChild(offTip); const restEl = document.createElement('div'); restEl.style.cssText = 'display:flex;flex-wrap:wrap;gap:4px;'; restEl.dataset.role = 'rest'; wrap.appendChild(restEl); const persist = () => { const intl = key === 'intlOrder' ? cur.slice() : getFromOpts().intlOrder; const cn = key === 'cnOrder' ? cur.slice() : getFromOpts().cnOrder; const o = getFromOpts(); o.intlOrder = intl; o.cnOrder = cn; store.set(FROM_KEY, o); }; const render = () => { listEl.innerHTML = ''; cur.forEach((api, idx) => { const item = ALL.find(p => p.api === api); if (!item) return; const row = document.createElement('div'); row.draggable = true; row.dataset.api = api; row.style.cssText = 'display:flex;align-items:center;gap:6px;padding:4px 8px;border-radius:6px;margin:3px 0;cursor:grab;border:1px solid var(--accent,#5b8cff);background:rgba(91,140,255,.08);'; const handle = document.createElement('span'); handle.textContent = '☰'; handle.style.cssText = 'flex:0 0 auto;color:var(--fg-muted,#888);cursor:grab;user-select:none;'; const name = document.createElement('span'); name.textContent = item.name; name.style.cssText = 'flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600;'; const mkBtn = (txt, act, color, title) => { const b = document.createElement('button'); b.textContent = txt; b.dataset.act = act; b.title = title; b.style.cssText = 'padding:0 6px;cursor:pointer;font-size:11px;line-height:18px;border:1px solid var(--border,#444);border-radius:4px;background:transparent;color:' + (color || 'var(--fg,#e8eaf0)') + ';'; return b; }; const up = mkBtn('▲', 'up', '', '上移'); const down = mkBtn('▼', 'down', '', '下移'); const off = mkBtn('✕', 'off', 'var(--err,#ff6b6b)', '移除(不参与该语言换源)'); row.appendChild(handle); row.appendChild(name); row.appendChild(up); row.appendChild(down); row.appendChild(off); row.addEventListener('dragstart', (e) => { dragSrc = api; row.style.opacity = '.35'; try { e.dataTransfer.setData('text/plain', api); } catch (x) {} e.dataTransfer.effectAllowed = 'move'; }); row.addEventListener('dragend', () => { row.style.opacity = ''; dragSrc = null; }); row.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; row.style.borderColor = 'var(--ok,#3fd68f)'; }); row.addEventListener('dragleave', () => { row.style.borderColor = 'var(--accent,#5b8cff)'; }); row.addEventListener('drop', (e) => { e.preventDefault(); row.style.borderColor = 'var(--accent,#5b8cff)'; const src = dragSrc || e.dataTransfer.getData('text/plain'); if (!src || src === api) return; const si = cur.indexOf(src); const di = cur.indexOf(api); if (si === -1 || di === -1) return; cur.splice(si, 1); cur.splice(di, 0, src); persist(); render(); }); up.addEventListener('click', (e) => { e.stopPropagation(); if (idx > 0) { [cur[idx - 1], cur[idx]] = [cur[idx], cur[idx - 1]]; persist(); render(); } }); down.addEventListener('click', (e) => { e.stopPropagation(); if (idx < cur.length - 1) { [cur[idx], cur[idx + 1]] = [cur[idx + 1], cur[idx]]; persist(); render(); } }); off.addEventListener('click', (e) => { e.stopPropagation(); const i = cur.indexOf(api); if (i !== -1) { cur.splice(i, 1); rest.push(api); persist(); render(); } }); listEl.appendChild(row); }); restEl.innerHTML = ''; if (!rest.length) { const empty = document.createElement('span'); empty.style.cssText = 'font-size:11px;color:var(--fg-muted,#888);'; empty.textContent = '(全部已启用)'; restEl.appendChild(empty); return; } rest.forEach(api => { const item = ALL.find(p => p.api === api); if (!item) return; const chip = document.createElement('button'); chip.textContent = '+ ' + item.name; chip.style.cssText = 'padding:3px 9px;cursor:pointer;font-size:11px;border:1px solid var(--border,#444);border-radius:6px;background:transparent;color:var(--fg-muted,#888);'; chip.addEventListener('click', () => { const i = rest.indexOf(api); if (i !== -1) { rest.splice(i, 1); cur.push(api); persist(); render(); } }); restEl.appendChild(chip); }); }; render(); parent.appendChild(wrap); return { getValue: () => cur.slice() }; } /* ================================================================ * §48 主题美化 * ================================================================ */ /* V9.76.17(用户 m13487 ②):「主题微调(状态色 / 背景透明度)」整体下线 —— applyThemeTweaks() 已删除,注入主题样式后不再覆盖 --ok / --err / --accent / --bg ✓ */ const THEMES = ['dark', 'glass', 'ocean', 'sakura', 'forest', 'sunset', 'mono', 'latte', 'nord', 'dracula', 'coffee', 'mint', 'grape', 'rose', 'amber', 'steel', 'liquid']; const THEME_NAMES = { dark: '暗黑', glass: '毛玻璃', ocean: '海洋', sakura: '樱花', forest: '森林', sunset: '日落', mono: '黑白', latte: '拿铁', nord: '北欧', dracula: '德古拉', coffee: '咖啡', mint: '薄荷', grape: '葡萄', rose: '玫瑰', amber: '琥珀', steel: '钢青', liquid: '液态玻璃' }; const THEME_CSS = ` .ocd-theme-dark{--bg:rgba(24,26,32,.96);--bg-soft:#262b35;--fg:#e8eaf0;--fg-muted:#9aa3b2;--border:rgba(255,255,255,.10);--accent:#5b8cff;--accent2:#8a6bff;--btn-fg:#fff;--shadow:0 12px 40px rgba(0,0,0,.45);--input-bg:#2a303c;--ring:rgba(91,140,255,.35);--ok:#3fd68f;--err:#ff6b6b;} .ocd-theme-glass{--bg:rgba(255,255,255,.72);--bg-soft:rgba(240,243,250,.85);--fg:#1f2430;--fg-muted:#5c6470;--border:rgba(255,255,255,.65);--accent:#4f7cff;--accent2:#22b8cf;--btn-fg:#fff;--shadow:0 12px 40px rgba(31,45,80,.22);--input-bg:rgba(255,255,255,.9);--ring:rgba(79,124,255,.25);--ok:#1a8f54;--err:#c93b3b;} .ocd-theme-ocean{--bg:linear-gradient(160deg,#141e30,#1b2a45 55%,#16324f);--bg-soft:rgba(255,255,255,.07);--fg:#e6f1ff;--fg-muted:#8fa8c7;--border:rgba(120,180,255,.25);--accent:#00c6ff;--accent2:#0072ff;--btn-fg:#fff;--shadow:0 12px 40px rgba(0,10,30,.5);--input-bg:rgba(8,20,35,.55);--ring:rgba(0,198,255,.30);--ok:#3fd68f;--err:#ff6b6b;} .ocd-theme-sakura{--bg:rgba(255,247,250,.94);--bg-soft:rgba(255,235,242,.9);--fg:#4a2c3a;--fg-muted:#a8798d;--border:rgba(255,182,208,.65);--accent:#ff7eb3;--accent2:#ff9a76;--btn-fg:#fff;--shadow:0 12px 40px rgba(255,150,190,.28);--input-bg:rgba(255,255,255,.92);--ring:rgba(255,126,179,.28);--ok:#1e8f5e;--err:#c0392b;} .ocd-theme-forest{--bg:rgba(16,28,22,.96);--bg-soft:#22352b;--fg:#e2f0e6;--fg-muted:#94b8a0;--border:rgba(120,220,160,.14);--accent:#4caf7d;--accent2:#2e9e63;--btn-fg:#fff;--shadow:0 12px 40px rgba(0,20,10,.45);--input-bg:#1d2f26;--ring:rgba(76,175,125,.35);--ok:#5fd694;--err:#ff6b6b;} .ocd-theme-sunset{--bg:linear-gradient(160deg,#2b1a2e,#3a1f24 55%,#241a30);--bg-soft:rgba(255,200,150,.08);--fg:#ffe9dc;--fg-muted:#c9a0a0;--border:rgba(255,160,110,.25);--accent:#ff9a5b;--accent2:#ff5e8a;--btn-fg:#fff;--shadow:0 12px 40px rgba(30,5,15,.5);--input-bg:rgba(40,15,25,.55);--ring:rgba(255,154,91,.30);--ok:#5fd694;--err:#ff6b6b;} .ocd-theme-mono{--bg:rgba(10,10,10,.97);--bg-soft:#1c1c1c;--fg:#f2f2f2;--fg-muted:#9a9a9a;--border:rgba(255,255,255,.16);--accent:#e8e8e8;--accent2:#b0b0b0;--btn-fg:#111;--shadow:0 12px 40px rgba(0,0,0,.6);--input-bg:#222;--ring:rgba(255,255,255,.25);--ok:#7ddb9a;--err:#ff8080;} .ocd-theme-latte{--bg:rgba(250,244,235,.96);--bg-soft:rgba(240,228,212,.9);--fg:#4a3a2a;--fg-muted:#a08a70;--border:rgba(180,150,110,.5);--accent:#c08a4f;--accent2:#a06a3f;--btn-fg:#fff;--shadow:0 12px 40px rgba(120,90,50,.25);--input-bg:rgba(255,252,246,.92);--ring:rgba(192,138,79,.30);--ok:#1e8f5e;--err:#c0392b;} .ocd-theme-nord{--bg:rgba(46,52,64,.97);--bg-soft:#3b4252;--fg:#eceff4;--fg-muted:#a8b3cf;--border:rgba(216,222,233,.14);--accent:#88c0d0;--accent2:#81a1c1;--btn-fg:#2e3440;--shadow:0 12px 40px rgba(0,0,0,.5);--input-bg:#3b4252;--ring:rgba(136,192,208,.3);--ok:#a3be8c;--err:#bf616a;} .ocd-theme-dracula{--bg:rgba(40,42,54,.97);--bg-soft:#44475a;--fg:#f8f8f2;--fg-muted:#b9bcc7;--border:rgba(248,248,242,.14);--accent:#bd93f9;--accent2:#ff79c6;--btn-fg:#282a36;--shadow:0 12px 40px rgba(0,0,0,.55);--input-bg:#44475a;--ring:rgba(189,147,249,.3);--ok:#50fa7b;--err:#ff5555;} .ocd-theme-coffee{--bg:rgba(43,35,29,.97);--bg-soft:#3d332a;--fg:#f0e6d8;--fg-muted:#b3a08a;--border:rgba(220,190,150,.15);--accent:#c8965a;--accent2:#a67c48;--btn-fg:#2b231d;--shadow:0 12px 40px rgba(0,0,0,.5);--input-bg:#3d332a;--ring:rgba(200,150,90,.3);--ok:#8fbf6f;--err:#d97b66;} .ocd-theme-mint{--bg:rgba(235,248,244,.96);--bg-soft:rgba(214,240,232,.9);--fg:#1f4038;--fg-muted:#6d9a8d;--border:rgba(120,200,170,.5);--accent:#2fbf9b;--accent2:#1e9e80;--btn-fg:#fff;--shadow:0 12px 40px rgba(40,150,120,.22);--input-bg:rgba(255,255,255,.92);--ring:rgba(47,191,155,.28);--ok:#1e8f5e;--err:#c0392b;} .ocd-theme-grape{--bg:rgba(34,24,50,.97);--bg-soft:#31264a;--fg:#efe8ff;--fg-muted:#a99cc9;--border:rgba(190,160,255,.16);--accent:#9d6ff0;--accent2:#c86fd8;--btn-fg:#fff;--shadow:0 12px 40px rgba(20,5,40,.55);--input-bg:#31264a;--ring:rgba(157,111,240,.3);--ok:#7ddba3;--err:#f2777a;} .ocd-theme-rose{--bg:rgba(252,242,245,.96);--bg-soft:rgba(250,230,238,.9);--fg:#4d2436;--fg-muted:#b57f94;--border:rgba(240,160,190,.55);--accent:#e05a8a;--accent2:#d94f6e;--btn-fg:#fff;--shadow:0 12px 40px rgba(220,80,140,.22);--input-bg:rgba(255,255,255,.93);--ring:rgba(224,90,138,.28);--ok:#1e8f5e;--err:#b03a2e;} .ocd-theme-amber{--bg:rgba(30,26,18,.97);--bg-soft:#3a3222;--fg:#ffeec9;--fg-muted:#c4ad7f;--border:rgba(255,210,120,.18);--accent:#ffb347;--accent2:#ff8c42;--btn-fg:#2a2318;--shadow:0 12px 40px rgba(0,0,0,.55);--input-bg:#3a3222;--ring:rgba(255,179,71,.3);--ok:#a8d08d;--err:#ff7043;} .ocd-theme-steel{--bg:rgba(224,228,232,.97);--bg-soft:rgba(206,212,220,.9);--fg:#2a3038;--fg-muted:#68737f;--border:rgba(100,115,135,.45);--accent:#4a6fa5;--accent2:#3d5a85;--btn-fg:#fff;--shadow:0 12px 40px rgba(50,70,100,.25);--input-bg:rgba(255,255,255,.9);--ring:rgba(74,111,165,.28);--ok:#1e7e50;--err:#b03030;} .ocd-theme-liquid{--bg:rgba(255,255,255,.14);--bg-soft:rgba(255,255,255,.10);--fg:#ffffff;--fg-muted:rgba(255,255,255,.72);--border:rgba(255,255,255,.30);--accent:#7dd8ff;--accent2:#c58bff;--btn-fg:#0b1a2a;--shadow:0 8px 32px rgba(31,38,135,.37), inset 0 1px 0 rgba(255,255,255,.45), inset 0 -1px 0 rgba(255,255,255,.15), inset 1px 0 0 rgba(255,255,255,.20);--input-bg:rgba(255,255,255,.12);--ring:rgba(255,255,255,.35);--ok:#7dffb8;--err:#ff8ba0;} `; const BASE_CSS = ` @keyframes ocd-in{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:none}} #ocd-helper-panel{ background: var(--bg) !important; color: var(--fg) !important; border: var(--panel-bw, 1px) solid var(--panel-bc, var(--border)) !important; border-radius: var(--panel-radius, 14px) !important; box-shadow: var(--shadow) !important; backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); font-family: system-ui,-apple-system,"Segoe UI","Microsoft YaHei",sans-serif !important; animation: ocd-in .25s ease; } #ocd-head{ cursor: default; } #ocd-theme, #ocd-gear, #ocd-toggle{ display:inline-flex;align-items:center;justify-content:center; width:24px;height:24px; border-radius:6px; transition: background .15s; } #ocd-theme:hover, #ocd-gear:hover, #ocd-toggle:hover{ background: var(--bg-soft); } #ocd-input, #ocd-settings input[type="text"], #ocd-settings select, #ocd-settings textarea, #ocd-probe-out{ background: var(--input-bg) !important; color: var(--fg) !important; border: 1px solid var(--border) !important; border-radius: 8px !important; transition: border-color .15s ease, box-shadow .15s ease; outline: none !important; } #ocd-input:focus, #ocd-settings input:focus, #ocd-settings select:focus, #ocd-settings textarea:focus{ border-color: var(--accent) !important; box-shadow: 0 0 0 3px var(--ring) !important; } #ocd-input::placeholder, #ocd-settings input::placeholder{ color: var(--fg-muted) !important; } #ocd-preview{ background: var(--bg-soft) !important; border:1px solid var(--border) !important; border-radius:8px !important; } #ocd-preview div{ margin:1px 0; } #ocd-preview span[style*="#080"]{ color: var(--ok) !important; } #ocd-preview span[style*="#c00"]{ color: var(--err) !important; } #ocd-preview span[style*="#888"]{ color: var(--fg-muted) !important; } #ocd-fill, #ocd-multi-btn, #ocd-multi-run, #ocd-multi-retry, #ocd-settings button{ background: linear-gradient(135deg, var(--accent), var(--accent2)) !important; color: var(--btn-fg) !important; border: none !important; border-radius: 8px !important; font-weight: 600; letter-spacing: .5px; transition: transform .12s ease, filter .15s ease, box-shadow .2s ease; box-shadow: 0 2px 10px var(--ring); } #ocd-fill:hover, #ocd-multi-btn:hover, #ocd-multi-run:hover, #ocd-multi-retry:hover, #ocd-settings button:hover{ filter: brightness(1.08); transform: translateY(-1px); } #ocd-fill:active, #ocd-multi-btn:active, #ocd-multi-run:active, #ocd-multi-retry:active, #ocd-settings button:active{ transform: translateY(0); filter: brightness(.96); } #ocd-settings > div:last-child > button:last-child{ background: transparent !important; border: 1px solid var(--border) !important; color: var(--fg-muted) !important; box-shadow: none !important; } #ocd-settings > div:last-child > button:last-child:hover{ filter: none; transform: translateY(-1px); color: var(--fg) !important; border-color: var(--accent) !important; } #ocd-multi-box{ margin-top:8px; font-size:12px; } #ocd-multi-box button{ background: transparent !important; border: 1px solid var(--border) !important; color: var(--fg) !important; border-radius: 6px !important; padding: 2px 8px !important; font-size: 12px !important; font-weight: 400 !important; box-shadow: none !important; transition: border-color .15s ease, color .15s ease; } #ocd-multi-box button:hover{ border-color: var(--accent) !important; color: var(--accent) !important; } #ocd-multi-box button.ocd-btn-ok{ color: var(--ok) !important; border-color: var(--ok) !important; } #ocd-multi-box button.ocd-btn-err{ color: var(--err) !important; border-color: var(--err) !important; } #ocd-multi-box button.ocd-btn-ok:hover, #ocd-multi-box button.ocd-btn-err:hover{ color: var(--fg) !important; } #ocd-preview .ocd-pv-k{ flex: 0 0 64px; color: var(--fg-muted); font-size: 11px; letter-spacing: 1px; text-align: justify; text-align-last: justify; } #ocd-preview .ocd-pv-k::after{ content: ":"; margin-left: 4px; color: var(--fg-muted); } #ocd-preview .ocd-pv-v{ flex: 1; min-width: 0; color: var(--fg) !important; font-weight: 600; text-shadow: 0 0 8px var(--ring); word-break: break-all; } #ocd-settings{ border-top: 1px solid var(--border) !important; } #ocd-settings details{ background: var(--bg-soft) !important; border:1px solid var(--border) !important; border-radius:8px !important; } #ocd-settings summary{ color: var(--fg-muted) !important; } #ocd-settings span[style*="color:#999"], #ocd-settings span[style*="color:#888"]{ color: var(--fg-muted) !important; } #ocd-settings div[style*="color:#666"]{ color: var(--fg-muted) !important; } #ocd-settings label{ color: var(--fg-muted) !important; } #ocd-helper-panel::-webkit-scrollbar{ width:8px; } #ocd-helper-panel::-webkit-scrollbar-thumb{ background: var(--border); border-radius:4px; } #ocd-helper-panel::-webkit-scrollbar-thumb:hover{ background: var(--accent); } .ocd-cand-item{ cursor:pointer; padding:5px 8px 5px 22px; margin:2px 0; border:1px solid var(--border); border-radius:7px; color:var(--fg); font-size:11px; line-height:1.5; word-break:break-all; position:relative; transition: color .15s, border-color .15s, background .15s; } .ocd-cand-item::before{ content:"◦"; position:absolute; left:8px; top:50%; transform:translateY(-50%); color:var(--fg-muted); font-size:11px; } .ocd-cand-item:hover{ color: var(--accent) !important; border-color: var(--accent) !important; background: var(--bg-soft); } .ocd-cand-item:hover::before{ content:"▸"; color: var(--accent); } .ocd-cand-note{ color:var(--fg-muted); font-size:11px; padding:5px 6px; border:1px dashed var(--border); border-radius:7px; margin:2px 0; } #ocd-quick-multi, .ocd-quick-act, #ocd-fs-spec, #ocd-fs-tor{ background: transparent !important; border: 1px solid var(--border) !important; color: var(--fg) !important; border-radius: 8px !important; font-weight: 500 !important; box-shadow: none !important; transition: border-color .15s ease, color .15s ease, background .15s ease; } #ocd-quick-multi{ background: var(--bg-soft) !important; font-weight: 600 !important; letter-spacing: .5px; } .ocd-quick-act:hover, #ocd-fs-spec:hover, #ocd-fs-tor:hover{ border-color: var(--accent) !important; color: var(--accent) !important; background: var(--bg-soft) !important; } #ocd-quick-multi:hover{ border-color: var(--accent) !important; } .ocd-quick-act.ocd-active{ border-color: var(--accent) !important; color: var(--accent) !important; background: var(--bg-soft) !important; } .ocd-quick-act{ white-space: nowrap !important; overflow: hidden; text-overflow: ellipsis; } #ocd-quick-pop{ background: var(--bg) !important; color: var(--fg) !important; box-shadow: var(--shadow); } #ocd-quick-pop button{ background: transparent !important; border: 1px solid var(--border) !important; color: var(--fg) !important; border-radius: 6px !important; } #ocd-quick-pop button:hover{ border-color: var(--accent) !important; color: var(--accent) !important; } .ocd-pv-v.ocd-clip{ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:100%; cursor:default; } /* 已移除 hover 展开(会导致布局抖动);改用 title 属性原生 tooltip */ .ocd-theme-liquid #ocd-helper-panel{ background: linear-gradient(135deg, rgba(255,255,255,.16), rgba(255,255,255,.06)) !important; backdrop-filter: blur(28px) saturate(180%) brightness(1.08); -webkit-backdrop-filter: blur(28px) saturate(180%) brightness(1.08); border: 1px solid rgba(255,255,255,.35) !important; box-shadow: 0 8px 32px rgba(31,38,135,.37), inset 0 1px 0 rgba(255,255,255,.5), inset 0 -1px 0 rgba(255,255,255,.15), inset 1px 0 0 rgba(255,255,255,.22), inset -1px 0 0 rgba(255,255,255,.22) !important; } .ocd-theme-liquid #ocd-input, .ocd-theme-liquid #ocd-settings input[type="text"], .ocd-theme-liquid #ocd-settings select, .ocd-theme-liquid #ocd-settings textarea{ background: rgba(255,255,255,.14) !important; border: 1px solid rgba(255,255,255,.28) !important; box-shadow: inset 0 1px 0 rgba(255,255,255,.35); backdrop-filter: blur(10px); } .ocd-theme-liquid #ocd-fill, .ocd-theme-liquid #ocd-multi-btn, .ocd-theme-liquid #ocd-multi-run, .ocd-theme-liquid #ocd-multi-retry, .ocd-theme-liquid #ocd-settings button{ background: linear-gradient(135deg, rgba(125,216,255,.75), rgba(197,139,255,.75)) !important; box-shadow: 0 4px 16px rgba(125,180,255,.35), inset 0 1px 0 rgba(255,255,255,.5); backdrop-filter: blur(8px); } @keyframes ocd-liquid-sheen{ 0%{transform:translateX(-120%) skewX(-18deg);} 100%{transform:translateX(240%) skewX(-18deg);} } .ocd-theme-liquid #ocd-head::after{ content:""; position:absolute; left:0; top:0; bottom:0; width:60px; background:linear-gradient(90deg, transparent, rgba(255,255,255,.35), transparent); animation: ocd-liquid-sheen 2.4s ease-in-out infinite; pointer-events:none; border-radius:6px; } .ocd-theme-liquid #ocd-helper-panel::before{ content:""; position:absolute; inset:-30px; z-index:-1; background: radial-gradient(circle at 20% 30%, rgba(125,216,255,.35), transparent 50%), radial-gradient(circle at 80% 70%, rgba(197,139,255,.35), transparent 50%); filter: blur(30px); border-radius: 24px; pointer-events:none; } .ocd-theme-liquid #ocd-helper-panel{ overflow: visible; } #ocd-topfix.ocd-collapsed > *:not(#ocd-head):not(#ocd-settings){ display:none !important; } #ocd-topfix.ocd-collapsed > #ocd-settings{ max-height:70vh; overflow-y:auto; } `; function applyTheme(name) { if (THEMES.indexOf(name) === -1) name = 'dark'; store.set(THEME_KEY, name); const panel = document.getElementById('ocd-helper-panel'); if (panel) panel.className = 'ocd-theme-' + name; ['ocd-batch-panel', 'ocd-shell-menu', 'ocd-probe-panel', 'ocd-sh-panel'].forEach(id => { const el = document.getElementById(id); if (el) { el.classList.remove(...Array.from(el.classList).filter(c => c.indexOf('ocd-theme-') === 0)); el.classList.add('ocd-theme-' + name); } }); const dpP = document.getElementById('ocd-dp-panel'); if (dpP) { dpP.classList.remove(...Array.from(dpP.classList).filter(c => c.indexOf('ocd-theme-') === 0)); dpP.classList.add('ocd-theme-' + name); } const dpPop = document.getElementById('dp-set-pop'); if (dpPop) { dpPop.classList.remove(...Array.from(dpPop.classList).filter(c => c.indexOf('ocd-theme-') === 0)); dpPop.classList.add('ocd-theme-' + name); } const dcP = document.getElementById('ocd-dc-panel'); if (dcP) { dcP.classList.remove(...Array.from(dcP.classList).filter(c => c.indexOf('ocd-theme-') === 0)); dcP.classList.add('ocd-theme-' + name); } ['ocd-sil-panel', 'ocd-sil-thinbar'].forEach(cls => { document.querySelectorAll('.' + cls).forEach(el => { el.classList.remove(...Array.from(el.classList).filter(c => c.indexOf('ocd-theme-') === 0)); el.classList.add('ocd-theme-' + name); }); }); const btn = document.getElementById('ocd-theme'); if (btn) btn.title = '切换主题(当前:' + (THEME_NAMES[name] || name) + ')'; const head = panel ? panel.querySelector('#ocd-head b') : null; if (head) { let ver = document.getElementById('ocd-ver-tag'); if (!ver) { ver = document.createElement('span'); ver.id = 'ocd-ver-tag'; ver.style.cssText = 'margin-left:6px;font-size:10px;font-weight:400;color:var(--fg-muted);border:1px solid var(--border);border-radius:4px;padding:0 4px;'; head.appendChild(ver); } ver.textContent = 'v' + VERSION; } if (panel) { let tip = document.getElementById('ocd-theme-tip'); if (!tip) { tip = document.createElement('span'); tip.id = 'ocd-theme-tip'; tip.style.cssText = [ 'position:absolute', 'left:50%', 'top:6px', 'transform:translateX(-50%)', 'font-size:11px', 'font-weight:400', 'color:var(--fg)', 'background:var(--bg-soft)', 'border:1px solid var(--border)', 'border-radius:6px', 'padding:1px 8px', 'pointer-events:none', 'z-index:50', 'opacity:0', 'transition:opacity .5s ease' ].join(';'); const hd = panel.querySelector('#ocd-head'); if (hd) { hd.style.position = 'relative'; hd.appendChild(tip); } } tip.textContent = THEME_NAMES[name] || name; tip.style.transition = 'opacity 0s'; tip.style.opacity = '1'; clearTimeout(tip.__hideTimer); tip.__hideTimer = setTimeout(() => { tip.style.transition = 'opacity 1.2s ease'; tip.style.opacity = '0'; }, 3000); } } function initTheme() { if (document.getElementById('ocd-theme-style')) return; const style = document.createElement('style'); style.id = 'ocd-theme-style'; style.textContent = THEME_CSS + BASE_CSS; document.head.appendChild(style); applyTheme(store.get(THEME_KEY, 'dark')); } /* ================================================================ * §49 发布自检 * ================================================================ */ const CHECKLIST_KEY = 'ocd_helper_checklist_v1'; function shChecklistPrefs() { const p = store.get(CHECKLIST_KEY, {}) || {}; return { blockOnErr: p.blockOnErr === true, showWarn: p.showWarn !== false }; } function shSizeToMB(s) { const m = String(s || '').match(/([\d.,]+)\s*(TB|GB|MB|KB)?/i); if (!m) return 0; const n = parseFloat(m[1].replace(/,/g, '')); if (!isFinite(n)) return 0; const u = String(m[2] || 'MB').toUpperCase(); return u === 'TB' ? n * 1024 * 1024 : u === 'GB' ? n * 1024 : u === 'KB' ? n / 1024 : n; } function shChecklistData() { const scope = getFormScope(); const items = []; const valOf = (key) => { const c = resolveControl(key); if (!c) return ''; if (c.kind === 'select') { const o = c.el.options[c.el.selectedIndex]; return (c.el.selectedIndex > 0 && o) ? String(o.text || c.el.value) : ''; } if (c.kind === 'radio') { const r = c.radios.find(x => x.checked); return r ? String(labelOf(r) || r.value) : ''; } return String(c.el.value || ''); }; for (const [key, cfg] of Object.entries(FIELDS)) { const v = String(valOf(key) || '').trim(); items.push({ key: key, cn: cfg.cn, ok: !!v, lv: v ? 'ok' : (cfg.preset ? 'err' : 'warn'), val: v.slice(0, 60), note: v ? '' : (cfg.preset ? '预设项未选择' : '未填写'), }); } const q = (sel) => { try { const el = scope.querySelector(sel); return el ? String(el.value || '').trim() : ''; } catch (e) { return ''; } }; const title = q('input[name="resource_name"]'); if (title) { const okFmt = /^\d{4}\s+.+\s+-\s+.+/.test(title); items.push({ key: 'titleFmt', cn: '标题格式(年份 艺人 - 专辑)', ok: okFmt, lv: okFmt ? 'ok' : 'warn', val: title.slice(0, 60), note: okFmt ? '' : '建议按「年份 艺人 - 专辑」命名' }); } const cover = q('input[name="cover"]'); items.push({ key: 'cover', cn: '封面链接', ok: !!cover, lv: cover ? 'ok' : 'warn', val: cover.slice(0, 60), note: cover ? '' : '未填封面' }); const spec = q('input[name="spectrogram"]'); items.push({ key: 'spectro', cn: '频谱图', ok: !!spec, lv: spec ? 'ok' : 'warn', val: spec.slice(0, 40), note: spec ? '' : '未填频谱(部分分类必填)' }); const tl = q('textarea[name="track_list"]') || q('textarea[name="tracks"]') || q('input[name="tracks"]') || q('textarea[name="tracklist"]'); items.push({ key: 'tracks', cn: '曲目列表', ok: !!tl, lv: tl ? 'ok' : 'warn', val: (tl.split('\n')[0] || '').slice(0, 60), note: tl ? '' : '未填曲目' }); const descr = q('textarea[name="descr"]'); items.push({ key: 'descr', cn: '简介', ok: !!descr, lv: descr ? 'ok' : 'warn', val: descr.slice(0, 60), note: descr ? '' : '未填简介' }); const dupTxt = String((document.querySelector('#ocd-dup-list') || {}).textContent || '').replace(/\s+/g, ' ').trim(); const dupOk = /无重复|未发现|干净|安全/.test(dupTxt); const dupBad = /疑似|重复/.test(dupTxt) && !dupOk; items.push({ key: 'dup', cn: '发种查重结果', ok: dupOk, lv: dupBad ? 'err' : (dupOk ? 'ok' : 'warn'), val: dupTxt.slice(0, 60) || '尚未执行', note: dupBad ? '存在疑似重复,请人工确认' : '' }); return items; } function shChecklistReport(items) { return items.map(i => (i.ok ? '[OK] ' : (i.lv === 'err' ? '[必填缺失] ' : '[警告] ')) + i.cn + ':' + (i.val || i.note)).join('\n'); } /* ================================================================ * 发布前「最终一屏」(4.6)+ 面包屑式返回(8.4) * ================================================================ */ function shPublishFieldScan() { const rows = [], missing = []; Object.keys(FIELDS).forEach((k) => { const f = FIELDS[k] || {}; let el = null; for (const sel of (f.selectors || [])) { try { el = document.querySelector(sel); } catch (e) { el = null; } if (el) break; } if (!el) { missing.push(f.cn || k); return; } let v = ''; try { if (f.kind === 'radio') { const c = document.querySelector(String(f.selectors[0]) + ':checked'); v = c ? (String(c.value || '已选')) : ''; } else if (el.tagName === 'SELECT') { v = (el.selectedIndex > 0 && el.options[el.selectedIndex]) ? String(el.options[el.selectedIndex].textContent || el.value).trim() : ''; } else { v = String(el.value || '').trim(); } } catch (e) { v = ''; } rows.push({ key: k, cn: f.cn || k, v: v }); }); const cover = (rows.find(r => r.key === 'cover') || {}).v || ''; const tracks = (rows.find(r => r.key === 'tracklist') || {}).v || ''; const descr = (function () { try { const d = document.querySelector('textarea[name="descr"]'); return d ? String(d.value || '') : ''; } catch (e) { return ''; } })(); return { rows: rows, missing: missing, cover: cover, tracks: tracks, descr: descr }; } function shPublishSummaryText(scan) { const dupTxt = (function () { try { const el = document.querySelector('#ocd-pv-dup'); return el ? String(el.textContent || '').replace(/\s+/g, ' ').trim() : ''; } catch (e) { return ''; } })(); const lines = ['【OpenCD 发布前摘要】', '时间:' + new Date().toLocaleString('zh-CN')]; scan.rows.forEach(r => { if (r.v) lines.push('· ' + r.cn + ':' + (r.v.length > 120 ? r.v.slice(0, 120) + '…' : r.v)); }); if (dupTxt) lines.push('· 查重结论:' + dupTxt); if (scan.missing.length) lines.push('· 未找到字段:' + scan.missing.join('、')); return lines.join('\n'); } /* ================================================================ * 首次使用引导(自选打磨)+ 一键自检 + 更新要点提示 * ================================================================ */ const ONBOARD_KEY = 'ocd_helper_onboard_v1'; const SEENVER_KEY = 'ocd_helper_seenver_v1'; function shShowOnboarding(force) { const old = document.getElementById('ocd-onboard-pop'); if (old) { old.remove(); if (!force) return; } const html = '
' + '欢迎使用 OpenCD 工具箱 v' + VERSION + ' —— 一个把「发布 → 查重 → 批量 → 做种 → 统计 → 求种订阅 → 通知」收进悬浮球的脚本。' + '下面是三条最常用的动线,点按钮可直接打开对应模块。
' + '
' + '
' + '
🎵 发一颗种子
' + '
发布页粘贴目录名 → 平台匹配 → 抓封面/简介 → 查重 → 填充。
' + ' ' + '
' + '
' + '
⚡ 批量发种
' + '
粘贴多行目录名 → 入队 → 解析/查重 → 静默发布,失败可单条重试。
' + ' ' + '
' + '
' + '
🎯 盯站与订阅
' + '
关键词订阅(站内收录 / 音乐平台新发行)→ 命中进通知中心 + 桌面通知。
' + ' ' + '
' + '
' + '
' + ' 快捷键:Alt+K 命令面板 · Alt+B 批量 · Alt+S 做种看板 · Alt+D 数据面板 · Alt+N 通知 · Alt+R 查重 · Esc 关闭最上面板
' + ' 所有限制都可配:⚙ 设置中心 → 🎛️ 高级设置(0 = 不限,达到推荐值只软提醒)
' + ' 数据在你本地:订阅 / 快照 / 账本都存在浏览器里,可随时完整备份' + '
' + '
' + ' ' + ' ' + ' ' + ' ' + ' ' + '
'; const win = shUtilityModal('👋 欢迎使用 OpenCD 工具箱', html, { width: 760 }); setTimeout(() => { const pop = document.getElementById('ocd-util-pop'); if (!pop) return; pop.id = 'ocd-onboard-pop'; const done = () => { try { pop.remove(); } catch (e) {} store.set(ONBOARD_KEY, VERSION); }; const cl = pop.querySelector('#ocd-onb-close'); if (cl) cl.addEventListener('click', done); pop.querySelectorAll('.ocd-onb-go').forEach((b) => b.addEventListener('click', () => { const go = b.dataset.go; done(); if (go === 'subs') shShowSubsTool(); else shOpenModuleById(go); })); const st = pop.querySelector('#ocd-onb-settings'); if (st) st.addEventListener('click', () => { done(); openSettingsCenter(); }); const gd = pop.querySelector('#ocd-onb-guide'); if (gd) gd.addEventListener('click', () => { try { const url = MANUAL_URL; /* V9.73.19:改为线上链接(原 file:// 被浏览器拦截) */ window.open(url, '_blank'); } catch (e) { toastSafe('手册文件位于脚本同目录:OpenCD 工具箱 使用手册.html', 'info'); } }); const dg = pop.querySelector('#ocd-onb-diag'); if (dg) dg.addEventListener('click', () => { done(); shShowDiagnosis(); }); }, 0); return win; } /* 一键自检(🩺):把结构自检 / 定时器 / 缓存 / 请求去重 / 存储占用汇总成一屏 */ function shShowDiagnosis() { let selft = ''; try { selft = String(shSelectorSelfCheck() || ''); } catch (e) { selft = ''; } const lines = []; const push = (k, v, ok) => lines.push({ k: k, v: String(v), ok: ok }); let storeKeys = 0, storeBytes = 0; try { const ks = (typeof GM_listValues === 'function') ? GM_listValues() : []; storeKeys = ks.length; ks.forEach((k) => { try { storeBytes += String(store.get(k, '') || '').length; } catch (e) {} }); } catch (e) {} push('脚本版本', VERSION, true); push('数据版本', (Number(store.get(SCHEMA_KEY, 0)) || 0), (Number(store.get(SCHEMA_KEY, 0)) || 0) >= 3); push('模块数量', getModuleRegistry().filter(m => !m.hidden).length + ' 个(含 1 个隐藏)', true); push('已注册周期任务', ocdEveryList().map(t => t.name).join(' / ') || '无', ocdEveryList().length > 0); push('请求去重命中', ocdInflightHits() + ' 次', true); try { const rs = ocdReqStats(); push('网络请求(本次会话)', rs.total + ' 次 · 失败 ' + rs.fail + ' · 平均 ' + rs.avg + 'ms · Top: ' + (rs.byHost.slice(0, 3).map(h => h.host + '×' + h.n).join(' / ') || '无'), rs.fail === 0); } catch (e) {} push('存储键 / 估算占用', storeKeys + ' 键 · ' + (storeBytes / 1024).toFixed(1) + ' KB', storeBytes < 4 * 1024 * 1024); push('触屏拖拽桥接', window.__ocdTouchDragBound ? '已注册' : '未注册', !!window.__ocdTouchDragBound); push('日志级别', Object.keys(OCD_LEVELS).find(k => OCD_LEVELS[k] === ocdLogLevel()) || 'info', true); push('站点结构自检', selft ? (selft.length > 200 ? selft.slice(0, 200) + '…' : selft) : '(无法在面板内运行,可在页面点命令面板 → 站点结构自检)', !!selft); const html = '
' + '汇总运行时状态,反馈问题时把这份内容复制给对方最有效。
' + '' + lines.map(l => '' + '' + '').join('') + '
' + esc(l.k) + '' + (l.ok ? '' : '⚠ ') + esc(l.v) + '
' + '
' + ' ' + ' ' + ' ' + ' ' + '
'; const win = shUtilityModal('🩺 一键自检', html, { width: 680 }); setTimeout(() => { const pop = document.getElementById('ocd-util-pop'); if (!pop) return; const txt = '【OpenCD 工具箱 自检】v' + VERSION + ' · ' + new Date().toLocaleString('zh-CN') + '\n' + lines.map(l => '· ' + l.k + ':' + l.v).join('\n'); const cp = pop.querySelector('#ocd-diag-copy'); if (cp) cp.addEventListener('click', () => { try { GM_setClipboard(txt); toastSafe('已复制自检结果', 'ok'); } catch (e) { toastSafe('复制失败', 'err'); } }); const rf = pop.querySelector('#ocd-diag-refresh'); if (rf) rf.addEventListener('click', () => { try { pop.remove(); } catch (e) {} shShowDiagnosis(); }); const sg = pop.querySelector('#ocd-diag-storage'); if (sg) sg.addEventListener('click', () => { try { pop.remove(); } catch (e) {} try { shShowStorageUsage(); } catch (e) {} }); const rq = pop.querySelector('#ocd-diag-req'); if (rq) rq.addEventListener('click', () => { const rs = ocdReqStats(); shUtilityModal('🌐 请求统计(本次会话)', '
共 ' + rs.total + ' 次 · 失败 ' + rs.fail + ' · 平均 ' + rs.avg + 'ms · 去重复用 ' + rs.dedupe + ' 次
' + '' + (rs.byHost.length ? rs.byHost.map(h => '').join('') : '') + '
域名次数失败平均耗时
' + esc(h.host) + '' + h.n + '' + h.fail + '' + h.avg + 'ms
本次会话还没有网络请求
', { width: 560 }); setTimeout(() => { const p2 = document.getElementById('ocd-util-pop'); const rb = p2 && p2.querySelector('#ocd-req-reset'); if (rb) rb.addEventListener('click', () => { ocdReqStats(true); try { p2.remove(); } catch (e) {} toastSafe('请求统计已清空', 'ok'); }); }, 0); }); }, 0); return win; } /* 更新要点提示:版本变化时提示一次,可一键查看更新记录 */ function shCheckVersionNotice() { try { const seen = String(store.get(SEENVER_KEY, '') || ''); if (seen === VERSION) return false; store.set(SEENVER_KEY, VERSION); if (!seen) { setTimeout(() => { try { shShowOnboarding(true); } catch (e) {} }, 9000); return true; } setTimeout(() => { try { toastAction('🆕 已更新到 v' + VERSION + '(点击查看更新要点)', '查看更新', () => shShowChangelog(), { ms: 12000, level: 'ok' }); } catch (e) {} }, 4000); return true; } catch (e) { return false; } } /* 发布耗时与失败归因(4.4) */ function shPublishStatsData() { const { tasks } = getBatchTasks(); const list = tasks || []; const byState = {}, byPlat = {}, errCount = {}; let avgMs = 0; try { avgMs = Number(sessionStorage.getItem('ocd_batch_avgms')) || 0; } catch (e) { avgMs = 0; } list.forEach((t) => { byState[t.state] = (byState[t.state] || 0) + 1; const pn = taskPlatformOf(t) || '未识别平台'; if (!byPlat[pn]) byPlat[pn] = { published: 0, failed: 0, other: 0, total: 0 }; byPlat[pn].total++; if (t.state === 'published') byPlat[pn].published++; else if (t.state === 'failed') byPlat[pn].failed++; else byPlat[pn].other++; if (t.state === 'failed') { const key = String(t.err || '未知原因').replace(/\d{3,}/g, 'N').replace(/#\d+/g, '#N').slice(0, 60).trim() || '未知原因'; errCount[key] = (errCount[key] || 0) + 1; } }); const published = byState.published || 0; const failed = byState.failed || 0; const done = published + failed; return { total: list.length, published: published, failed: failed, successRate: done ? Math.round(published / done * 100) : 0, byState: byState, byPlat: byPlat, errors: errCount, avgMs: avgMs, remain: list.filter(x => ['pending', 'dupchecking', 'dup-ok', 'parsing', 'parsed', 'publishing', 'verifying'].indexOf(x.state) !== -1).length, }; } function shShowPublishStats() { const d = shPublishStatsData(); const plats = Object.keys(d.byPlat).sort((a, b) => d.byPlat[b].total - d.byPlat[a].total); const errs = Object.keys(d.errors).sort((a, b) => d.errors[b] - d.errors[a]).slice(0, 10); const bar = (n, total, color) => '
' + '
'; const fmtMs = (ms) => (ms ? (ms >= 60000 ? (ms / 60000).toFixed(1) + ' 分钟' : Math.round(ms / 1000) + ' 秒') : '—'); const html = '
' + ['任务总数|' + d.total + '|var(--fg-muted)', '已发布|' + d.published + '|#3fd68f', '失败|' + d.failed + '|#ff6b6b', '待处理|' + d.remain + '|#f59e0b', '成功率|' + d.successRate + '%|#5b8cff', '平均每步|' + fmtMs(d.avgMs) + '|#c58bff'] .map(x => { const p = x.split('|'); return '
' + '
' + p[0] + '
' + p[1] + '
'; }).join('') + '
' + '
🎶 按平台
' + (plats.length ? ('' + '' + plats.map(pn => { const v = d.byPlat[pn]; const rate = (v.published + v.failed) ? Math.round(v.published / (v.published + v.failed) * 100) : 0; return '' + '' + ''; }).join('') + '
平台总数成功失败成功率分布
' + esc(pn) + '' + v.total + '' + v.published + '' + v.failed + '' + rate + '%' + bar(v.published, v.total, '#3fd68f') + '
') : '
暂无任务数据
') + '
⚠️ 失败原因 Top 10
' + (errs.length ? ('' + errs.map((e, i) => '' + '').join('') + '
' + (i + 1) + '' + esc(e) + '×' + d.errors[e] + '
') : '
没有失败记录 🎉
') + '
' + ' ' + ' ' + ' ' + '
' + '
统计基于本地批量任务队列;「平均每步」来自最近的静默发布耗时采样。
'; const win = shUtilityModal('📈 发布耗时与失败归因', html, { width: 760 }); setTimeout(() => { const pop = document.getElementById('ocd-util-pop'); if (!pop) return; const rb = pop.querySelector('#pst-refresh'); if (rb) rb.addEventListener('click', () => { try { pop.remove(); } catch (e) {} shShowPublishStats(); }); const cb = pop.querySelector('#pst-copy'); if (cb) cb.addEventListener('click', () => { const txt = ['【发布统计】', '任务 ' + d.total + ' · 已发布 ' + d.published + ' · 失败 ' + d.failed + ' · 成功率 ' + d.successRate + '% · 待处理 ' + d.remain] .concat(plats.map(pn => '· ' + pn + ':总 ' + d.byPlat[pn].total + ' / 成功 ' + d.byPlat[pn].published + ' / 失败 ' + d.byPlat[pn].failed)) .concat(errs.map((e, i) => '· 失败' + (i + 1) + ':' + e + ' ×' + d.errors[e])).join('\n'); try { GM_setClipboard(txt); toastSafe('已复制发布统计', 'ok'); } catch (e) { toastSafe('复制失败', 'err'); } }); const ob = pop.querySelector('#pst-open'); if (ob) ob.addEventListener('click', () => shOpenModuleById('batch')); }, 0); return win; } function shShowPublishFinalScreen() { const scan = shPublishFieldScan(); const dupTxt = (function () { try { const el = document.querySelector('#ocd-pv-dup'); return el ? String(el.textContent || '').replace(/\s+/g, ' ').trim() : ''; } catch (e) { return ''; } })(); const filled = scan.rows.filter(r => r.v).length; const coverUrl = scan.cover || (function () { try { const img = document.querySelector('#ocd-cover-preview img, .ocd-cover-img img, #ocd-pv-cover img'); return img ? img.src : ''; } catch (e) { return ''; } })(); const tracksShow = scan.tracks ? scan.tracks.split('\n').filter(Boolean).slice(0, 12) : []; const introShow = scan.descr ? scan.descr.replace(/\s+/g, ' ').slice(0, 240) : ''; const html = '
' + ' 发布页›最终确认' + ' ' + ' 已填 ' + filled + ' / ' + scan.rows.length + ' 项' + '
' + '
' + '
' + '
🖼 封面
' + (coverUrl ? '封面' : '
未检测到封面(可在发布页助手点「🎨 抓封面」)
') + '
📝 简介开头
' + (introShow ? '
' + esc(introShow) + (scan.descr.length > 240 ? '…' : '') + '
' : '
简介为空
') + '
🎵 曲目(前 12 行)
' + (tracksShow.length ? '
' + tracksShow.map(x => esc(x)).join('
') + (scan.tracks.split('\n').filter(Boolean).length > 12 ? '
…' : '') + '
' : '
曲目为空
') + '
' + '
' + '
🧾 字段摘要
' + ' ' + scan.rows.map(r => '' + '' + '').join('') + '
' + esc(r.cn) + '' + (r.v ? esc(r.v.length > 90 ? r.v.slice(0, 90) + '…' : r.v) : '未填写') + '
' + '
🔍 查重结论
' + '
' + (dupTxt ? esc(dupTxt) : '尚未查重(可在发布页助手点「🔍 查重」)') + '
' + (scan.missing.length ? ('
未在页面找到这些字段:' + esc(scan.missing.join('、')) + '(可能当前不在发布页)
') : '') + '
' + '
' + '
' + ' ' + ' ' + ' ' + ' ' + ' ' + '
' + '
提示:本站字段较多,最终确认只做只读汇总;提交前请再核对分类 / 媒介 / 格式与简介来源。
'; const win = shUtilityModal('🚀 发布前最终确认', html, { width: 880 }); setTimeout(() => { const pop = document.getElementById('ocd-util-pop'); if (!pop) return; const on = (id, fn) => { const el = pop.querySelector('#' + id); if (el) el.addEventListener('click', fn); }; on('pfs-fill', () => { const b = document.getElementById('ocd-fill'); if (b) { b.click(); toastSafe('已触发一键填充', 'ok'); } else toastSafe('未找到发布页助手面板', 'warn'); }); on('pfs-copy', () => { try { GM_setClipboard(shPublishSummaryText(scan)); toastSafe('已复制发布摘要', 'ok'); } catch (e) { toastSafe('复制失败', 'err'); } }); on('pfs-back', () => { try { pop.remove(); } catch (e) {} }); on('pfs-submit', async () => { const form = (function () { const t = document.querySelector('textarea[name="descr"]'); return t ? t.closest('form') : null; })(); const btn = form ? form.querySelector('input[type="submit"], button[type="submit"], #submit') : null; if (!btn) { toastSafe('未找到站点提交按钮(请确认在发布页)', 'err'); return; } const okGo = await shConfirm({ title: '确认提交发布?', html: '将点击站点发布按钮提交当前表单。
已填字段:' + filled + ' / ' + scan.rows.length + (dupTxt ? ('
查重:' + esc(dupTxt.slice(0, 80))) : ''), buttons: [{ text: '再看看', value: false }, { text: '提交', value: true, primary: true }] }); if (okGo) { try { btn.click(); toastSafe('已提交,页面将跳转', 'ok'); } catch (e) { toastSafe('提交失败:' + ((e && e.message) || e), 'err'); } } }); }, 0); return win; } function shShowChecklist() { const render = () => { const items = shChecklistData(); const errs = items.filter(i => i.lv === 'err' && !i.ok); const warns = items.filter(i => i.lv === 'warn' && !i.ok); const oks = items.filter(i => i.ok).length; return { html: '
' + (errs.length ? '🚫 ' + errs.length + ' 项必填缺失' : '✅ 必填项齐全') + ' · 通过 ' + oks + '/' + items.length + (warns.length ? ' · ⚠️ ' + warns.length + ' 项建议补全' : '') + '
' + items.map(i => '
' + '' + (i.ok ? '✓' : (i.lv === 'err' ? '✗' : '!')) + '' + '' + esc(i.cn) + '' + '' + esc(i.val || i.note || '') + '' + (i.ok ? '' : '') + '
').join('') + '
' + ' ' + '
', copy: shChecklistReport(items), }; }; const first = render(); const win = shUtilityModal('✅ 发布自检清单', first.html, { copyText: first.copy }); const bind = () => { const rb = win.querySelector('#ocd-chk-again'); if (rb) rb.addEventListener('click', () => { const r = render(); const body = win.querySelector('#ocd-util-body'); body.innerHTML = r.html; const cp = win.querySelector('#ocd-util-copy'); if (cp) cp.onclick = () => copyText(r.copy, () => toastSafe('📋 已复制', 'ok')); bind(); toastSafe('已重新检查', 'ok'); }); win.querySelectorAll('.ocd-chk-jump').forEach(b => { b.addEventListener('click', () => { const ctrl = resolveControl(b.dataset.key); const el = ctrl && (ctrl.el || (ctrl.radios && ctrl.radios[0])); if (!el || !el.isConnected) { toastSafe('未找到对应控件', 'err'); return; } try { el.scrollIntoView({ block: 'center', behavior: 'smooth' }); el.focus({ preventScroll: true }); const old = el.style.boxShadow; el.style.boxShadow = '0 0 0 3px rgba(91,140,255,.55)'; setTimeout(() => { el.style.boxShadow = old; }, 1600); } catch (e) {} }); }); }; bind(); return win; } /* ================================================================ * §50 促销标签强化 * ================================================================ */ const PROMO_STYLE = { free: { cn: '免费', c: '#3fd68f' }, '2x': { cn: '2X', c: '#f59e0b' }, '2xfree': { cn: '2X免费', c: '#00c6ff' }, half: { cn: '50%', c: '#c58bff' }, p30: { cn: '30%', c: '#9aa3b2' }, }; /* V9.76.17:撤销设置项 —— 「促销标签高亮」「促销行色条」两项开关已从设置中心移除, 功能改为**固定开启**(用户 m13487 ①)。历史键 ocd_helper_promo_v1 不再读写 ✓ */ function shPromoPrefs() { return { enabled: true, tint: true }; } function shPromoOfRow(tr) { if (!tr) return ''; const img = tr.querySelector('img[class*="pro_"]'); if (img) { const k = String(img.className || '').match(/pro_([a-z0-9]+)/i); if (k) { const v = k[1].toLowerCase(); if (PROMO_STYLE[v]) return v; if (v === 'free2x' || v === '2xfree') return '2xfree'; } } const txt = String(tr.textContent || ''); if (/2X\s*免费|2xfree/i.test(txt)) return '2xfree'; if (/免费|Free/i.test(txt)) return 'free'; if (/2X|双倍/i.test(txt)) return '2x'; if (/50%/.test(txt)) return 'half'; if (/30%/.test(txt)) return 'p30'; return ''; } function shApplyPromoHighlight() { if (!/torrents\.php/i.test(location.pathname)) return 0; const prefs = shPromoPrefs(); if (!prefs.enabled) return 0; let n = 0; document.querySelectorAll('table.torrentname tr, table.torrents tr').forEach(tr => { if (tr.dataset.ocdPromo === '1') return; const k = shPromoOfRow(tr); if (!k) return; const st = PROMO_STYLE[k]; tr.dataset.ocdPromo = '1'; if (prefs.tint) tr.style.boxShadow = 'inset 3px 0 0 ' + st.c; const host = tr.querySelector('td.embedded') || tr.firstElementChild; if (host && !host.querySelector('.ocd-promo-chip')) { const chip = document.createElement('span'); chip.className = 'ocd-promo-chip'; chip.textContent = st.cn; chip.title = '促销:' + st.cn; chip.style.cssText = 'display:inline-block;margin-left:6px;padding:0 6px;border-radius:8px;font-size:10px;font-weight:700;vertical-align:middle;' + 'background:' + st.c + '22;color:' + st.c + ';border:1px solid ' + st.c + '55;'; host.appendChild(chip); } n++; }); if (n) console.log('[Promo] 已高亮 ' + n + ' 个促销种子'); return n; } /* ================================================================ * §51 窄屏适配 * ================================================================ */ const RESPONSIVE_KEY = 'ocd_helper_responsive_v1'; function shResponsivePrefs() { const p = store.get(RESPONSIVE_KEY, {}) || {}; return { enabled: p.enabled !== false }; } function shInjectResponsiveCss() { if (document.getElementById('ocd-responsive-css')) return; if (!shResponsivePrefs().enabled) return; const css = document.createElement('style'); css.id = 'ocd-responsive-css'; css.textContent = [ '#ocd-batch-panel,#ocd-dp-panel,#ocd-dc-panel,#ocd-probe-panel,#ocd-helper-panel,#ocd-sh-panel,#ocd-seed-panel,#ocd-arch-panel,#ocd-notify-panel,#ocd-trend-panel{contain:layout style;}', '@media (max-width:1500px){', ' #ocd-batch-panel{width:min(400px,calc(100vw - 24px)) !important;}', ' #ocd-dp-panel,#ocd-dc-panel,#ocd-probe-panel,#ocd-sh-panel{max-width:calc(100vw - 24px) !important;}', '}', '@media (max-width:1100px){', ' #ocd-helper-panel{width:min(420px,calc(100vw - 20px)) !important;}', ' #ocd-seed-panel{width:min(520px,calc(100vw - 20px)) !important;}', ' #ocd-batch-panel{left:10px !important;right:10px !important;width:auto !important;}', ' #ocd-fab{right:10px !important;}', ' #ocd-settings-center,#ocd-batch-stats-win{width:min(96vw,900px) !important;}', ' #ocd-req-preview>div,#ocd-util-pop>div,#ocd-rpv-grab>div{width:min(96vw,900px) !important;}', '}', '@media (max-width:820px){', ' #ocd-helper-panel,#ocd-batch-panel,#ocd-dp-panel,#ocd-dc-panel,#ocd-probe-panel,#ocd-sh-panel,#ocd-seed-panel{left:6px !important;right:6px !important;width:auto !important;max-width:calc(100vw - 12px) !important;}', ' #ocd-req-preview>div,#ocd-util-pop>div,#ocd-rpv-grab>div{max-height:94vh !important;padding:10px !important;}', ' #ocd-shell-menu{max-width:calc(100vw - 20px) !important;}', '}', ].join('\n'); document.head.appendChild(css); console.log('[Responsive] 窄屏适配 CSS 已注入'); } function shFitPanelsToViewport() { if (!shResponsivePrefs().enabled) return 0; const vw = window.innerWidth, vh = window.innerHeight; let n = 0; for (const id of PANEL_IDS.concat(['ocd-helper-panel', 'ocd-seed-panel'])) { const el = document.getElementById(id); if (!el || !el.isConnected) continue; if (el.style.display === 'none') continue; const r = el.getBoundingClientRect(); if (r.width > vw - 8 || r.right > vw - 2 || r.left < 2 || r.height > vh - 8) { el.style.maxWidth = Math.max(280, vw - 12) + 'px'; el.style.maxHeight = Math.max(200, vh - 12) + 'px'; if (r.right > vw - 2) { el.style.right = '6px'; el.style.left = 'auto'; } if (r.left < 2) { el.style.left = '6px'; el.style.right = 'auto'; } n++; } } if (n) console.log('[Responsive] 已按视口重排 ' + n + ' 个面板'); return n; } /* ================================================================ * §52 命令面板与快捷键 * ================================================================ */ const SHORTCUT_KEY = 'ocd_helper_shortcuts_v1'; function shShortcutPrefs() { const p = store.get(SHORTCUT_KEY, {}) || {}; return { enabled: p.enabled !== false }; } function shBuildCommands() { const cmds = []; const add = (id, cn, kw, run) => cmds.push({ id: id, cn: cn, kw: (kw + ' ' + cn).toLowerCase(), run: run }); for (const m of getModuleRegistry().filter(x => !x.hidden)) { add('mod:' + m.id, '打开模块:' + m.name, m.name + ' ' + m.id, () => openModuleUI(m)); } add('tool:selfcheck', '站点结构自检', '自检 结构 selector 选择器', () => shSelectorSelfCheck()); add('tool:notify', '打开通知中心', '通知 站内信 未读 pm', () => openNotifyCenterUI()); add('tool:autotags', '自动标签设置(匹配 / 别名表 / 发烧档位)', '自动标签 标签 别名 genre 流派 发烧 音质 hifi autotag tag', () => openAutoTagSettings()); add('tool:tab-seed', '数据面板 → 做种看板助手', '做种 seedboard 数据面板 标签', () => shOpenDataTab('seed')); add('tool:tab-arch', '数据面板 → 发布档案', '档案 发布 失效 重试 数据面板 标签', () => shOpenDataTab('arch')); add('tool:tab-trend', '数据面板 → 趋势看板', '趋势 图表 快照 数据面板 标签', () => shOpenDataTab('trend')); add('tool:notify-scan', '立即扫描通知', '通知 扫描 刷新', async () => { await shCollectNotifications(true); toastSafe('🔔 通知已更新:未读 ' + shNotifyUnreadCount(), 'ok'); }); add('tool:checklist', '发布自检清单', '自检 发布 checklist', () => shShowChecklist()); add('tool:namecheck', '目录命名规范校验', '目录 命名 规范 校验 文件夹', () => shShowNameCheckTool()); add('tool:torrentcmp', '种子内容比对', '种子 torrent 比对 目录 bencode', () => shShowTorrentCompareTool()); add('tool:subs', '智能订阅', '订阅 关键词 每日 扫描 命中', () => shShowSubsTool()); add('tool:whitelist', '查重白名单管理', '白名单 whitelist 查重 跳过', () => shShowWhitelistTool()); add('tool:onboard', '新手引导(三条核心动线)', '新手 引导 上手 欢迎 教程 onboarding', () => shShowOnboarding(true)); add('tool:pubparams', '发布参数记忆', '发布 参数 记忆 复用 pubparams 上次', () => shShowPubParamsTool()); add('tool:mask', '分享打码(敏感信息抹除)', '打码 脱敏 分享 截图 mask 隐私', () => shShowMaskTool()); add('tool:snapcmp', '快照对比(今日 vs 昨日)', '快照 对比 变化 昨日 趋势 diff compare', () => shShowSnapshotCompare()); add('mod:toolbox', '工具箱维护(自检 / 备份 / 工具面板)', '工具箱 维护 自检 备份 工具 toolbox 排序 置顶', () => openToolboxUI()); add('tool:audit', '操作审计日志', '审计 日志 历史 操作记录 audit 时间线', () => shShowAuditTool()); add('tool:diag', '一键自检(运行时状态汇总)', '自检 诊断 状态 反馈 diag 复制', () => shShowDiagnosis()); add('tool:undo-test', '查看可撤销操作数', '撤销 undo 回退', () => toastSafe('当前可撤销操作:' + _undoStack.length + ' 个(最近 5 分钟内)', 'info')); add('tool:ledger', '求种账本', '求种 悬赏 收入 支出 账本 回报', () => shShowReqLedgerTool()); add('tool:subs-scan', '立即扫描全部订阅', '订阅 扫描 命中', async () => { const r = await shSubScanAll(true); toastSafe('🔔 扫描 ' + r.scanned + ' 条订阅,新增命中 ' + r.hits + ' 条', r.hits ? 'ok' : 'err'); }); add('tool:config-export', '导出配置', '配置 导出 备份 json', shExportConfig); add('tool:config-import', '导入配置', '配置 导入 恢复 json', shImportConfig); add('tool:storage', '查看存储占用', '存储 占用 缓存 清理', shShowStorageUsage); add('tool:logs', '查看调试日志', '日志 log 调试', shShowLogPanel); add('tool:changelog', '查看更新记录', '版本 更新 changelog', shShowChangelog); add('ui:theme', '切换主题', '主题 theme 换肤', () => { try { const cur = store.get(THEME_KEY, 'dark') || 'dark'; const next = THEMES[(THEMES.indexOf(cur) + 1) % THEMES.length]; store.set(THEME_KEY, next); if (typeof applyTheme === 'function') applyTheme(next); toastSafe('🎨 主题:' + next, 'ok'); } catch (e) { toastSafe('主题切换失败:' + (e.message || e), 'err'); } }); add('ui:responsive', '按当前视口重排面板', '窄屏 适配 布局 重排', () => { shFitPanelsToViewport(); toastSafe('已重排可见面板', 'ok'); }); add('ui:promo', '重新高亮促销种子', '促销 免费 2x 高亮', () => { const n = shApplyPromoHighlight(); toastSafe(n ? ('已高亮 ' + n + ' 行') : '当前页未发现促销行', n ? 'ok' : 'err'); }); add('act:dup', '发布页:发种查重', '查重 dup 发布页', () => { const b = document.getElementById('ocd-dup-run') || document.querySelector('#ocd-dup-btn'); if (b) { b.click(); } else toastSafe('请先打开发布页助手', 'err'); }); add('act:stats', '发布耗时与失败归因统计', '发布 统计 成功率 耗时 失败原因 归因', () => shShowPublishStats()); add('act:final', '发布页:发布前最终确认(最终一屏)', '发布 最终 确认 摘要 封面 曲目 提交 finalscreen', () => shShowPublishFinalScreen()); add('act:fill', '发布页:解析并填充', '解析 填充 发布页', () => { const b = document.getElementById('ocd-fill'); if (b) { b.click(); } else toastSafe('请先打开发布页助手', 'err'); }); add('act:batch', '批量:开始解析', '批量 解析 开始', () => { const b = document.getElementById('ocd-batch-parse'); if (b) { b.click(); } else toastSafe('请先在悬浮球菜单打开批量发种', 'err'); }); return cmds; } /* 参数化命令:输入形如 "dup 关键词" / "sub add 关键词" / "theme dark" 直接执行 */ function shParseVerbCommand(text) { const raw = String(text || '').trim(); if (!raw) return null; const alias = shResolveAlias(raw); if (alias) return alias; const m = raw.match(/^(dup|sub|batch|theme|notify|log|自检|查重|订阅|批量|主题|通知)\s*(.*)$/i); if (!m) return null; const verb = m[1].toLowerCase(); const rest = String(m[2] || '').trim(); const kw = rest.replace(/^(add|scan|read|rm)\s+/i, '').trim(); const sub = (rest.match(/^(add|scan|read|rm)\b/i) || [])[1]; if (verb === 'dup' || verb === '查重') { if (!kw) return null; return { label: '🔍 发种查重:' + kw, run: () => shShowDupToolWithKeyword(kw) }; } if (verb === 'sub' || verb === '订阅') { if (sub === 'scan' || !rest) return { label: '🔁 立即扫描全部订阅', run: async () => { const r = await shSubScanAll(true); toastSafe('🔔 扫描 ' + r.scanned + ' 条订阅,新增命中 ' + r.hits + ' 条', r.hits ? 'ok' : 'err'); } }; if (sub === 'add' && kw) return { label: '➕ 添加订阅:' + kw, run: () => { shSubSave({ list: (shSubPrefs().list || []).concat([{ kw: kw, kind: 'site', hits: [], ts: Date.now() }]) }); toastSafe('已添加订阅:' + kw, 'ok'); } }; return { label: '📌 打开智能订阅', run: () => shShowSubsTool() }; } if (verb === 'batch' || verb === '批量') { if (kw) return { label: '📥 加入批量队列:' + kw, run: () => { try { batchAddTasks([kw]); toastSafe('已加入批量队列:' + kw, 'ok'); } catch (e) { toastSafe('入队失败:' + ((e && e.message) || e), 'err'); } } }; return { label: '⚡ 打开批量发种', run: () => shOpenModuleById('batch') }; } if (verb === 'theme' || verb === '主题') { return { label: '🎨 切换主题' + (kw ? ':' + kw : ''), run: () => { const list = THEMES || []; const want = kw ? list.find(x => String(x).toLowerCase().indexOf(kw.toLowerCase()) !== -1) : ''; const next = want || list[(list.indexOf(store.get(THEME_KEY, 'dark')) + 1) % list.length]; store.set(THEME_KEY, next); if (typeof applyTheme === 'function') applyTheme(next); toastSafe('🎨 主题:' + next, 'ok'); } }; } if (verb === 'notify' || verb === '通知') { if (sub === 'read' || kw === 'read') return { label: '✅ 通知全部已读', run: () => { shNotifySave({ seenTs: Date.now() }); toastSafe('通知已全部标记已读', 'ok'); } }; return { label: '🔔 打开通知中心', run: () => openNotifyCenterUI() }; } if (verb === 'log') return { label: '📜 查看调试日志', run: () => shShowLogPanel() }; if (verb === '自检') return { label: '🔬 站点结构自检', run: () => shSelectorSelfCheck() }; return null; } function shShowCommandPalette() { const old = document.getElementById('ocd-cmdk'); if (old) { old.remove(); return; } const cmds = shBuildCommands(); const mask = document.createElement('div'); mask.id = 'ocd-cmdk'; mask.className = shGetThemeCls(); mask.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:' + ocdTopZ(20) + ';display:flex;align-items:flex-start;justify-content:center;padding-top:12vh;'; const win = document.createElement('div'); win.style.cssText = 'width:min(560px,92vw);max-height:64vh;display:flex;flex-direction:column;background:var(--bg,#242632);color:var(--fg,#e8eaf0);border:1px solid #4a69bd;border-radius:12px;padding:10px;font:13px/1.6 -apple-system,"Microsoft YaHei",sans-serif;box-shadow:0 24px 60px rgba(0,0,0,.6);'; win.innerHTML = '' + '' + '
'; mask.appendChild(win); document.body.appendChild(mask); const input = win.querySelector('#ocd-cmdk-input'); const list = win.querySelector('#ocd-cmdk-list'); let filtered = cmds.slice(0, 40), sel = 0; const close = () => { document.removeEventListener('keydown', onKey, true); mask.remove(); }; const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } else if (e.key === 'ArrowDown') { e.preventDefault(); sel = Math.min(filtered.length - 1, sel + 1); paint(); } else if (e.key === 'ArrowUp') { e.preventDefault(); sel = Math.max(0, sel - 1); paint(); } else if (e.key === 'Home') { e.preventDefault(); sel = 0; paint(); } else if (e.key === 'End') { e.preventDefault(); sel = Math.max(0, filtered.length - 1); paint(); } else if (e.key === 'PageDown') { e.preventDefault(); sel = Math.min(filtered.length - 1, sel + 5); paint(); } else if (e.key === 'PageUp') { e.preventDefault(); sel = Math.max(0, sel - 5); paint(); } else if (e.key === 'Tab') { e.preventDefault(); sel = e.shiftKey ? Math.max(0, sel - 1) : Math.min(filtered.length - 1, sel + 1); paint(); } else if (e.key === 'Enter') { e.preventDefault(); const v = shParseVerbCommand(input.value); if (v && (!filtered.length || String(input.value).trim().split(/\s+/).length > 1)) { close(); try { v.run(); } catch (err) { toastSafe('执行失败:' + ((err && err.message) || err), 'err'); } return; } runSel(); } }; const runSel = () => { const c = filtered[sel]; if (!c) return; close(); try { advRecordCmd(c.id); c.run(); } catch (e) { toastSafe('执行失败:' + (e.message || e), 'err'); } }; function paint() { const verbCmd = shParseVerbCommand(input.value); if (!String(input.value || '').trim()) { const STARRED = ['tool:subs', 'mod:batch', 'mod:datapanel', 'mod:notify', 'tool:whitelist', 'tool:diag']; const starItems = STARRED.map(id => cmds.find(c => c.id === id)).filter(Boolean); if (starItems.length) { list.innerHTML = '
⭐ 常用
' + starItems.map(c => '
' + esc(c.cn) + '
').join('') + '
全部命令
' + cmds.slice(0, 40).map((c, i) => '
' + esc(c.cn) + '
').join(''); list.querySelectorAll('.ocd-cmdk-star').forEach(el => el.addEventListener('click', () => { const id = el.dataset.id; close(); shRunCommandById(id); })); list.querySelectorAll('.ocd-cmdk-item').forEach(el => { el.addEventListener('mouseenter', () => { sel = Number(el.dataset.i); paint(); }); el.addEventListener('click', () => { sel = Number(el.dataset.i); runSel(); }); }); return; } const recentIds = advCmdPrefs().recent; const recents = recentIds.map(id => cmds.find(c => c.id === id)).filter(Boolean); if (recents.length) { list.innerHTML = '
🕘 最近使用
' + recents.slice(0, 6).map(c => '
' + esc(c.cn) + '
').join('') + '
全部命令
' + filtered.map((c, i) => '
' + esc(c.cn) + '
').join(''); list.querySelectorAll('.ocd-cmdk-recent').forEach(el => el.addEventListener('click', () => { const id = el.dataset.id; close(); shRunCommandById(id); })); list.querySelectorAll('.ocd-cmdk-item').forEach(el => { el.addEventListener('mouseenter', () => { sel = Number(el.dataset.i); paint(); }); el.addEventListener('click', () => { sel = Number(el.dataset.i); runSel(); }); }); return; } } list.innerHTML = (verbCmd ? ('
↵ ' + esc(verbCmd.label) + '(回车执行)
') : '') + (filtered.length ? filtered.map((c, i) => '
' + esc(c.cn) + '
').join('') : '
没有匹配的命令
'); list.querySelectorAll('.ocd-cmdk-item').forEach(el => { el.addEventListener('mouseenter', () => { sel = Number(el.dataset.i); paint(); }); el.addEventListener('click', () => { sel = Number(el.dataset.i); runSel(); }); }); } input.addEventListener('input', () => { const kw = String(input.value || '').trim().toLowerCase(); /* 前缀模式(3.10):#类别 过滤 · @关键词(平台/来源) 过滤 · > 直接当命令执行 */ const CAT_OF = (id) => (id.indexOf('mod:') === 0 ? '模块' : id.indexOf('tool:') === 0 ? '工具' : id.indexOf('act:') === 0 ? '发布页' : id.indexOf('ui:') === 0 ? '界面' : '其他'); let catMode = '', platMode = '', cmdOnly = false, kw2 = kw; if (kw.charAt(0) === '#') { catMode = kw.slice(1); kw2 = ''; } else if (kw.charAt(0) === '@') { platMode = kw.slice(1); kw2 = ''; } else if (kw.charAt(0) === '>') { cmdOnly = true; kw2 = kw.slice(1); } let base = cmds; /* 类别前缀(#工具 等)严格按命令类别过滤;普通关键词才做名称模糊匹配 */ if (catMode) base = cmds.filter(c => CAT_OF(c.id).indexOf(catMode) !== -1); if (platMode) base = cmds.filter(c => c.kw.indexOf(platMode) !== -1 || c.cn.toLowerCase().indexOf(platMode) !== -1); filtered = (kw2 ? base.filter(c => c.kw.indexOf(kw2) !== -1 || c.cn.toLowerCase().indexOf(kw2) !== -1) : base).slice(0, 40); try { const hint = document.getElementById('ocd-cmdk-mode'); if (hint) { hint.textContent = cmdOnly ? ('> 直接执行:' + kw2) : catMode ? ('# 类别:' + (catMode || '全部') + '(模块 / 工具 / 发布页 / 界面)') : platMode ? ('@ 关键词:' + (platMode || '全部') + '(平台 / 来源名)') : ''; hint.style.display = hint.textContent ? 'block' : 'none'; } } catch (e) {} sel = 0; paint(); }); mask.addEventListener('click', (e) => { if (e.target === mask) close(); }); document.addEventListener('keydown', onKey, true); paint(); if (_cmdkPreRun >= 0) { const pre = _cmdkPreRun; _cmdkPreRun = -1; if (filtered.length) { sel = Math.max(0, Math.min(filtered.length - 1, pre)); runSel(); return win; } } setTimeout(() => input.focus(), 0); return win; } let _cmdkPreRun = -1; function shOpenModuleById(id) { const mod = getModule(id); if (!mod) { toastSafe('未找到模块:' + id, 'err'); return; } openModuleUI(mod); } function shCloseTopPanel() { try { const masks = Array.from(document.querySelectorAll('body > div')).filter((el) => { const st = window.getComputedStyle(el); return st.position === 'fixed' && st.display !== 'none' && Number(st.zIndex) >= 10000000; }); if (masks.length) { masks[masks.length - 1].remove(); return true; } const panels = allPanelEls().filter(e => window.getComputedStyle(e).display !== 'none'); if (panels.length) { shHidePanel(panels[panels.length - 1].id); return true; } } catch (e) {} return false; } const _shortcutMap = { 'alt+k': () => shShowCommandPalette(), 'alt+p': () => shShowCommandPalette(), 'alt+shift+k': () => shShowCommandPalette(), 'alt+b': () => shOpenModuleById('batch'), 'alt+s': () => shOpenModuleById('seedboard'), 'alt+d': () => shOpenModuleById('datapanel'), 'alt+n': () => shOpenModuleById('notify'), 'alt+r': () => shOpenModuleById('dupcheck'), 'alt+h': () => shOpenModuleById('searchhelp'), 'alt+e': () => shOpenModuleById('probedl'), 'escape': () => shCloseTopPanel(), }; for (let _i = 1; _i <= 9; _i++) { _shortcutMap['alt+' + _i] = ((n) => () => { _cmdkPreRun = n; shShowCommandPalette(); })(_i - 1); } function shKeyName(e) { const parts = []; if (e.altKey) parts.push('alt'); if (e.ctrlKey) parts.push('ctrl'); if (e.shiftKey) parts.push('shift'); if (e.metaKey) parts.push('meta'); const k = String(e.key || '').toLowerCase(); if (!k || k === 'alt' || k === 'control' || k === 'shift' || k === 'meta') return ''; parts.push(k); return parts.join('+'); } function shInstallShortcuts() { if (window.__ocdShortcuts) return; window.__ocdShortcuts = true; document.addEventListener('keydown', (e) => { if (!shShortcutPrefs().enabled) return; const name = shKeyName(e); if (!name || !_shortcutMap[name]) return; const tag = (e.target && e.target.tagName) || ''; if (/^(INPUT|TEXTAREA|SELECT)$/.test(tag) && name !== 'escape' && name.indexOf('alt+') !== 0) return; e.preventDefault(); e.stopPropagation(); try { _shortcutMap[name](); } catch (err) { console.warn('[Hotkey] 执行失败:', err.message || err); } }, true); try { const hk = advCmdPrefs().hotkeys; Object.keys(hk).forEach((combo) => { const key = String(combo).toLowerCase().replace(/\s+/g, ''); if (!key || _shortcutMap[key]) return; const cmdId = String(hk[combo] || ''); if (!cmdId) return; _shortcutMap[key] = () => shRunCommandById(cmdId); }); } catch (e) {} console.log('[Hotkey] 已安装快捷键:' + Object.keys(_shortcutMap).join(' / ')); } /* ================================================================ * §53 做种看板助手 * ================================================================ */ const SEEDBOARD_KEY = 'ocd_helper_seedboard_v1'; const SEEDW_KEY = 'ocd_helper_seed_w_v1'; const SEEDPOS_KEY = 'ocd_helper_seed_pos_v1'; const SEEDUI_KEY = 'ocd_helper_seed_ui_v1'; const SEED_TTL = advTtlMs('seed', 10); const SEED_PAGE_CAP = 6; function shSeedPrefs() { const p = store.get(SEEDUI_KEY, {}) || {}; const sd = advGroup('seed') || {}; return { tab: p.tab || 'seeding', sort: p.sort || 'seeders-asc', kw: String(p.kw || ''), deadOnly: p.deadOnly === true, pageCap: Math.max(1, Math.min(60, Number(p.pageCap) || Number(sd.pageCap) || SEED_PAGE_CAP)), deadMax: Math.max(0, Math.min(999, Number(p.deadMax === undefined ? sd.deadMax : p.deadMax) || 0)), }; } function shSeedPrefsSave(patch) { const p = Object.assign(shSeedPrefs(), patch || {}); const sd = advGroup('seed') || {}; // 面板未显式设置时,不固化由高级设置派生的默认值(否则高级设置改动会被面板旧值覆盖) if (Number(p.pageCap) === (Number(sd.pageCap) || 6)) delete p.pageCap; if (Number(p.deadMax) === (Number(sd.deadMax) || 0)) delete p.deadMax; store.set(SEEDUI_KEY, p); return p; } function shSeedFmtMB(mb) { const n = Number(mb) || 0; if (n >= 1024 * 1024) return (n / 1024 / 1024).toFixed(3) + ' TB'; if (n >= 1024) return (n / 1024).toFixed(2) + ' GB'; return n.toFixed(1) + ' MB'; } function shParseUserStats(html) { const raw = String(html || ''); const txt = raw.replace(/<[^>]*>/g, ' ') .replace(/ /g, ' ').replace(/&/g, '&').replace(/'/g, "'") .replace(/\s+/g, ' '); const pick = (label) => { const re = new RegExp(label + '[\\s\\S]{0,240}?\\((\\d+)\\s*个种子(?:[,,]\\s*共计\\s*([\\d.,]+)\\s*(TB|GB|MB|KB))?(?:[,,]\\s*已下载\\s*([\\d.,]+)\\s*(TB|GB|MB|KB))?\\)'); const m = txt.match(re) || raw.match(re); if (!m) return null; return { n: parseInt(m[1], 10) || 0, mb: m[2] ? dpSizeToMB(m[2], m[3]) : 0, dlMB: m[4] ? dpSizeToMB(m[4], m[5]) : 0, }; }; const out = { published: pick('发布种子'), seeding: pick('当前做种'), leeching: pick('当前下载'), completed: pick('完成种子'), incomplete: pick('未完成种子'), hr: pick('H&R'), }; if (!out.published && !out.seeding && !out.completed) { throw new Error('详情页统计解析失败(未登录或页面结构变化)'); } return out; } async function shFetchSeedStats(uid) { const u = uid || store.get('ocd_helper_dp_uid_v1', '') || shGetMyUid(); if (!u) throw new Error('无法识别用户 ID'); const html = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: 'https://open.cd/userdetails.php?id=' + u, headers: { Referer: 'https://open.cd/' }, timeout: advTimeoutMs('publish', 45000), onload: (r) => resolve(r.responseText || ''), onerror: () => reject(new Error('网络错误')), ontimeout: () => reject(new Error('请求超时(45s)')), }); }); const st = shParseUserStats(html); st.uid = String(u); try { store.set('ocd_helper_dp_uid_v1', String(u)); } catch (e) {} console.log('[SeedBoard] 详情页权威统计:发布=' + (st.published && st.published.n) + ' 做种=' + (st.seeding && st.seeding.n) + ' 下载=' + (st.leeching && st.leeching.n) + ' H&R=' + (st.hr && st.hr.n)); return st; } function shParseUserTorrentRows(html) { const rows = []; if (!html) return rows; const titleRe = /]*)>([\s\S]{0,300}?)<\/a>/gi; const promoRe = /]+class="pro_[a-z0-9]+"[^>]*title="([^"]{1,20})"/i; const anchors = []; let m; while ((m = titleRe.exec(html)) !== null) { const attrTitle = (String(m[2] || "").match(/title="([^"]{1,300})"/) || [])[1] || ""; anchors.push({ idx: m.index, id: m[1], title: stripHtmlTags(attrTitle || m[3] || "").trim() }); } const cells = []; const cellRe = /]*>([\s\S]{1,2000}?)<\/td>/g; let cm; while ((cm = cellRe.exec(html)) !== null) cells.push({ idx: cm.index, content: cm[1] }); for (const a of anchors) { let ti = -1; for (let i = cells.length - 1; i >= 0; i--) { if (cells[i].idx <= a.idx) { ti = i; break; } } if (ti === -1 || ti + 3 >= cells.length) continue; const pm = cells[ti].content.match(promoRe); const szM = cells[ti + 1].content.match(/([0-9.,]+)\s*(?:| |\s)\s*(TB|GB|MB|KB)/i); const seeders = parseInt(stripHtmlTags(cells[ti + 2].content).replace(/[^\d]/g, ''), 10) || 0; const leechers = parseInt(stripHtmlTags(cells[ti + 3].content).replace(/[^\d]/g, ''), 10) || 0; const upM = (ti + 4 < cells.length) ? cells[ti + 4].content.match(/([0-9.,]+)\s*(?:| |\s)\s*(TB|GB|MB|KB)/i) : null; const timeTxt = (ti + 5 < cells.length) ? stripHtmlTags(cells[ti + 5].content).replace(/\s+/g, ' ').trim() : ''; rows.push({ id: a.id, tid: a.id, title: a.title, size: szM ? (szM[1] + ' ' + szM[2].toUpperCase()) : '', sizeMB: szM ? dpSizeToMB(szM[1], szM[2]) : 0, seeders: seeders, leechers: leechers, upMB: upM ? dpSizeToMB(upM[1], upM[2]) : 0, time: /^\d+天?\d*:\d+:\d+$/.test(timeTxt) ? timeTxt : '', promo: pm ? pm[1] : '', }); } return rows; } async function shFetchUserTorrentPages(type, maxPages, onPage) { const uid = store.get('ocd_helper_dp_uid_v1', '') || shGetMyUid(); if (!uid) throw new Error('无法识别用户 ID'); const cap = Math.max(1, Number(maxPages) || shSeedPrefs().pageCap || SEED_PAGE_CAP); const all = []; const seen = {}; let pages = 0, emptyStreak = 0, exhausted = false; for (let page = 1; page <= cap; page++) { const html = await new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url: 'https://open.cd/getusertorrentlistajax.php?userid=' + uid + '&type=' + type + '&page=' + page, headers: { Referer: 'https://open.cd/userdetails.php?id=' + uid }, timeout: 60000, onload: (r) => resolve(r.responseText || ''), onerror: () => resolve(''), ontimeout: () => resolve(''), }); }); const rows = (html && html.length > 80) ? shParseUserTorrentRows(html) : []; let added = 0; for (const r of rows) { if (!r.id || seen[r.id]) continue; seen[r.id] = 1; all.push(r); added++; } pages = page; if (added === 0) { emptyStreak++; exhausted = true; if (emptyStreak >= 2) break; } else emptyStreak = 0; if (onPage) { try { onPage(page, all.length); } catch (e) {} } await new Promise(r => setTimeout(r, 200)); } const truncated = !exhausted && pages >= cap; console.log('[SeedBoard] type=' + type + ' 解析 ' + all.length + ' 条 / ' + pages + ' 页' + (truncated ? '(可能还有更多)' : '(已到底)')); return { rows: all, pages: pages, truncated: truncated, exhausted: exhausted }; } function shSeedTotals(d) { const st = d.stats || {}; const n = (k) => (st[k] && st[k].n) || 0; const mb = (k) => (st[k] && st[k].mb) || 0; const up = d.uploaded || []; return { seeding: n('seeding'), leeching: n('leeching'), published: n('published'), completed: n('completed'), incomplete: n('incomplete'), seedMB: mb('seeding'), pubMB: mb('published'), doneMB: mb('completed'), incMB: mb('incomplete'), hr: (d.hr && d.hr.length) ? d.hr.length : n('hr'), hrMB: (d.hr || []).reduce((a, x) => a + (Number(x.sizeMB) || 0), 0), dead: up.filter(x => !(Number(x.seeders) || 0)).length, loadedUploaded: up.length, loadedSeeding: (d.seeding || []).length, loadedLeeching: (d.leeching || []).length, }; } async function shFetchSeedBoard(force) { if (!force) { const c = store.get(SEEDBOARD_KEY, null); if (c && c.ts && c.stats && Date.now() - c.ts < SEED_TTL) return c; } const out = { ts: Date.now(), stats: null, seeding: [], uploaded: [], leeching: [], hr: [], errs: {}, coverage: {}, }; try { out.stats = await shFetchSeedStats(); } catch (e) { out.errs.stats = (e && e.message) || String(e); console.warn('[SeedBoard] 详情页统计失败:', out.errs.stats); } const tab = shSeedPrefs().tab; try { if (tab === 'hr') { const r = await dpCollectHrList(); out.hr = (Array.isArray(r) ? r : []).slice(0, 2000); } else { const r = await shFetchUserTorrentPages(tab, shSeedPrefs().pageCap); out[tab] = r.rows.slice(0, 2000); out.coverage[tab] = { loaded: r.rows.length, pages: r.pages, truncated: r.truncated, exhausted: r.exhausted }; } } catch (e) { out.errs[tab] = (e && e.message) || String(e); console.warn('[SeedBoard] ' + tab + ' 列表抓取失败:', out.errs[tab]); } out.totals = shSeedTotals(out); try { store.set(SEEDBOARD_KEY, out); } catch (e) {} return out; } async function shSeedLoadMore(d, tab) { if (!d || !tab || tab === 'hr') return { added: 0, loaded: 0, exhausted: true, truncated: false }; const cur = (d.coverage && d.coverage[tab]) || { pages: 0, loaded: 0 }; const before = (d[tab] || []).length; const r = await shFetchUserTorrentPages(tab, (cur.pages || 0) + shSeedPrefs().pageCap); d[tab] = r.rows.slice(0, 2000); d.coverage = d.coverage || {}; d.coverage[tab] = { loaded: r.rows.length, pages: r.pages, truncated: r.truncated, exhausted: r.exhausted }; d.totals = shSeedTotals(d); try { store.set(SEEDBOARD_KEY, d); } catch (e) {} return { added: d[tab].length - before, loaded: d[tab].length, exhausted: r.exhausted, truncated: r.truncated }; } function shSeedBoardStatus() { try { const d = store.get(SEEDBOARD_KEY, null); if (!d || !d.totals) return ''; if (d.totals.hr > 0) return 'H&R ' + d.totals.hr; if (d.totals.seeding > 0) return '做种 ' + d.totals.seeding; return ''; } catch (e) { return ''; } } function shSeedListOf(d, tab, kw, deadOnly) { const src = tab === 'uploaded' ? d.uploaded : tab === 'hr' ? d.hr : tab === 'leeching' ? d.leeching : d.seeding; const list = (src || []).map(x => Object.assign({}, x, { _group: tab })); const k = String(kw || '').trim().toLowerCase(); return list.filter(x => { if (k && String(x.title || '').toLowerCase().indexOf(k) === -1) return false; const _deadMax = shSeedPrefs().deadMax; if (deadOnly && tab !== 'hr' && (Number(x.seeders) || 0) > _deadMax) return false; return true; }); } function shSeedSortList(list, sort) { const arr = list.slice(); const num = (v) => Number(v) || 0; const byTitle = (a, b) => String(a.title || '').localeCompare(String(b.title || ''), 'zh'); const sz = (x) => x.sizeMB || shSizeToMB(x.size); const cmp = { 'seeders-asc': (a, b) => num(a.seeders) - num(b.seeders) || byTitle(a, b), 'seeders-desc': (a, b) => num(b.seeders) - num(a.seeders) || byTitle(a, b), 'leechers-desc': (a, b) => num(b.leechers) - num(a.leechers) || byTitle(a, b), 'size-desc': (a, b) => sz(b) - sz(a) || byTitle(a, b), 'size-asc': (a, b) => sz(a) - sz(b) || byTitle(a, b), 'title': byTitle, }; arr.sort(cmp[sort] || cmp['seeders-asc']); return arr; } /* 订阅命中关键词高亮(先转义再包 mark,避免 XSS) */ function shHlKw(text, kw) { const safe = esc(String(text == null ? '' : text)); const words = String(kw || '').split(/[\s,,、]+/).map(x => x.trim()).filter(x => x.length >= 1).sort((a, b) => b.length - a.length).slice(0, 8); if (!words.length) return safe; let out = safe; words.forEach((w) => { try { let safeKw = ''; for (const ch of w) { safeKw += ('^$.|?*+()[]{}'.indexOf(ch) !== -1) ? ('\\' + ch) : ch; } const re = new RegExp('(' + safeKw + ')', 'gi'); out = out.replace(re, '$1'); } catch (e) {} }); return out; } /* 10.1 分享打码:把邮箱 / 令牌 / passkey / IP / 长串密钥替换为 ***(默认不处理 uid) */ const MASK_RULES = [ { n: '邮箱', re: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, to: '***@***' }, { n: 'passkey/authkey 等', re: /\b(passkey|authkey|torrent_pass|api_?key|apikey|token|access_?token|secret)\b\s*[=:]\s*["']?[A-Za-z0-9._\-]{6,}["']?/gi, to: '$1=***' }, { n: '长串密钥', re: /\b[A-Fa-f0-9]{32,}\b/g, to: '***' }, { n: 'Base64 长串', re: /\b[A-Za-z0-9+/]{40,}={0,2}\b/g, to: '***' }, { n: 'IPv4', re: /\b(?:\d{1,3}\.){3}\d{1,3}\b/g, to: '***.***.***.***' }, ]; function shMaskText(text, opts) { const o = opts || {}; let out = String(text == null ? '' : text); const used = {}; MASK_RULES.forEach((r) => { const m = out.match(r.re); if (m && m.length) { used[r.n] = (used[r.n] || 0) + m.length; out = out.replace(r.re, r.to); } }); if (o.maskUid) { const m = out.match(/\buid=\d+/gi); if (m && m.length) { used['uid'] = m.length; out = out.replace(/\buid=\d+/gi, 'uid=***'); } } return { text: out, masked: used }; } function shShowMaskTool() { const old = document.getElementById('ocd-mask-pop'); if (old) { old.remove(); return; } const mask = document.createElement('div'); mask.id = 'ocd-mask-pop'; mask.className = shGetThemeCls(); mask.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:' + (OCD_Z.CONFIRM + 7) + ';display:flex;align-items:center;justify-content:center;'; const win = document.createElement('div'); win.style.cssText = 'width:min(720px,94vw);max-height:88vh;display:flex;flex-direction:column;background:var(--bg,#242632);color:var(--fg,#e8eaf0);border:1px solid #4a69bd;border-radius:12px;padding:14px 16px;font:13px/1.6 -apple-system,"Microsoft YaHei",sans-serif;box-shadow:0 20px 60px rgba(0,0,0,.55);'; win.innerHTML = '
' + ' 🔒 分享打码(截图 / 发帖前把敏感信息抹掉)' + ' ✕' + '
' + '
把要分享的内容粘到下面,点「打码」:邮箱 / passkey / token / 长串密钥 / IP 会被替换为 ***()
' + '' + '
' + ' ' + ' ' + ' ' + ' ' + '
' + ''; mask.appendChild(win); document.body.appendChild(mask); const inp = win.querySelector('#ocd-mask-in'); const outEl = win.querySelector('#ocd-mask-out'); const info = win.querySelector('#ocd-mask-info'); const run = () => { const r = shMaskText(inp.value, { maskUid: win.querySelector('#ocd-mask-uid').checked }); outEl.value = r.text; const parts = Object.keys(r.masked).map(k => k + '×' + r.masked[k]); info.textContent = parts.length ? ('已打码:' + parts.join(' · ')) : '未发现需要打码的内容'; return r; }; win.querySelector('#ocd-mask-run').addEventListener('click', run); win.querySelector('#ocd-mask-copy').addEventListener('click', () => { if (!outEl.value) run(); try { GM_setClipboard(outEl.value); toastSafe('已复制打码后的文本', 'ok'); } catch (e) { toastSafe('复制失败', 'err'); } }); win.querySelector('#ocd-mask-clear').addEventListener('click', () => { inp.value = ''; outEl.value = ''; info.textContent = ''; }); win.querySelector('#ocd-mask-close').addEventListener('click', () => mask.remove()); mask.addEventListener('click', (e) => { if (e.target === mask) mask.remove(); }); return mask; } /* 📊 快照对比(今日 vs 昨日):纯函数便于测试 + 面板 */ const SNAP_METRICS = [ { k: 'magic', cn: '魔力值', unit: '', better: 1, fix: 0 }, { k: 'ratio', cn: '分享率', unit: '', better: 1, fix: 2 }, { k: 'uploadedMB', cn: '上传量', unit: ' MB', better: 1, fix: 0 }, { k: 'downloadedMB', cn: '下载量', unit: ' MB', better: 0, fix: 0 }, { k: 'seeding', cn: '做种中', unit: ' 个', better: 1, fix: 0 }, { k: 'leeching', cn: '下载中', unit: ' 个', better: 0, fix: 0 }, { k: 'published', cn: '发布种子', unit: ' 个', better: 1, fix: 0 }, { k: 'hr', cn: 'H&R', unit: ' 项', better: -1, fix: 0 }, ]; function shSnapshotDiff(prev, cur) { if (!prev || !cur) return []; return SNAP_METRICS.map((m) => { const a = Number(prev[m.k]) || 0; const b = Number(cur[m.k]) || 0; const d = b - a; const pct = a ? (d / a * 100) : 0; const good = m.better === 0 ? null : (d === 0 ? null : (d > 0) === (m.better > 0)); return { k: m.k, cn: m.cn, unit: m.unit, prev: a, cur: b, delta: d, pct: pct, good: good, fix: m.fix }; }); } function shSnapshotCompareHtml(rows, labels) { if (!rows.length) return '
还没有足够快照可对比 —— 先去「📊 数据统计面板」点两次「⟳ 采集」
'; const fmt = (v, f) => (Number(v) || 0).toFixed(f) + ''; return '' + '' + '' + rows.map((r) => { const arrow = r.delta > 0 ? '▲' : r.delta < 0 ? '▼' : '='; const color = r.good === null ? 'var(--fg-muted)' : (r.good ? '#3fd68f' : '#ff6b6b'); return '' + '' + '' + '' + '' + '' + ''; }).join('') + '
指标' + esc(labels.prev) + '' + esc(labels.cur) + '变化幅度
' + esc(r.cn) + '' + fmt(r.prev, r.fix) + esc(r.unit) + '' + fmt(r.cur, r.fix) + esc(r.unit) + '' + arrow + ' ' + (r.delta > 0 ? '+' : '') + fmt(r.delta, r.fix) + '' + (r.pct ? ((r.pct > 0 ? '+' : '') + r.pct.toFixed(1) + '%') : '—') + '
'; } async function shShowSnapshotCompare() { const old = document.getElementById('ocd-snapcmp-pop'); if (old) { old.remove(); return; } let snaps = [], snapTotal = 0, snapTrunc = false; try { const lim = Math.max(10, Math.min(5000, Number((advGroup('data') || {}).snapLimit) || 500)); const pg = await ocdDB.getAllPaged('snapshots', { limit: lim }); snaps = (pg.rows || []).sort((a, b) => a.ts - b.ts); snapTotal = pg.total || snaps.length; snapTrunc = !!pg.truncated; } catch (e) { snaps = []; } const mode = shShowSnapshotCompare._mode || 'prev'; let prev = null, cur = snaps.length ? snaps[snaps.length - 1] : null; if (snaps.length >= 2) { if (mode === 'day') { cur = snaps[snaps.length - 1]; const target = cur.ts - 24 * 60 * 60 * 1000; prev = snaps.slice(0, -1).sort((a, b) => Math.abs(a.ts - target) - Math.abs(b.ts - target))[0] || snaps[snaps.length - 2]; } else prev = snaps[snaps.length - 2]; } const rows = shSnapshotDiff(prev, cur); const fmtTs = (ts) => ts ? new Date(ts).toLocaleString('zh-CN', { hour12: false }) : '—'; const mask = document.createElement('div'); mask.id = 'ocd-snapcmp-pop'; mask.className = shGetThemeCls(); mask.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:' + (OCD_Z.CONFIRM + 6) + ';display:flex;align-items:center;justify-content:center;'; const win = document.createElement('div'); win.style.cssText = 'width:min(700px,94vw);max-height:88vh;display:flex;flex-direction:column;background:var(--bg,#242632);color:var(--fg,#e8eaf0);border:1px solid #4a69bd;border-radius:12px;padding:14px 16px;font:13px/1.6 -apple-system,"Microsoft YaHei",sans-serif;box-shadow:0 20px 60px rgba(0,0,0,.55);'; win.innerHTML = '
' + ' 📊 快照对比' + ' ' + ' ' + ' ✕' + '
' + '
' + ' ' + esc(mode === 'day' ? '对比昨日同期' : '对比上一次') + ':' + esc(fmtTs(prev && prev.ts)) + ' → ' + esc(fmtTs(cur && cur.ts)) + '' + ' · 共 ' + snaps.length + ' 个快照' + (snapTrunc ? ('(仅读取最近 ' + snaps.length + ' / ' + snapTotal + ' 个,可在高级设置调大上限)') : '') + '
' + '
' + shSnapshotCompareHtml(rows, { prev: '上次', cur: '本次' }) + '
' + '
' + ' ' + ' ' + '
'; mask.appendChild(win); document.body.appendChild(mask); win.querySelector('#ocd-sc-close').addEventListener('click', () => mask.remove()); win.querySelector('#ocd-sc-mode').addEventListener('click', () => { shShowSnapshotCompare._mode = (mode === 'day' ? 'prev' : 'day'); mask.remove(); shShowSnapshotCompare(); }); win.querySelector('#ocd-sc-collect').addEventListener('click', async () => { try { await shCollectSnapshot(); toastSafe('已采集新快照', 'ok'); } catch (e) { toastSafe('采集失败:' + ((e && e.message) || e), 'err'); } mask.remove(); shShowSnapshotCompare(); }); win.querySelector('#ocd-sc-open').addEventListener('click', () => { mask.remove(); shOpenModuleById('datapanel'); }); win.querySelector('#ocd-sc-copy').addEventListener('click', () => { const txt = ['【快照对比】' + fmtTs(prev && prev.ts) + ' → ' + fmtTs(cur && cur.ts)] .concat(rows.map(r => '· ' + r.cn + ':' + r.prev + ' → ' + r.cur + '(' + (r.delta > 0 ? '+' : '') + r.delta + ')')).join('\n'); try { GM_setClipboard(txt); toastSafe('已复制对比结果', 'ok'); } catch (e) { toastSafe('复制失败', 'err'); } }); return mask; } /* 9.1 跨链路流转:断种清单(做种数 ≤ 阈值)→ 批量重发 / 复制 / CSV */ function shSeedDeadList() { try { const d = store.get(SEEDBOARD_KEY, null); if (!d) return []; const p = shSeedPrefs(); const out = []; ['uploaded', 'seeding', 'leeching'].forEach((tab) => { const arr = shSeedListOf(d, tab, p.kw || '', true); shSeedSortList(arr, 'seeders-asc').forEach(x => out.push(x)); }); return out; } catch (e) { return []; } } /* 站点标题 → 规范发布目录名(parseFolderName 约定:`年份 艺人 - 专辑 … WEB …`) */ function shSeedTitleToFolder(title) { const raw = String(title || '').trim(); if (!raw) return ''; try { if (parseFolderName(raw)) return raw; } catch (e) {} let x = raw .replace(/^[\[【][^\]】]{0,30}[\]】]\s*/, '') /* [华语流行] / 【MV】 前缀 */ .replace(/\s*[((]\s*(?:FLAC|APE|WAV|MP3|AAC|DSD|Hi-?Res|24[-/]?\d{2,3}|16[-/]?\d{2,3})[^))]*[))]/gi, ' ') .replace(/\s*[\[【]\s*(?:FLAC|APE|WAV|MP3|AAC|DSD|Hi-?Res|24[-/]?\d{2,3}|16[-/]?\d{2,3})[^\]】]*[\]】]/gi, ' ') .replace(/[((]\s*((?:19|20)\d{2})\s*[))]/g, ' $1 ') /* (2022) → 2022 */ .replace(/\s*[\[【][^\]】]{0,24}[\]】]\s*/g, ' ') /* 其余方括号信息 */ .replace(/\s{2,}/g, ' ') .trim(); const ym = x.match(/\b(?:19|20)\d{2}\b/); const year = ym ? ym[0] : ''; let rest = year ? x.replace(year, ' ').replace(/\s{2,}/g, ' ').trim() : x; rest = rest.replace(/^[\s\-–—·]+|[\s\-–—·]+$/g, '').trim(); if (year && rest.indexOf(' - ') !== -1) return year + ' ' + rest + ' WEB'; return x; } function shSeedDeadToBatch() { const list = shSeedDeadList(); if (!list.length) { toastSafe('没有断种项(先在做种看板点「⟳ 拉取」,或阈值过严)', 'warn'); return 0; } const rawTitles = list.map(x => String(x.title || '').trim()).filter(Boolean); const norm = rawTitles.map(shSeedTitleToFolder).filter(Boolean); const titles = norm.length ? norm : rawTitles; let before = 0; try { before = (getBatchTasks().tasks || []).length; } catch (e) {} try { batchAddTasks(titles); } catch (e) { toastSafe('入队失败:' + ((e && e.message) || e), 'err'); return 0; } let added = titles.length; try { added = Math.max(0, (getBatchTasks().tasks || []).length - before); } catch (e) {} ocdAudit('seed', '断种加入批量重发', '尝试 ' + titles.length + ' 个 / 实际入队 ' + added + ' 个'); if (!added) { toastSafe('⚠️ ' + titles.length + ' 个断种标题都无法解析为「艺人 - 专辑」,请手动核对后再入队', 'warn'); return 0; } toastAction('↻ 已把 ' + added + ' 个断种加入批量队列' + (added < titles.length ? ('(' + (titles.length - added) + ' 个无法解析已跳过)') : ''), '打开批量', () => shOpenModuleById('batch')); return added; } function shSeedDeadCopy() { const list = shSeedDeadList(); if (!list.length) { toastSafe('没有断种项可复制', 'warn'); return ''; } const txt = list.map(x => String(x.title || '').trim()).filter(Boolean).join('\n'); try { GM_setClipboard(txt); toastSafe('📋 已复制 ' + list.length + ' 个断种标题', 'ok'); } catch (e) { toastSafe('复制失败', 'err'); } return txt; } function shSeedDeadCsv() { const list = shSeedDeadList(); if (!list.length) { toastSafe('没有断种项可导出', 'warn'); return ''; } const head = ['标题', '分组', '做种', '下载', '大小', 'H&R', '链接']; const rows = list.map(x => [ String(x.title || '').trim(), x._group === 'uploaded' ? '我发布' : x._group === 'leeching' ? '下载中' : '做种中', Number(x.seeders) || 0, Number(x.leechers) || 0, x.size || '', x.hr ? '是' : '', x.id ? ('https://open.cd/details.php?id=' + x.id) : '', ]); const csv = head.join(',') + '\n' + rows.map(r => r.map(v => '"' + String(v).replace(/"/g, '""') + '"').join(',')).join('\n'); try { const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'opencd-dead-seeds-' + new Date().toISOString().slice(0, 10) + '.csv'; a.click(); setTimeout(() => { try { URL.revokeObjectURL(a.href); } catch (e) {} }, 4000); ocdAudit('seed', '导出断种清单 CSV', list.length + ' 条'); toastSafe('已导出断种清单(' + list.length + ' 条)', 'ok'); } catch (e) { toastSafe('导出失败:' + ((e && e.message) || e), 'err'); } return csv; } async function shBackupTorrents(list, onProgress, shouldStop) { let ok = 0, fail = 0; for (let i = 0; i < list.length; i++) { if (shouldStop && shouldStop()) break; const x = list[i]; const id = x.id || x.tid; try { const blob = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: 'https://open.cd/download.php?id=' + id, headers: { Referer: 'https://open.cd/details.php?id=' + id }, timeout: 60000, responseType: 'blob', onload: (r) => (r.status >= 200 && r.status < 400 && r.response) ? resolve(r.response) : reject(new Error('HTTP ' + r.status)), onerror: () => reject(new Error('网络错误')), ontimeout: () => reject(new Error('超时')), }); }); const name = '[OpenCD].' + String(x.title || id).replace(/[<>:"/\\|?*\n\r\t]+/g, '_').slice(0, 150) + '.torrent'; const u = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = u; a.download = name; a.style.display = 'none'; document.body.appendChild(a); a.click(); setTimeout(() => { try { a.remove(); } catch (e) {} try { URL.revokeObjectURL(u); } catch (e) {} }, 5000); ok++; } catch (e) { fail++; console.warn('[SeedBoard] 备份失败 #' + id + ':', (e && e.message) || e); } if (onProgress) { try { onProgress(i + 1, list.length, ok, fail); } catch (e) {} } await new Promise(r => setTimeout(r, 700)); } return { ok: ok, fail: fail }; } function shSeedListToCsv(list) { const head = ['种子ID', '标题', '体积', '做种', '下载', '累计上传', '做种时长', '分组', '链接']; const rows = list.map(x => [ x.id || x.tid || '', x.title || '', x.size || (x.sizeMB ? shSeedFmtMB(x.sizeMB) : ''), Number(x.seeders) || 0, Number(x.leechers) || 0, x.upMB ? shSeedFmtMB(x.upMB) : '', x.time || '', x._group || '', 'https://open.cd/details.php?id=' + (x.id || x.tid || ''), ]); return [head].concat(rows).map(r => r.map(v => '"' + String(v == null ? '' : v).replace(/"/g, '""') + '"').join(',')).join('\r\n'); } function openSeedBoardUI(container, opts) { const embedRoot = (opts && opts.root) || null; const exist = document.getElementById('ocd-seed-panel'); if (exist && !embedRoot) { if (!exist.isConnected) (document.getElementById('ocd-mod-host') || document.body).appendChild(exist); exist.style.display = 'flex'; _openModuleId = 'seedboard'; applyOpenAnim(exist); return; } const prefs = shSeedPrefs(); const panel = embedRoot || document.createElement('div'); if (embedRoot) panel.setAttribute('data-ocd-view', 'ocd-seed-panel'); else panel.id = 'ocd-seed-panel'; if (!embedRoot) { panel.className = shGetThemeCls(); panel.setAttribute('data-ocd-panel', '1'); panel.style.cssText = 'position:fixed;top:90px;right:18px;z-index:9999990;width:680px;min-width:400px;max-width:1100px;max-height:86vh;' + 'display:flex;flex-direction:column;background:var(--bg,rgba(24,26,32,.96));color:var(--fg,#e8eaf0);border:1px solid #3fd68f;border-radius:12px;' + 'padding:10px 12px;font:13px/1.6 -apple-system,"Microsoft YaHei",sans-serif;box-shadow:0 16px 48px rgba(0,0,0,.5);'; } else { panel.style.cssText = 'display:flex;flex-direction:column;min-height:0;flex:1;'; } panel.innerHTML = '
' + ' 🌱 做种看板助手' + ' ' + ' ' + ' ' + ' ✕' + '
' + '
' + '
' + '
' + ' ' + ' ' + ' ' + '
' + '
' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + '
' + '
'; if (!embedRoot) (container || document.getElementById('ocd-mod-host') || document.body).appendChild(panel); if (!embedRoot) _openModuleId = 'seedboard'; const $ = (id) => panel.querySelector('#' + id); if (embedRoot) { const mc = $('ocd-seed-mini'); if (mc) mc.style.display = 'none'; const cl = $('ocd-seed-close'); if (cl) cl.style.display = 'none'; } const close = () => { panel.style.display = 'none'; if (_openModuleId === 'seedboard') _openModuleId = null; }; $('ocd-seed-close').addEventListener('click', close); let data = null, backupStop = false, backupRunning = false, busy = false; const currentList = () => { const p = shSeedPrefs(); return shSeedSortList(shSeedListOf(data || {}, p.tab, p.kw, p.deadOnly), p.sort); }; const coverageOf = (tab) => (data && data.coverage && data.coverage[tab]) || null; const card = (label, val, clr, title) => '
' + '
' + esc(label) + '
' + '
' + esc(String(val)) + '
'; const renderCards = () => { const T = (data && data.totals) || {}; const src = data && data.stats ? '站点详情页统计' : '列表统计(详情页统计未取到)'; const cov = coverageOf('uploaded'); const deadTip = cov ? ('基于已加载的 ' + (T.loadedUploaded || 0) + ' / ' + (T.published || 0) + ' 颗发布种子统计') : '需要先切到「我发布」分组加载列表后才能统计'; $('ocd-seed-cards').innerHTML = card('我在做种', T.seeding || 0, '#3fd68f', src + ':当前做种') + card('我在下载', T.leeching || 0, '#00c6ff', src + ':当前下载') + card('我发布的', T.published || 0, 'var(--fg)', src + ':发布种子') + card('做种体积', shSeedFmtMB(T.seedMB), '#3fd68f', src + ':当前做种体积') + card('发布体积', shSeedFmtMB(T.pubMB), 'var(--fg-muted)', src + ':发布种子体积') + card('断种', cov ? (T.dead || 0) : '—', (cov && T.dead) ? '#ff6b6b' : 'var(--fg)', deadTip) + card('H&R 风险', T.hr || 0, (T.hr ? '#ff6b6b' : '#3fd68f'), 'H&R 待做种数量') + card('完成种子', T.completed || 0, 'var(--fg-muted)', src + ':完成种子' + (T.doneMB ? '(' + shSeedFmtMB(T.doneMB) + ')' : '')) + card('未完成', T.incomplete || 0, 'var(--fg-muted)', src + ':未完成种子' + (T.incMB ? '(' + shSeedFmtMB(T.incMB) + ')' : '')); const cnt = (k) => (data && data.stats && data.stats[k] && data.stats[k].n) || 0; const loaded = (k) => ((data && data[k]) ? data[k].length : 0); const tabs = [ ['seeding', '⬆ 我做种中', cnt('seeding') || loaded('seeding')], ['uploaded', '📦 我发布', cnt('published') || loaded('uploaded')], ['hr', '⚠️ H&R', (data && data.totals ? data.totals.hr : 0)], ]; const cur = shSeedPrefs().tab; $('ocd-seed-tabs').innerHTML = tabs.map(t => { const on = cur === t[0]; return ''; }).join(''); $('ocd-seed-tabs').querySelectorAll('.ocd-seed-tab').forEach(b => b.addEventListener('click', async () => { if (busy) return; const t = b.dataset.t; shSeedPrefsSave({ tab: t }); if (data && !(data[t] || []).length) { busy = true; const label = t === 'uploaded' ? '我发布' : t === 'hr' ? 'H&R' : '我做种中'; $('ocd-seed-body').innerHTML = '
⏳ 正在抓取「' + esc(label) + '」列表…
'; try { if (t === 'hr') { data.hr = ((await dpCollectHrList()) || []).slice(0, 2000); } else { const r = await shFetchUserTorrentPages(t, shSeedPrefs().pageCap, (pg, n) => { $('ocd-seed-stat').textContent = '抓取第 ' + pg + ' 页 · 已加载 ' + n + ' 条'; }); data[t] = r.rows.slice(0, 2000); data.coverage = data.coverage || {}; data.coverage[t] = { loaded: r.rows.length, pages: r.pages, truncated: r.truncated, exhausted: r.exhausted }; } data.totals = shSeedTotals(data); try { store.set(SEEDBOARD_KEY, data); } catch (e) {} } catch (e) { data.errs = data.errs || {}; data.errs[t] = (e && e.message) || String(e); } busy = false; } renderCards(); renderList(); })); if (data && data.ts) $('ocd-seed-meta').textContent = new Date(data.ts).toLocaleTimeString('zh-CN', { hour12: false }); }; const renderList = () => { const body = $('ocd-seed-body'); if (!data) { body.innerHTML = '
⏳ 正在抓取…
'; return; } const p = shSeedPrefs(); const list = currentList(); const errs = Object.keys(data.errs || {}).map(k => k + ':' + data.errs[k]); const cov = coverageOf(p.tab); const tabTotal = p.tab === 'uploaded' ? ((data.stats && data.stats.published && data.stats.published.n) || 0) : p.tab === 'seeding' ? ((data.stats && data.stats.seeding && data.stats.seeding.n) || 0) : ((data.hr || []).length); const maxSeed = Math.max(1, list.reduce((a, x) => Math.max(a, Number(x.seeders) || 0), 0)); let html = ''; if (errs.length) html += '
' + esc(errs.join(' · ')) + '
'; if (tabTotal && cov && cov.loaded < tabTotal) { if (cov.truncated) { html += '
已加载 ' + cov.loaded + ' / ' + tabTotal + ' 条(点「⏬ 加载更多」继续;断种统计基于已加载部分)
'; } else { html += '
列表已到底:接口共返回 ' + cov.loaded + ' 条,详情页计数 ' + tabTotal + '(相差 ' + (tabTotal - cov.loaded) + ' 条,站点列表未含该部分;断种统计基于已加载部分)
'; } } if (!list.length) { html += '
' + (p.kw || p.deadOnly ? '当前筛选无结果(试试清空关键词或取消「仅断种」)' : (p.tab === 'hr' ? '🎉 没有 H&R 风险项' : '该分组暂无数据')) + '
'; } else { html += '
显示 ' + list.length + ' 条' + (p.kw ? '(关键词「' + esc(p.kw) + '」)' : '') + (p.deadOnly && p.tab !== 'hr' ? '(仅断种)' : '') + '
'; html += list.map(x => { const id = x.id || x.tid || ''; const seeders = Number(x.seeders) || 0; const leechers = Number(x.leechers) || 0; const sizeTxt = x.size || (x.sizeMB ? shSeedFmtMB(x.sizeMB) : ''); const barW = Math.round(Math.min(100, seeders / maxSeed * 100)); const dead = p.tab !== 'hr' && seeders === 0; return '
' + '' + seeders + '/' + leechers + '' + '' + '' + esc(x.title || ('#' + id)) + '' + (x.time ? '' + esc(x.time) + '' : '') + (sizeTxt ? '' + esc(sizeTxt) + '' : '') + '' + '' + '
'; }).join(''); } body.innerHTML = html; body.querySelectorAll('.ocd-seed-cp').forEach((b) => b.addEventListener('click', () => { const row = b.closest('.ocd-seed-row'); const t = row ? ((row.querySelector('a') || {}).textContent || '') : ''; copyText(t, () => toastSafe('📋 已复制标题', 'ok')); })); body.querySelectorAll('.ocd-seed-dl').forEach((b) => b.addEventListener('click', async () => { const row = b.closest('.ocd-seed-row'); const id = row && row.dataset.id; if (!id) return; b.disabled = true; const r = await shBackupTorrents([{ id: id, title: (row.querySelector('a') || {}).textContent || id }]); b.disabled = false; toastSafe(r.ok ? '✅ 种子文件已下载' : '❌ 下载失败,请看控制台', r.ok ? 'ok' : 'err'); })); const moreBtn = $('ocd-seed-more'); if (moreBtn) { const done = !!(cov && cov.exhausted); moreBtn.disabled = done; moreBtn.style.opacity = done ? '.5' : ''; moreBtn.style.cursor = done ? 'not-allowed' : 'pointer'; moreBtn.title = done ? '该分组列表已全部加载(接口共 ' + (cov ? cov.loaded : 0) + ' 条)' : '继续抓取该分组后续分页'; } $('ocd-seed-stat').textContent = '筛选后 ' + list.length + ' 条' + (cov && tabTotal ? ' · 已加载 ' + cov.loaded + '/' + tabTotal : ''); }; const load = async (force) => { busy = true; $('ocd-seed-body').innerHTML = '
⏳ 正在抓取(详情页统计 + 当前分组列表)…
'; try { data = await shFetchSeedBoard(force); } catch (e) { busy = false; $('ocd-seed-body').innerHTML = '
抓取失败:' + esc(e.message || e) + '
'; return; } busy = false; renderCards(); renderList(); }; $('ocd-seed-refresh').addEventListener('click', () => load(true)); $('ocd-seed-mini').addEventListener('click', () => toggleMini()); $('ocd-seed-kw').value = prefs.kw || ''; let kwTimer = null; $('ocd-seed-kw').addEventListener('input', () => { if (kwTimer) clearTimeout(kwTimer); kwTimer = setTimeout(() => { kwTimer = null; shSeedPrefsSave({ kw: $('ocd-seed-kw').value }); renderList(); }, 200); }); $('ocd-seed-sort').value = prefs.sort; $('ocd-seed-sort').addEventListener('change', () => { shSeedPrefsSave({ sort: $('ocd-seed-sort').value }); renderList(); }); $('ocd-seed-dead').addEventListener('click', () => { const p = shSeedPrefsSave({ deadOnly: !shSeedPrefs().deadOnly }); $('ocd-seed-dead').style.borderColor = p.deadOnly ? '#ff6b6b' : 'var(--border,#444)'; $('ocd-seed-dead').style.color = p.deadOnly ? '#ff6b6b' : 'var(--fg-muted)'; renderList(); }); $('ocd-seed-dead').style.borderColor = prefs.deadOnly ? '#ff6b6b' : 'var(--border,#444)'; $('ocd-seed-dead').style.color = prefs.deadOnly ? '#ff6b6b' : 'var(--fg-muted)'; $('ocd-seed-more').addEventListener('click', async () => { if (busy || !data) return; const t = shSeedPrefs().tab; if (t === 'hr') { toastSafe('H&R 分组为全量返回,无需分页', 'ok'); return; } busy = true; const btn = $('ocd-seed-more'); btn.textContent = '⏳ 加载中…'; let r = null; try { r = await shSeedLoadMore(data, t); } catch (e) { toastSafe('加载失败:' + (e.message || e), 'err'); } busy = false; btn.textContent = '⏬ 加载更多'; if (r) { const site = t === 'uploaded' ? ((data.stats && data.stats.published && data.stats.published.n) || 0) : ((data.stats && data.stats.seeding && data.stats.seeding.n) || 0); if (r.added > 0) toastSafe('⏬ 新增 ' + r.added + ' 条,共 ' + r.loaded + (site ? ' / ' + site : ''), 'ok'); else if (r.exhausted) toastSafe('⏹ 列表已到底:接口共返回 ' + r.loaded + ' 条' + (site && site > r.loaded ? ',详情页计数 ' + site + '(相差 ' + (site - r.loaded) + ' 条,站点侧差异)' : ''), 'err'); else toastSafe('本次未取到新数据,可稍后再试', 'err'); } renderCards(); renderList(); }); $('ocd-seed-export').addEventListener('click', () => { const list = currentList(); if (!list.length) { toastSafe('当前筛选无可导出内容', 'err'); return; } dlFile('\ufeff' + shSeedListToCsv(list), 'ocd_seedboard_' + dateTag() + '.csv', 'text/csv'); toastSafe('📄 已导出 ' + list.length + ' 条清单', 'ok'); }); $('ocd-seed-deadbatch').addEventListener('click', () => { shSeedDeadToBatch(); }); $('ocd-seed-deadcopy').addEventListener('click', () => { shSeedDeadCopy(); }); $('ocd-seed-deadcsv').addEventListener('click', () => { shSeedDeadCsv(); }); $('ocd-seed-backup').addEventListener('click', async () => { if (backupRunning) { backupStop = true; toastSafe('⏹ 已请求中止备份…', 'err'); return; } const list = currentList(); if (!list.length) { toastSafe('当前筛选无可备份内容', 'err'); return; } if (!confirm('将逐个下载 ' + list.length + ' 个种子文件(.torrent),每个间隔约 0.7 秒。\n\n数量较多时请耐心等待,可再次点击按钮中止。继续?')) return; backupRunning = true; backupStop = false; const btn = $('ocd-seed-backup'); btn.textContent = '⏹ 中止备份'; const r = await shBackupTorrents(list, (done, total, ok, fail) => { $('ocd-seed-stat').textContent = '备份中 ' + done + '/' + total + ' · 成功 ' + ok + ' · 失败 ' + fail; }, () => backupStop); backupRunning = false; btn.textContent = '⬇ 备份 .torrent'; toastSafe((backupStop ? '⏹ 已中止:' : '✅ 备份完成:') + '成功 ' + r.ok + ' · 失败 ' + r.fail, r.fail ? 'err' : 'ok'); renderList(); }); const toggleMini = () => { const mini = panel.__mini !== true; panel.__mini = mini; for (const id of ['ocd-seed-cards', 'ocd-seed-tools', 'ocd-seed-acts', 'ocd-seed-body']) { const el = $(id); if (!el) continue; if (mini) { if (el.__ocdDisp === undefined) el.__ocdDisp = el.style.display || ''; el.style.display = 'none'; } else { el.style.display = el.__ocdDisp === undefined ? '' : el.__ocdDisp; } } if (mini) { if (panel.__ocdMaxH === undefined) panel.__ocdMaxH = panel.style.maxHeight || ''; panel.style.maxHeight = 'none'; } else { panel.style.maxHeight = panel.__ocdMaxH === undefined ? '' : panel.__ocdMaxH; } const mb = $('ocd-seed-mini'); if (mb) mb.textContent = mini ? '+' : '—'; }; $('ocd-seed-head').addEventListener('dblclick', toggleMini); if (!embedRoot) { makeDraggable($('ocd-seed-head'), SEEDPOS_KEY, panel); makeEdgeResize(panel, { getPanel: () => panel, key: SEEDW_KEY, min: 400, max: 1100, def: 680 }); makePinBtn($('ocd-seed-head'), panel); panel.addEventListener('mousedown', () => focusPanel(panel)); applyOpenAnim(panel); } load(false); } function closeSeedBoardUI() { const el = document.getElementById('ocd-seed-panel'); if (el) el.remove(); if (_openModuleId === 'seedboard') _openModuleId = null; } /* ================================================================ * §54 发布档案 * ================================================================ */ const PUBARCH_KEY = 'ocd_helper_pubarch_v1'; const PUBARCH_TTL = advTtlMs('pubarch', 10); function shModulePanel(opts) { const id = opts.id; const embedRoot = opts.root || null; const exist = document.getElementById(id); if (exist && !embedRoot) { if (!exist.isConnected) (document.getElementById('ocd-mod-host') || document.body).appendChild(exist); exist.style.display = 'flex'; if (opts.moduleId) _openModuleId = opts.moduleId; applyOpenAnim(exist); /* V9.73.12:重开侧复位 —— 任何关闭路径(面板 ✕ / 菜单开关 / 其它)都可能在元素上 留下"收起(迷你)"状态,命中已存在面板时先复位为展开,保证再次打开必定是展开态 */ try { if (exist && exist.__mini === true && exist.__api && typeof exist.__api.toggleMini === "function") { exist.__api.toggleMini(); try { console.log("[V9.73.12] 重开面板:已复位收起状态"); } catch (e) {} } } catch (e) {} return exist.__api; } const panel = embedRoot || document.createElement('div'); if (embedRoot) panel.setAttribute('data-ocd-view', id); else panel.id = id; if (!embedRoot) { panel.className = shGetThemeCls(); panel.setAttribute('data-ocd-panel', '1'); panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-modal', 'false'); panel.setAttribute('aria-label', String(opts.title || '模块面板')); panel.setAttribute('tabindex', '-1'); panel.style.cssText = 'position:fixed;top:' + (opts.top || 96) + 'px;right:18px;z-index:' + (opts.z || OCD_Z.MODULE_PANEL) + ';width:' + (opts.width || 660) + 'px;min-width:' + (opts.minW || 400) + 'px;max-width:' + (opts.maxW || 1200) + 'px;max-height:86vh;display:flex;flex-direction:column;' + 'background:var(--bg,rgba(24,26,32,.96));backdrop-filter:blur(14px) saturate(150%);-webkit-backdrop-filter:blur(14px) saturate(150%);color:var(--fg,#e8eaf0);border:1px solid ' + (opts.color || '#4a69bd') + ';border-radius:12px;padding:10px 12px;' + 'font:13px/1.6 -apple-system,"Microsoft YaHei",sans-serif;box-shadow:0 16px 48px rgba(0,0,0,.5);'; } else { panel.style.cssText = 'display:flex;flex-direction:column;min-height:0;flex:1;'; } panel.innerHTML = '
' + ' ' + opts.icon + ' ' + esc(opts.title) + '' + ' ' + (opts.headHtml || '') + ' ' + ' ✕' + '
' + '
' + (opts.barHtml ? '
' + opts.barHtml + '
' : '') + '
'; if (!embedRoot) (document.getElementById('ocd-mod-host') || document.body).appendChild(panel); if (!embedRoot && opts.moduleId) _openModuleId = opts.moduleId; const $ = (s) => panel.querySelector('#' + id + '-' + s); const miniParts = ['head', 'cards', 'bar', 'body']; const toggleMini = () => { const mini = panel.__mini !== true; panel.__mini = mini; for (const part of miniParts) { const el = $(part); if (!el || part === 'head') continue; if (mini) { if (el.__ocdDisp === undefined) el.__ocdDisp = el.style.display || ''; el.style.display = 'none'; } else { el.style.display = el.__ocdDisp === undefined ? '' : el.__ocdDisp; } } if (mini) { if (panel.__ocdMaxH === undefined) panel.__ocdMaxH = panel.style.maxHeight || ''; panel.style.maxHeight = 'none'; } else { panel.style.maxHeight = panel.__ocdMaxH === undefined ? '' : panel.__ocdMaxH; } $('mini').textContent = mini ? '+' : '—'; }; const close = () => { panel.style.display = 'none'; /* V9.73.9:本助手关闭只是隐藏元素,重开返回同一元素 → 必须把"收起(迷你)"状态复位为展开, 否则再次打开会一直处于收起态(用户反馈:通知中心 / 工具箱维护 / 做种看板 等) */ try { if (panel.__mini === true) toggleMini(); } catch (e) {} if (opts.moduleId && _openModuleId === opts.moduleId) _openModuleId = null; if (opts.onClose) { try { opts.onClose(); } catch (e) {} } }; $('close').addEventListener('click', close); $('mini').addEventListener('click', toggleMini); $('head').addEventListener('dblclick', toggleMini); if (embedRoot) { $('close').style.display = 'none'; $('mini').style.display = 'none'; } else { makeDraggable($('head'), opts.posKey, panel); makeEdgeResize(panel, { getPanel: () => panel, key: opts.wKey, min: opts.minW || 400, max: opts.maxW || 1200, def: opts.width || 660 }); makePinBtn($('head'), panel); panel.addEventListener('mousedown', () => focusPanel(panel)); applyOpenAnim(panel); } const api = { panel: panel, $: $, close: close, toggleMini: toggleMini, setMeta: (t) => { $('meta').textContent = t; }, setCards: (html) => { $('cards').innerHTML = html; }, setBody: (html) => { $('body').innerHTML = html; }, }; panel.__api = api; return api; } function shMakeWheelScrollable(root, getDest) { if (!root || root.__ocdWheel) return; root.__ocdWheel = true; root.addEventListener('wheel', (e) => { try { let dest = (typeof getDest === 'function') ? getDest() : getDest; if (!dest || !dest.isConnected) dest = root; let el = e.target, inner = null; while (el && el !== dest && el !== root.parentElement) { if (el.scrollHeight > el.clientHeight + 2) { const oy = getComputedStyle(el).overflowY; if (oy === 'auto' || oy === 'scroll') { inner = el; break; } } el = el.parentElement; } const sc = inner || dest; if (!sc || sc.scrollHeight <= sc.clientHeight + 1) return; const atTop = sc.scrollTop <= 0; const atBottom = sc.scrollTop + sc.clientHeight >= sc.scrollHeight - 1; if ((e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom)) return; const before = sc.scrollTop; sc.scrollTop = before + e.deltaY; if (sc.scrollTop !== before) e.preventDefault(); } catch (err) {} }, { passive: false }); } function shHidePanel(id) { const el = document.getElementById(id); if (el) el.style.display = 'none'; } function shCard(label, val, clr, title) { return '
' + '
' + esc(label) + '
' + '
' + esc(String(val)) + '
'; } async function shArchiveSearchTitle(title) { const kw = String(title || '').replace(/[\[\]()()]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 60); if (!kw) return 'unknown'; try { const html = await gmFetchText('https://open.cd/torrents.php?search=' + encodeURIComponent(kw) + '&incldead=1&search_area=0', { Referer: 'https://open.cd/' }); if (!html || html.length < 200) return 'unknown'; if (/没有找到|无匹配|Sorry/i.test(html) && !/torrentname/.test(html)) return 'gone'; if (/torrentname/.test(html) && /details\.php\?id=/.test(html)) return 'online'; return 'gone'; } catch (e) { console.warn('[PubArchive] 校验失败:', (e && e.message) || e); return 'unknown'; } } async function shFetchPublishArchive(force) { if (!force) { const c = store.get(PUBARCH_KEY, null); if (c && c.ts && Date.now() - c.ts < PUBARCH_TTL) return c; } const up = (await shFetchUserTorrentPages('uploaded', 40)).rows; const map = {}; up.forEach(x => { map[normKey(x.title)] = x; }); const rows = []; const seen = {}; for (const h of getHistory()) { const k = normKey(h.title); if (!k) continue; seen[k] = 1; const t = map[k]; rows.push(t ? { title: h.title, ts: h.ts || 0, id: t.id, size: t.size, sizeMB: t.sizeMB, seeders: t.seeders, leechers: t.leechers, upMB: t.upMB, time: t.time, state: t.seeders > 0 ? 'online' : 'dead' } : { title: h.title, ts: h.ts || 0, id: '', state: 'missing' }); } for (const x of up) { const k = normKey(x.title); if (seen[k]) continue; rows.push({ title: x.title, ts: 0, id: x.id, size: x.size, sizeMB: x.sizeMB, seeders: x.seeders, leechers: x.leechers, upMB: x.upMB, time: x.time, state: x.seeders > 0 ? 'online' : 'dead', noRecord: true }); } let verified = 0; for (const r of rows) { if (r.state !== 'missing' || verified >= 6) continue; verified++; const v = await shArchiveSearchTitle(r.title); if (v === 'online') r.state = 'online'; else if (v === 'gone') r.state = 'gone'; else r.state = 'missing'; await new Promise(res => setTimeout(res, 400)); } const cnt = (s) => rows.filter(r => r.state === s).length; const out = { ts: Date.now(), rows: rows, stats: { total: rows.length, recorded: rows.filter(r => !r.noRecord).length, online: cnt('online'), dead: cnt('dead'), gone: cnt('gone'), unknown: cnt('missing'), noRecord: rows.filter(r => r.noRecord).length, upMB: rows.reduce((a, r) => a + (Number(r.upMB) || 0), 0), verified: verified, }, }; try { store.set(PUBARCH_KEY, out); } catch (e) {} try { await ocdDB.put('pubRecords', { ts: out.ts, stats: out.stats, rows: out.rows.slice(0, 400) }); } catch (e) {} return out; } function shPubStateChip(state) { const M = { online: { cn: '在线', c: '#3fd68f' }, dead: { cn: '断种', c: '#ff9f43' }, gone: { cn: '已删除', c: '#ff6b6b' }, missing: { cn: '待核对', c: '#9aa3b2' }, }; const s = M[state] || M.missing; return '' + s.cn + ''; } const DP_TABS = [ ['overview', '📊 概览'], ['seed', '🌱 做种看板助手'], ['arch', '📋 发布档案'], ['trend', '📈 趋势看板'], ]; let _dpTabBuilt = null; function shSwitchDataPanelTab(tab) { const bar = document.getElementById('ocd-dp-tabs'); if (!bar) return; const hosts = {}; DP_TABS.forEach(x => { hosts[x[0]] = document.getElementById('ocd-dp-host-' + x[0]); }); if (!hosts[tab]) return; DP_TABS.forEach(x => { const btn = bar.querySelector('[data-t="' + x[0] + '"]'); const on = x[0] === tab; if (btn) { btn.style.borderColor = on ? '#5b8cff' : 'var(--border,#444)'; btn.style.background = on ? 'rgba(91,140,255,.14)' : 'transparent'; btn.style.color = on ? '#5b8cff' : 'var(--fg-muted)'; btn.style.fontWeight = on ? '700' : '400'; } if (hosts[x[0]]) hosts[x[0]].style.display = on ? 'flex' : 'none'; }); try { if (tab === 'seed' && !hosts.seed.firstElementChild) openSeedBoardUI(null, { root: hosts.seed }); else if (tab === 'arch' && !hosts.arch.firstElementChild) openPubArchiveUI({ root: hosts.arch }); else if (tab === 'trend' && !hosts.trend.firstElementChild) openTrendUI({ root: hosts.trend }); const act = hosts[tab]; window.__ocdDpScroll = act ? (function findScroller(node) { if (!node) return null; if (node.scrollHeight > node.clientHeight + 2) { const oy = getComputedStyle(node).overflowY; if (oy === 'auto' || oy === 'scroll') return node; } const kids = node.querySelectorAll('*'); for (let i = 0; i < kids.length; i++) { const el = kids[i]; if (el.scrollHeight > el.clientHeight + 2) { const oy2 = getComputedStyle(el).overflowY; if (oy2 === 'auto' || oy2 === 'scroll') return el; } } return node; })(act) : null; } catch (e) { console.warn('[DataPanel] 标签视图构建失败:', (e && e.message) || e); toastSafe('标签视图构建失败:' + ((e && e.message) || e), 'err'); } } function shMountDataPanelTabs() { const panel = document.getElementById('ocd-dp-panel'); if (!panel || panel.__ocdTabs) return; panel.__ocdTabs = true; const kids = Array.from(panel.children); const header = kids.shift(); const overview = document.createElement('div'); overview.style.cssText = 'flex:1;min-height:0;overflow-y:auto;overscroll-behavior:contain;'; kids.forEach(k => overview.appendChild(k)); const bar = document.createElement('div'); bar.id = 'ocd-dp-tabs'; bar.style.cssText = 'flex:0 0 auto;display:flex;gap:4px;margin:6px 0;flex-wrap:wrap;'; bar.innerHTML = DP_TABS.map(x => '').join(''); const wrap = document.createElement('div'); wrap.id = 'ocd-dp-tabhost'; wrap.style.cssText = 'flex:1;min-height:0;display:flex;flex-direction:column;'; DP_TABS.forEach(x => { const h = document.createElement('div'); h.id = 'ocd-dp-host-' + x[0]; h.style.cssText = 'flex:1;min-height:0;display:none;flex-direction:column;'; if (x[0] === 'overview') { h.style.display = 'flex'; h.appendChild(overview); } wrap.appendChild(h); }); panel.appendChild(bar); panel.appendChild(wrap); bar.querySelectorAll('.ocd-dp-tab').forEach(b => b.addEventListener('click', () => shSwitchDataPanelTab(b.dataset.t))); window.__ocdDpTab = shSwitchDataPanelTab; shMakeWheelScrollable(panel, () => window.__ocdDpScroll || document.getElementById('ocd-dp-tabhost')); window.__ocdDpTabName = () => { const b = bar.querySelector('[data-t]'); return b ? b.dataset.t : ''; }; shSwitchDataPanelTab('overview'); console.log('[DataPanel] 已挂载 4 个标签(概览 / 做种看板助手 / 发布档案 / 趋势看板)'); } function shOpenDataTab(tab) { const mod = getModule('datapanel'); if (!mod) { toastSafe('未找到数据统计面板模块', 'err'); return; } if (!document.getElementById('ocd-dp-panel') || _openModuleId !== 'datapanel') openModuleUI(mod); setTimeout(() => { try { if (!document.getElementById('ocd-dp-tabs')) shMountDataPanelTabs(); shSwitchDataPanelTab(tab); } catch (e) { console.warn('[DataPanel] 切换标签失败:', (e && e.message) || e); } }, 500); } function openPubArchiveUI(opts) { const api = shModulePanel({ root: (opts && opts.root) || null, id: 'ocd-arch-panel', moduleId: 'pubarchive', title: '发布档案', icon: '📋', color: '#5b8cff', posKey: 'ocd_helper_arch_pos_v1', wKey: 'ocd_helper_arch_w_v1', width: 700, headHtml: ' ', barHtml: ' ' + ' ' + ' ' + ' ' + ' ' + ' ', }); let data = null; const prefs = () => { const p = store.get('ocd_helper_arch_ui_v1', {}) || {}; return { kw: String(p.kw || ''), state: String(p.state || ''), sort: String(p.sort || 'seeders-asc') }; }; const save = (patch) => store.set('ocd_helper_arch_ui_v1', Object.assign(prefs(), patch)); const list = () => { const p = prefs(); const kw = p.kw.trim().toLowerCase(); const arr = ((data && data.rows) || []).filter(r => { if (kw && String(r.title || '').toLowerCase().indexOf(kw) === -1) return false; if (p.state && r.state !== p.state) return false; return true; }); return shSeedSortList(arr, p.sort); }; const render = () => { const T = (data && data.stats) || {}; api.setCards( shCard('已记录', T.recorded || 0, 'var(--fg)', '本地发布历史条数') + shCard('在线', T.online || 0, '#3fd68f', '站点列表中且有人做种') + shCard('断种', T.dead || 0, (T.dead ? '#ff9f43' : 'var(--fg)'), '站点列表中但 0 做种') + shCard('已删除', T.gone || 0, (T.gone ? '#ff6b6b' : 'var(--fg)'), '经搜索确认站点已无此种子') + shCard('待核对', T.unknown || 0, 'var(--fg-muted)', '本地有记录但站点列表没有,且未能确认') + shCard('无本地记录', T.noRecord || 0, 'var(--fg-muted)', '站点有我发布的种子,但本地没有发布记录') + shCard('累计上传', shSeedFmtMB(T.upMB), 'var(--fg-muted)', '档案内所有种子累计上传合计') ); const arr = list(); if (!data) { api.setBody('
⏳ 正在核对发布档案…
'); return; } let html = ''; if (!arr.length) { html = '
' + (data.rows.length ? '当前筛选无结果' : '暂无记录:先在发布页发布一次,或点 ⟳ 重新核对') + '
'; } else { html = arr.map(r => '
' + '' + shPubStateChip(r.state) + '' + (r.id ? '' + esc(r.title) + '' : '' + esc(r.title) + '') + '' + (r.seeders === undefined ? '—' : (r.seeders + '/' + r.leechers)) + '' + '' + esc(r.upMB ? shSeedFmtMB(r.upMB) : '') + '' + '' + esc(r.size || '') + '' + '' + (r.ts ? new Date(r.ts).toLocaleDateString('zh-CN') : '') + '' + '
').join(''); } api.setBody(html); api.$('stat').textContent = '显示 ' + arr.length + ' / ' + ((data.rows || []).length) + ' 条' + (data.stats && data.stats.verified ? ' · 已校验 ' + data.stats.verified + ' 项' : ''); api.setMeta(data.ts ? new Date(data.ts).toLocaleTimeString('zh-CN', { hour12: false }) : ''); }; const load = async (force) => { api.setBody('
⏳ 正在核对(抓取我的发布列表 + 校验失效项)…
'); try { data = await shFetchPublishArchive(force); } catch (e) { api.setBody('
核对失败:' + esc(e.message || e) + '
'); return; } render(); }; const p0 = prefs(); api.$('kw').value = p0.kw; api.$('state').value = p0.state; api.$('sort').value = p0.sort; api.$('refresh').addEventListener('click', () => load(true)); let t1 = null; api.$('kw').addEventListener('input', () => { if (t1) clearTimeout(t1); t1 = setTimeout(() => { t1 = null; save({ kw: api.$('kw').value }); render(); }, 200); }); api.$('state').addEventListener('change', () => { save({ state: api.$('state').value }); render(); }); api.$('sort').addEventListener('change', () => { save({ sort: api.$('sort').value }); render(); }); api.$('export').addEventListener('click', () => { const arr = list(); if (!arr.length) { toastSafe('当前筛选无可导出内容', 'err'); return; } const head = ['状态', '标题', '种子ID', '做种', '下载', '累计上传MB', '体积', '记录时间', '链接']; const csv = [head].concat(arr.map(r => [ (shPubStateChip(r.state).match(/>([^<]+) row.map(v => '"' + String(v == null ? '' : v).replace(/"/g, '""') + '"').join(',')).join('\r\n'); dlFile('\ufeff' + csv, 'ocd_pubarchive_' + dateTag() + '.csv', 'text/csv'); toastSafe('📄 已导出 ' + arr.length + ' 条', 'ok'); }); api.$('retry').addEventListener('click', () => { const bad = ((data && data.rows) || []).filter(r => r.state === 'gone' || r.state === 'missing'); if (!bad.length) { toastSafe('没有需要重发/核对的失效项', 'ok'); return; } if (!confirm('处理 ' + bad.length + ' 个失效/待核对项?' + String.fromCharCode(10, 10) + '· 仍在批量队列里的任务会重置为「待发布」,可直接重新发布' + String.fromCharCode(10) + '· 其余项只复制标题(本地没有源目录,无法自动入队)')) return; let reset = 0; const leftover = []; try { const seen = {}; const tasks = []; const push = (x) => { if (!x || !x.id || seen[x.id]) return; seen[x.id] = 1; tasks.push(x); }; (getBatchTasks().tasks || []).forEach(push); const persisted = store.get(BATCH_KEY, null); if (persisted && Array.isArray(persisted.tasks)) persisted.tasks.forEach(push); for (const r of bad) { const k = normKey(r.title); const hitT = tasks.find(x => { const rk = normKey(String(x.raw || '')); return rk && (rk === k || rk.indexOf(k) !== -1 || k.indexOf(rk) !== -1); }); if (hitT) { updateTask(hitT.id, x => { x.state = 'parsed'; x.err = ''; }); reset++; } else { leftover.push(r.title); } } } catch (err) { toastSafe('重置失败:' + (err.message || err), 'err'); return; } if (leftover.length) { copyText(leftover.join(String.fromCharCode(10)), () => toastSafe('📋 已复制 ' + leftover.length + ' 个标题,粘贴到「批量发种」输入框即可', 'ok')); } toastSafe('🔁 队列内重置 ' + reset + ' 个 · 复制标题 ' + leftover.length + ' 个', 'ok'); }); load(false); return api; } /* ================================================================ * §55 通知中心 * ================================================================ */ const NOTIFY_KEY = 'ocd_helper_notify_v1'; /* 🧠 发布参数记忆:发布成功后按标题记住参数,重发时提示「上次用的参数」 */ const PUBPARAMS_KEY = 'ocd_helper_pubparams_v1'; function shPubParamKey(title) { return String(title || '').trim().toLowerCase().replace(/\s+/g, ' ').slice(0, 80); } function shPubParamsAll() { try { const m = store.get(PUBPARAMS_KEY, {}); return (m && typeof m === 'object') ? m : {}; } catch (e) { return {}; } } function shPubParamRemember(title, task) { const k = shPubParamKey(title); if (!k) return null; try { const m = shPubParamsAll(); const r = (task && task.result) || {}; const preset = (r.preset && r.preset.vals) ? r.preset.vals : null; const entry = { ts: Date.now(), title: String(title).slice(0, 120), cat: (preset && (preset['#browsecat'] || preset.browsecat)) || '', tags: (r.tags && Array.isArray(r.tags.list)) ? r.tags.list.slice(0, 12) : [], preset: preset ? Object.keys(preset).slice(0, 20).reduce((a, k2) => { a[k2] = preset[k2]; return a; }, {}) : {}, platform: (r.platform && r.platform.hit && r.platform.hit.platformName) || '', }; m[k] = entry; const keys = Object.keys(m); if (keys.length > 200) { keys.sort((a, b) => (m[a].ts || 0) - (m[b].ts || 0)).slice(0, keys.length - 200).forEach(k2 => { delete m[k2]; }); } store.set(PUBPARAMS_KEY, m); return entry; } catch (e) { return null; } } function shPubParamGet(title) { try { return shPubParamsAll()[shPubParamKey(title)] || null; } catch (e) { return null; } } function shPubParamLine(title) { const e2 = shPubParamGet(title); if (!e2) return ''; const bits = []; if (e2.cat) bits.push('分类 ' + e2.cat); if (e2.platform) bits.push('平台 ' + e2.platform); if (e2.tags && e2.tags.length) bits.push('标签 ' + e2.tags.slice(0, 4).join('/')); return '🧠 上次发布' + (bits.length ? (':' + bits.join(' · ')) : '过该种子'); } function shPubParamsClear() { try { store.set(PUBPARAMS_KEY, {}); return true; } catch (e) { return false; } } function shShowPubParamsTool() { const old = document.getElementById('ocd-pubparams-pop'); if (old) { old.remove(); return; } const m = shPubParamsAll(); const keys = Object.keys(m).sort((a, b) => (m[b].ts || 0) - (m[a].ts || 0)); const mask = document.createElement('div'); mask.id = 'ocd-pubparams-pop'; mask.className = shGetThemeCls(); mask.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:' + (OCD_Z.CONFIRM + 8) + ';display:flex;align-items:center;justify-content:center;'; const win = document.createElement('div'); win.style.cssText = 'width:min(720px,94vw);max-height:88vh;display:flex;flex-direction:column;background:var(--bg,#242632);color:var(--fg,#e8eaf0);border:1px solid #4a69bd;border-radius:12px;padding:14px 16px;font:13px/1.6 -apple-system,"Microsoft YaHei",sans-serif;box-shadow:0 20px 60px rgba(0,0,0,.55);'; win.innerHTML = '
' + ' 🧠 发布参数记忆' + ' 共 ' + keys.length + ' 条(最多 200)' + ' ' + ' ' + ' ✕' + '
' + '
发布成功的种子会按标题记住所用参数(分类 / 平台 / 标签);批量列表里重发同名种子时会提示「🧠 上次发布…」。
' + '
'; mask.appendChild(win); document.body.appendChild(mask); win.querySelector('#ocd-pp-body').innerHTML = keys.length ? ('' + keys.map((k) => { const e2 = m[k]; return ''; }).join('') + '
标题分类平台标签时间
' + esc(e2.title || k) + '' + esc(e2.cat || '—') + '' + esc(e2.platform || '—') + '' + esc((e2.tags || []).slice(0, 4).join('/') || '—') + '' + new Date(e2.ts || Date.now()).toLocaleString('zh-CN', { hour12: false }).slice(5, 16) + '
') : '
还没有记录 —— 成功发布一次后自动记录
'; win.querySelector('#ocd-pp-close').addEventListener('click', () => mask.remove()); win.querySelector('#ocd-pp-copy').addEventListener('click', () => { try { GM_setClipboard(JSON.stringify(m, null, 2)); toastSafe('已复制参数记忆 JSON', 'ok'); } catch (e) { toastSafe('复制失败', 'err'); } }); win.querySelector('#ocd-pp-clear').addEventListener('click', async () => { const okGo = await shConfirm({ title: '清空发布参数记忆?', html: '共 ' + keys.length + ' 条,清空后重发时不再提示上次参数。', buttons: [{ text: '取消', value: false }, { text: '清空', value: true, primary: true }] }); if (!okGo) return; shPubParamsClear(); mask.remove(); toastSafe('已清空发布参数记忆', 'ok'); }); return mask; } /* 🔔 通知单条已读 / 按类别折叠(v9.31,存在通知偏好里,跟 items 一起持久化) */ function shNotifyReadIds() { try { const p = store.get(NOTIFY_KEY, {}) || {}; return Array.isArray(p.readIds) ? p.readIds : []; } catch (e) { return []; } } function shNotifyMarkRead(id) { if (!id) return false; try { const p = store.get(NOTIFY_KEY, {}) || {}; const cur = Array.isArray(p.readIds) ? p.readIds.slice() : []; if (cur.indexOf(String(id)) === -1) cur.push(String(id)); p.readIds = cur.slice(-300); store.set(NOTIFY_KEY, p); return true; } catch (e) { return false; } } function shNotifyClearRead() { try { const p = store.get(NOTIFY_KEY, {}) || {}; p.readIds = []; store.set(NOTIFY_KEY, p); return true; } catch (e) { return false; } } function shNotifyCollapsed() { try { const p = store.get(NOTIFY_KEY, {}) || {}; return (p.collapsed && typeof p.collapsed === 'object') ? p.collapsed : {}; } catch (e) { return {}; } } function shNotifyToggleKind(kind, force) { try { const p = store.get(NOTIFY_KEY, {}) || {}; const c = (p.collapsed && typeof p.collapsed === 'object') ? Object.assign({}, p.collapsed) : {}; const next = (force === undefined) ? !c[kind] : !!force; if (next) c[kind] = 1; else delete c[kind]; p.collapsed = c; store.set(NOTIFY_KEY, p); return next; } catch (e) { return false; } } function shNotifyIsUnread(it, seenTs, readIds) { if (!it) return false; if (it.id && (readIds || []).indexOf(String(it.id)) !== -1) return false; return (it.ts || 0) > (seenTs || 0); } function shNotifyPrefs() { const p = store.get(NOTIFY_KEY, {}) || {}; const desktop = ['off', 'background', 'always'].indexOf(p.desktop) !== -1 ? p.desktop : 'background'; return { seenTs: Number(p.seenTs) || 0, items: Array.isArray(p.items) ? p.items : [], pmLast: Number(p.pmLast) || 0, reads: p.reads || {}, desktop: desktop }; } function shNotifySave(patch) { const p = Object.assign(shNotifyPrefs(), patch || {}); try { store.set(NOTIFY_KEY, p); } catch (e) {} return p; } function shPmUnreadFromDom() { const a = document.querySelector('.infos-bar a[href*="messages.php"], a[href*="messages.php"]'); const _banner = (function () { try { return Array.from(document.querySelectorAll('a[href*="messages.php"]')).find(x => /新短讯|新短信|你有\s*\d+\s*条/.test(String(x.textContent || ''))) || null; } catch (e) { return null; } })(); if (!a) return null; const t = String(a.textContent || '') + ' ' + String((a.parentElement && a.parentElement.textContent) || ''); /* V9.73.51:兼容站点真实文本 "8 (1 新)" ✓ 与横幅 "你有1条新短讯" ✓ */ const m = t.match(/站内信\s*[((\[]?\s*(\d+)/) || t.match(/[((]\s*(\d+)\s*(?:新|unread|未读)?\s*[))]/) || t.match(/你有\s*(\d+)\s*条新短讯/) || t.match(/(\d+)\s*新/); return m ? (parseInt(m[1], 10) || 0) : null; } async function shFetchPmList(limit) { const html = await gmFetchText('https://open.cd/messages.php?action=viewmailbox&box=1', { Referer: 'https://open.cd/' }); if (!html || html.length < 200) throw new Error('站内信页面过短'); const doc = shParserDocOf(html); const out = []; if (!doc) throw new Error('站内信页面解析失败'); doc.querySelectorAll('a[href*="messages.php?action=viewmessage&id="]').forEach(a => { const href = String(a.getAttribute('href') || ''); const idm = href.match(/id=(\d+)/); const row = a.closest('tr'); const unread = !!(row && (/|unread|未读/i.test(row.className || '') || (row.querySelector('b') && !row.querySelector('a[href*="viewmessage"]:not(:has(b))')))); out.push({ id: idm ? idm[1] : '', title: String(a.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 120), from: row ? String((row.children[1] || {}).textContent || '').replace(/\s+/g, ' ').trim().slice(0, 40) : '', ts: row ? String((row.children[3] || {}).textContent || '').trim() : '', unread: unread, url: href.indexOf('http') === 0 ? href : ('https://open.cd/' + href.replace(/^\/+/, '')), }); }); if (!out.length) throw new Error('未解析到站内信行(可能页面结构变化或未登录)'); return out.slice(0, Math.max(1, Number(limit) || 20)); } async function shFetchPublishArchiveSafe() { try { return await shFetchPublishArchive(false); } catch (e) { return null; } } async function shCollectNotifications(force) { const items = []; const now = Date.now(); const pmDom = shPmUnreadFromDom(); const prefs = shNotifyPrefs(); if (pmDom !== null && pmDom > prefs.pmLast) { items.push({ id: 'pm-dom-' + pmDom, kind: 'pm', ts: now, title: '站内信有 ' + pmDom + ' 封未读', detail: '页面顶部计数由 ' + prefs.pmLast + ' → ' + pmDom, url: 'https://open.cd/messages.php' }); } try { const pms = await shFetchPmList(15); const unread = pms.filter(x => x.unread); if (unread.length) { items.push({ id: 'pm-list-' + unread.length, kind: 'pm', ts: now, title: '未读站内信 ' + unread.length + ' 封', detail: unread.slice(0, 3).map(x => x.title).join(' / ').slice(0, 120), url: 'https://open.cd/messages.php?action=viewmailbox&box=1' }); } pms.slice(0, 5).forEach(x => { if (!x.unread) return; items.push({ id: 'pm-' + x.id, kind: 'pm', ts: now, title: '✉ ' + (x.title || ('站内信 #' + x.id)), detail: (x.from ? '来自 ' + x.from : '') + (x.ts ? ' · ' + x.ts : ''), url: x.url }); }); } catch (e) { items.push({ id: 'pm-error', kind: 'system', ts: now, title: '站内信未取到', detail: (e && e.message) || String(e), url: 'https://open.cd/messages.php', warn: true }); } const nAdv = advGroup('notify') || {}; const hrMin = Math.max(1, Number(nAdv.hrMin) || 1); const deadMin = Math.max(1, Number(nAdv.deadMin) || 1); const pmKw = String(nAdv.pmKeyword || '').trim(); const muted = (nAdv.mute && typeof nAdv.mute === 'object') ? nAdv.mute : {}; const sb = store.get(SEEDBOARD_KEY, null); if (sb && sb.totals) { if (sb.totals.hr >= hrMin) items.push({ id: 'sb-hr', kind: 'hr', ts: sb.ts || now, title: 'H&R 风险 ' + sb.totals.hr + ' 项', detail: '建议尽快补种,避免被警告', url: 'https://open.cd/userdetails.php?id=' + (store.get('ocd_helper_dp_uid_v1', '') || '') }); if (sb.totals.dead >= deadMin) items.push({ id: 'sb-dead', kind: 'seed', ts: sb.ts || now, title: '断种 ' + sb.totals.dead + ' 颗(已加载范围)', detail: '可打开做种看板助手 → 仅断种,逐个补种', url: '' }); } try { const { tasks } = getBatchTasks(); const failed = (tasks || []).filter(t => t.state === 'failed'); if (failed.length) items.push({ id: 'batch-failed', kind: 'batch', ts: now, title: '批量任务失败 ' + failed.length + ' 个', detail: failed.slice(0, 3).map(t => String(t.raw || '').slice(0, 30) + ':' + String(t.err || '').slice(0, 30)).join(' / '), url: '' }); const verifying = (tasks || []).filter(t => t.state === 'verifying'); if (verifying.length) items.push({ id: 'batch-verifying', kind: 'batch', ts: now, title: '批量任务待核对 ' + verifying.length + ' 个', detail: '发布结果未确认,可在批量面板重试核对', url: '' }); } catch (e) {} const follows = shGetFollows(); if (follows.length) { const recently = follows.filter(f => f && f.ts && (now - f.ts) < 3 * 86400 * 1000).length; items.push({ id: 'follows', kind: 'req', ts: now, title: '关注中的求种 ' + follows.length + ' 条', detail: recently ? ('近 3 天新增/更新 ' + recently + ' 条') : '可在求种帖预览里查看应求情况', url: 'https://open.cd/viewrequests.php' }); } const sub = store.get('ocd_helper_subs_v1', null); if (sub && Array.isArray(sub.list)) { const hits = sub.list.reduce((a, s) => a + ((s.hits || []).length), 0); if (hits) items.push({ id: 'subs', kind: 'sub', ts: now, title: '智能订阅命中 ' + hits + ' 条', detail: '打开「智能订阅」查看新命中', url: '' }); } const _filtered = Object.keys(muted).length ? items.filter(it => !muted[it.kind]) : items.slice(); items.length = 0; _filtered.forEach(it => items.push(it)); if (pmKw) { for (let i = items.length - 1; i >= 0; i--) { if (items[i].kind === 'pm' && String(items[i].title || '').indexOf(pmKw) === -1) items.splice(i, 1); } } items.sort((a, b) => (b.ts || 0) - (a.ts || 0)); try { const prevIds = {}; (prefs.items || []).forEach(it => { if (it && it.id) prevIds[it.id] = 1; }); const PRIO = ['hr', 'seed', 'sub', 'batch']; const fresh = items.filter(it => it && !prevIds[it.id] && PRIO.indexOf(it.kind) !== -1); if (fresh.length) { if (!advInQuietHours()) ocdDesktopNotify('OpenCD 工具箱 · ' + fresh.length + ' 条新提醒', fresh.slice(0, 3).map(x => x.title).join(' / '), fresh[0].url || ''); advWebhook({ kind: 'notify', count: fresh.length, items: fresh.slice(0, 10).map(x => ({ kind: x.kind, title: x.title, detail: x.detail, url: x.url })) }); } } catch (e) {} const _retain = Math.max(0, Number(nAdv.retain) || 0); const saved = shNotifySave({ items: _retain > 0 ? items.slice(0, _retain) : items, lastScan: now }); return { items: items, seenTs: saved.seenTs, prefs: saved }; } function shNotifyUnreadCount() { const p = shNotifyPrefs(); return (p.items || []).filter(it => (it.ts || 0) > (p.seenTs || 0)).length; } function shNotifyStatus() { const n = shNotifyUnreadCount(); return n > 0 ? ('未读 ' + n) : ''; } function openNotifyCenterUI() { const api = shModulePanel({ id: 'ocd-notify-panel', moduleId: 'notify', title: '通知中心', icon: '🔔', color: '#f59e0b', posKey: 'ocd_helper_notify_pos_v1', wKey: 'ocd_helper_notify_w_v1', width: 620, headHtml: ' ' + ' ' + ' ' + ' ', barHtml: ' ' + ' ' + ' ' + ' ', }); const KIND = { pm: { cn: '站内信', c: '#00c6ff' }, hr: { cn: 'H&R', c: '#ff6b6b' }, seed: { cn: '做种', c: '#3fd68f' }, batch: { cn: '批量', c: '#c58bff' }, req: { cn: '求种', c: '#f59e0b' }, sub: { cn: '订阅', c: '#a259ff' }, system: { cn: '系统', c: '#9aa3b2' }, }; const render = () => { const p = shNotifyPrefs(); const items = p.items || []; const unread = items.filter(it => (it.ts || 0) > (p.seenTs || 0)).length; api.setCards( shCard('未读', unread, unread ? '#f59e0b' : 'var(--fg)', '上次「全部已读」之后新增的通知') + shCard('通知总数', items.length, 'var(--fg-muted)', '最多保留 ' + (Number((advGroup('notify') || {}).retain) || 60) + ' 条(高级设置可改,0 = 不限)') + shCard('站内信未读', shPmUnreadFromDom() === null ? '—' : shPmUnreadFromDom(), '#00c6ff', '从页面顶部计数读取') ); if (!items.length) { api.setBody('
暂无通知,点 ⟳ 扫描一次
'); } else { const _readIds = shNotifyReadIds(); const _collapsed = shNotifyCollapsed(); const _order = Object.keys(KIND); const _groups = {}; items.forEach((it) => { const kk = KIND[it.kind] ? it.kind : 'system'; (_groups[kk] = _groups[kk] || []).push(it); }); const _kinds = _order.filter(k2 => _groups[k2] && _groups[k2].length) .concat(Object.keys(_groups).filter(k2 => _order.indexOf(k2) === -1)); api.setBody(_kinds.map((kk) => { const k = KIND[kk] || KIND.system; const list = _groups[kk] || []; const un2 = list.filter(it => shNotifyIsUnread(it, p.seenTs, _readIds)).length; const folded = !!_collapsed[kk]; const head = '
' + '' + (folded ? '▸' : '▾') + '' + '' + k.cn + '' + '' + list.length + ' 条' + (un2 ? (' · ' + un2 + ' 未读') : '') + (folded ? '(已折叠)' : '') + '' + '
'; if (folded) return head; return head + list.map((it) => { const isNew = shNotifyIsUnread(it, p.seenTs, _readIds); const rid = it.id || ('t' + (it.ts || 0) + '_' + String(it.title || '').slice(0, 12)); return '
' + '' + (isNew ? '● ' : '') + esc(it.title) + '' + (it.detail ? '
' + esc(it.detail) + '
' : '') + '
' + '' + new Date(it.ts || Date.now()).toLocaleTimeString('zh-CN', { hour12: false }).slice(0, 5) + '' + (isNew ? ('') : '') + '
'; }).join(''); }).join('')); const _mode = (advGroup('notify') || {}).clickMode || 'open'; api.$('body').querySelectorAll('.ocd-notify-row').forEach(el => el.addEventListener('click', () => { const u = el.dataset.url; if (!u) return; if (_mode === 'copy') { try { GM_setClipboard(u); toastSafe('已复制链接:' + u.slice(0, 60), 'ok'); } catch (e) { toastSafe('复制失败', 'err'); } } else if (_mode === 'jump') { location.href = u; } else { window.open(u, '_blank'); } })); } api.$('stat').textContent = unread ? ('有 ' + unread + ' 条未读') : '全部已读'; /* 分组折叠 + 单条已读(v9.31) */ api.$('body').querySelectorAll('.ocd-nt-head').forEach((el) => el.addEventListener('click', () => { shNotifyToggleKind(el.dataset.kind); render(); })); api.$('body').querySelectorAll('.ocd-nt-read').forEach((el) => el.addEventListener('click', (e) => { e.stopPropagation(); shNotifyMarkRead(el.dataset.rid); render(); })); const foldBtn = document.getElementById('ocd-notify-panel-fold'); if (foldBtn && !foldBtn.__bound) { foldBtn.__bound = true; foldBtn.addEventListener('click', () => { const p2 = shNotifyPrefs(); const items2 = p2.items || []; const kinds = Array.from(new Set(items2.map(x => x.kind || 'system'))); const collapsed = shNotifyCollapsed(); const anyOpen = kinds.some(k => !collapsed[k]); kinds.forEach(k => shNotifyToggleKind(k, anyOpen)); foldBtn.textContent = anyOpen ? '⇲ 展开全部' : '⇱ 折叠全部'; render(); }); } }; const scan = async (silent) => { api.setBody('
⏳ 正在扫描通知来源…
'); try { await shCollectNotifications(true); } catch (e) { if (!silent) toastSafe('扫描失败:' + (e.message || e), 'err'); } render(); }; api.$('refresh').addEventListener('click', () => scan(false)); try { api.$('rss').addEventListener('click', () => shOpenNotifyRss()); } catch (e) {} try { api.$('click').addEventListener('change', (e) => { advSave({ notify: { clickMode: String(e.target.value || 'open') } }); toastSafe('点击通知行为已设为:' + e.target.value, 'ok'); }); } catch (e) {} api.$('read').addEventListener('click', () => { shNotifySave({ seenTs: Date.now() }); try { shNotifyClearRead(); } catch (e) {} render(); toastSafe('✔ 已全部标记为已读', 'ok'); }); api.$('clear').addEventListener('click', async () => { const p0 = shNotifyPrefs(); const backup = { items: (p0.items || []).slice(), seenTs: p0.seenTs }; if (!(p0.items || []).length) { toastSafe('当前没有通知', 'info'); return; } if (!(await shConfirm({ title: '清空全部通知?', html: '共 ' + (p0.items || []).length + ' 条,清空后可点提示里的「撤销」恢复。', buttons: [{ text: '取消', value: false }, { text: '清空', value: true, primary: true }] }))) return; shNotifySave({ items: [], seenTs: Date.now() }); render(); ocdUndoPush('清空通知', () => { shNotifySave(backup); }); toastAction('🧹 通知已清空(' + backup.items.length + ' 条)', '撤销', () => { shNotifySave(backup); render(); toastSafe('已恢复通知', 'ok'); }); }); render(); scan(true); return api; } /* ================================================================ * §56 趋势看板 * ================================================================ */ const TREND_KEY = 'ocd_helper_trend_v1'; const TREND_METRICS = [ { k: 'ratio', cn: '分享率', c: '#3fd68f', f: (s) => Number(s.ratio) || 0, dec: 2 }, { k: 'magic', cn: '魔力值', c: '#c58bff', f: (s) => Number(s.magic) || 0, dec: 0 }, { k: 'uploadedGB', cn: '上传 GB', c: '#5b8cff', f: (s) => (Number(s.uploadedMB) || 0) / 1024, dec: 2 }, { k: 'downloadedGB', cn: '下载 GB', c: '#f59e0b', f: (s) => (Number(s.downloadedMB) || 0) / 1024, dec: 2 }, { k: 'seeding', cn: '做种数', c: '#00c6ff', f: (s) => Number(s.seeding) || 0, dec: 0 }, { k: 'published', cn: '发布种子', c: '#7dd8ff', f: (s) => Number(s.published) || 0, dec: 0 }, { k: 'hr', cn: 'H&R', c: '#ff6b6b', f: (s) => Number(s.hr) || 0, dec: 0 }, ]; function shTrendPrefs() { const p = store.get(TREND_KEY, {}) || {}; const sel = Array.isArray(p.sel) && p.sel.length ? p.sel : ['ratio', 'uploadedGB', 'seeding']; return { sel: sel, range: Number(p.range) || 30 }; } function shTrendSave(patch) { const p = Object.assign(shTrendPrefs(), patch || {}); store.set(TREND_KEY, p); return p; } function shBuildTrendSvg(snaps, opts) { const o = opts || {}; const list = (snaps || []).slice().sort((a, b) => a.ts - b.ts); const _cc = (advGroup('chart') || {}).colors || {}; const sel = (o.sel || []).map(k => TREND_METRICS.find(m => m.k === k)).filter(Boolean).map((m) => { const c = String(_cc[m.k] || ''); return /^#[0-9a-fA-F]{6}$/.test(c) ? Object.assign({}, m, { c: c }) : Object.assign({}, m); }); const W = o.w || 640, H = o.h || 260, padL = 42, padR = 54, padT = 12, padB = 22; const iw = W - padL - padR, ih = H - padT - padB; if (list.length < 2 || !sel.length) { return '
' + (list.length < 2 ? ('快照不足(需 ≥2 条,当前 ' + list.length + ' 条)') : '请至少勾选一个指标') + '
'; } const xOf = (i) => padL + iw * i / (list.length - 1); const axis = []; for (let g = 0; g <= 4; g++) { const y = padT + ih * g / 4; axis.push(''); } let svg = '' + axis.join(''); svg += ''; const legend = []; sel.forEach((m, si) => { const vals = list.map(x => m.f(x)); const mn = Math.min.apply(null, vals), mx = Math.max.apply(null, vals); const span = (mx - mn) || 1; const yOf = (v) => padT + ih * (1 - (v - mn) / span); const d = vals.map((v, i) => (i ? 'L' : 'M') + xOf(i).toFixed(1) + ',' + yOf(v).toFixed(1)).join(' '); svg += ''; if (list.length <= 60) vals.forEach((v, i) => { svg += ''; }); svg += '' + esc(m.cn) + ''; svg += '' + esc(mn.toFixed(m.dec) + '~' + mx.toFixed(m.dec)) + ''; legend.push(m.cn + ' ' + vals[vals.length - 1].toFixed(m.dec)); }); svg += '' + new Date(list[0].ts).toLocaleDateString('zh-CN') + ''; svg += '' + new Date(list[list.length - 1].ts).toLocaleDateString('zh-CN') + ''; svg += ''; svg += ''; return svg + '
' + esc(legend.join(' · ')) + '
'; } function openTrendUI(opts) { const api = shModulePanel({ root: (opts && opts.root) || null, id: 'ocd-trend-panel', moduleId: 'trend', title: '趋势看板', icon: '📈', color: '#c58bff', posKey: 'ocd_helper_trend_pos_v1', wKey: 'ocd_helper_trend_w_v1', width: 760, headHtml: ' ' + ' ', barHtml: ' ' + ' ' + ' ' + ' ' + ' ', }); const renderMetrics = () => { const p = shTrendPrefs(); api.$('metrics').innerHTML = TREND_METRICS.map(m => { const on = p.sel.indexOf(m.k) !== -1; return ''; }).join(''); api.$('metrics').querySelectorAll('.ocd-trend-m').forEach(cb => cb.addEventListener('change', () => { const cur = shTrendPrefs().sel.slice(); const i = cur.indexOf(cb.dataset.k); if (cb.checked && i === -1) cur.push(cb.dataset.k); if (!cb.checked && i !== -1) cur.splice(i, 1); shTrendSave({ sel: cur }); renderMetrics(); draw(); })); }; const draw = async () => { const p = shTrendPrefs(); let snaps = []; let err = ''; try { snaps = await ocdDB.getAll('snapshots'); } catch (e) { err = (e && e.message) || String(e); } if (p.range > 0) { const cut = Date.now() - p.range * 86400 * 1000; snaps = snaps.filter(s => (s.ts || 0) >= cut); } snaps = snaps.slice().sort((a, b) => a.ts - b.ts).slice(-240); if (err) { api.setBody('
快照库读取失败:' + esc(err) + '
趋势图依赖「数据统计面板」的快照库(IndexedDB)。可先打开发布页/数据面板采集几条快照。
'); api.$('stat').textContent = '快照 0 条'; return; } api.setBody(shBuildTrendSvg(snaps, { sel: p.sel, w: 700, h: 280 })); const rows = snaps.slice(-12).reverse(); api.$('body').innerHTML += '
最近快照
' + (rows.length ? rows.map(s => '
' + '' + new Date(s.ts).toLocaleString('zh-CN', { hour12: false }).slice(0, 16) + '' + TREND_METRICS.map(m => '' + m.f(s).toFixed(m.dec) + '').join('') + '' + '
').join('') : '
暂无快照
'); api.$('body').querySelectorAll('.ocd-trend-del').forEach(b => b.addEventListener('click', async () => { try { await ocdDB.del('snapshots', Number(b.dataset.ts)); toastSafe('已删除该快照', 'ok'); } catch (e) { toastSafe('删除失败:' + (e.message || e), 'err'); } draw(); })); const hit = api.$('body').querySelector('#ocd-trend-panel-hit'); if (hit && snaps.length > 1) { hit.addEventListener('mousemove', (e) => { const r = hit.ownerSVGElement.getBoundingClientRect(); const ratio = (e.clientX - r.left) / r.width; const idx = Math.max(0, Math.min(snaps.length - 1, Math.round(ratio * (snaps.length - 1)))); const s = snaps[idx]; api.setMeta(new Date(s.ts).toLocaleString('zh-CN', { hour12: false }).slice(5, 16) + ' · ' + p.sel.map(k => { const m = TREND_METRICS.find(x => x.k === k); return m ? (m.cn + ' ' + m.f(s).toFixed(m.dec)) : ''; }).filter(Boolean).join(' · ')); }); } api.$('stat').textContent = '快照 ' + snaps.length + ' 条' + (p.range ? '(近 ' + p.range + ' 天)' : ''); }; api.$('refresh').addEventListener('click', draw); api.$('range').value = String(shTrendPrefs().range); api.$('range').addEventListener('change', () => { shTrendSave({ range: Number(api.$('range').value) || 0 }); draw(); }); api.$('take').addEventListener('click', async () => { api.$('take').disabled = true; try { if (typeof dpCollectSnapshot === 'function') await dpCollectSnapshot(true); else throw new Error('快照采集不可用'); toastSafe('⏺ 已采集一条快照', 'ok'); draw(); } catch (e) { toastSafe('采集失败:' + (e.message || e), 'err'); } api.$('take').disabled = false; }); api.$('clear').addEventListener('click', async () => { if (!confirm('删除 180 天前的快照?')) return; try { const all = await ocdDB.getAll('snapshots'); const cut = Date.now() - 180 * 86400 * 1000; const del = all.filter(s => (s.ts || 0) < cut); for (const s of del) await ocdDB.del('snapshots', s.ts); toastSafe('🧹 已删除 ' + del.length + ' 条旧快照', 'ok'); draw(); } catch (e) { toastSafe('清理失败:' + (e.message || e), 'err'); } }); api.setCards(''); renderMetrics(); draw(); return api; } /* ================================================================ * §57 求种协作增强 * ================================================================ */ function shReqQuoteText(p) { const body = String((p && p.intro) || '').replace(/\s+/g, ' ').trim(); const excerpt = body.slice(0, 300); return '[quote]' + excerpt + '[/quote]\n\n'; } async function shReqTripleAction(ctx, reqid, p) { const out = []; const respBtn = document.getElementById('ocd-rpv-respond'); if (respBtn && !respBtn.disabled) { respBtn.click(); out.push('已触发应求'); try { shLedgerRecordIncome({ id: reqid, title: (p && p.title) || ('#' + reqid), bonus: Number(p && p.bonus) || 0 }); out.push('已记账'); } catch (e) {} await new Promise(r => setTimeout(r, 800)); } else { out.push('应求按钮不可用(可能是自己的帖子或已应求)'); } const ta = document.getElementById('ocd-rpv-cm-text'); if (ta) { ta.value = shReqQuoteText(p) + ta.value; ta.focus(); out.push('已填入引用回复'); } try { const arr = shGetFollows(); const id = String(reqid); if (!arr.some(x => String(x && x.id) === id)) { arr.unshift({ id: id, title: (p && p.title) || ('#' + id), ts: Date.now(), bonus: (p && p.bonus) || '' }); shSaveFollows(arr); out.push('已加入关注列表'); } } catch (e) {} toastSafe('⚡ ' + out.join(';'), 'ok'); return out; } /* ================================================================ * §58 目录命名校验 * ================================================================ */ const NAMECHK_KEY = 'ocd_helper_namechk_v1'; const FORMAT_TAGS = ['FLAC', 'APE', 'WAV', 'ALAC', 'MP3', 'AAC', 'M4A', 'DSD', 'DFF', 'DSF', '24BIT', '24-96', '24-192', 'HI-RES', 'HIRES', 'VINYL', 'WEB', 'SACD', 'HDCD', 'MQA', 'TAPE', 'CD']; function shNamePrefs() { const p = store.get(NAMECHK_KEY, {}) || {}; return { year: p.year !== false, sep: p.sep !== false, brackets: p.brackets !== false, fmtTag: p.fmtTag === true, maxLen: Math.max(40, Math.min(400, Number(p.maxLen) || 180)), }; } function shNamePrefsSave(patch) { const p = Object.assign(shNamePrefs(), patch || {}); store.set(NAMECHK_KEY, p); return p; } /* 关键词模板:按用户配置生成检索变体(订阅扫描 / 搜索时可选启用) */ const TRAD_MAP = { '周': '週', '深': '深', '发': '發', '专': '專', '辑': '輯', '声': '聲', '听': '聽', '乐': '樂', '团': '團', '国': '國', '爱': '愛', '你': '你', '与': '與', '时': '時', '间': '間', '长': '長', '风': '風', '云': '雲', '光': '光', '梦': '夢', '记': '記', '岁': '歲', '月': '月', '无': '無', '为': '為', '来': '來', '过': '過', '开': '開', '会': '會', '还': '還', '这': '這', '那': '那' }; function advKwVariants(kw) { const raw = String(kw || '').trim(); const r = advGroup('kwRules') || {}; if (!raw) return []; const out = []; const add = (v) => { const x = String(v || '').replace(/\s{2,}/g, ' ').trim(); if (x && x !== raw && out.indexOf(x) === -1) out.push(x); }; if (r.stripParen !== false) { add(raw.replace(/[((【\[][^))】\]]{1,40}[))】\]]/g, ' ')); add(raw.replace(/\s*[-–—]\s*(FLAC|APE|WAV|MP3|24BIT|24-?96|Hi-?Res|WEB|CD|Vinyl|DSD|MQA)[\s\S]*$/i, '')); } if (r.splitArtist !== false && /[&&、,,]|\sfeat\.?\s|\swith\s/i.test(raw)) { raw.split(/[&&、,,]|\sfeat\.?\s|\swith\s/i).map(x => x.trim()).filter(Boolean).forEach(add); const m = raw.match(/^(.+?)\s*[-–—]\s*(.+)$/); if (m) add(m[1].trim()); } if (r.tradMap !== false) { let conv = ''; for (const ch of raw) conv += (TRAD_MAP[ch] || ch); if (conv !== raw) add(conv); } const max = Math.max(0, Math.min(8, Number(r.maxVariants) || 4)); return out.slice(0, max); } /* V9.76.17(用户 m13487 ②):「🎚 颜色微调」面板整体下线 —— 函数已删除,句柄 __ocd.tweakTool 同步移除;历史键 ocd_helper_themetweak_v1 不再读写 ✓ */ function shShowFabSlotsTool() { const list = shFabSlotList(); const html = '
悬浮球样式可保存为 A/B/C 三套方案(形状 / 动画 / 悬停 / 粒子 / 扫光 / 徽标等全部外观项),一键切换。
' + '' + '' + list.map(x => '' + '' + '' + '').join('') + '
方案状态操作
' + x.slot + '' + (x.has ? ('已保存 ' + (x.ts ? new Date(x.ts).toLocaleString('zh-CN').slice(5, 16) : '')) : '空') + '' + ' ' + '' + '
' + '
' + ' ' + ' ' + ' ' + '
'; const win = shUtilityModal('🧿 悬浮球方案(A/B/C)', html); setTimeout(() => { const box = document.getElementById('ocd-util-pop'); if (!box) return; box.querySelectorAll('.fab-save').forEach(b => b.addEventListener('click', () => { shFabSlotSave(b.dataset.s); box.remove(); shShowFabSlotsTool(); })); box.querySelectorAll('.fab-apply').forEach(b => b.addEventListener('click', () => { shFabSlotApply(b.dataset.s); })); const ex = box.querySelector('#fab-export'); if (ex) ex.addEventListener('click', () => shExportFabPack()); const im = box.querySelector('#fab-import'); if (im) im.addEventListener('click', () => shImportFabPack()); const pr = box.querySelector('#fab-presets'); if (pr) pr.addEventListener('click', () => { box.remove(); const pb = document.getElementById('ocd-fabdiy-preset-btn'); if (pb) pb.click(); else toastSafe('请在设置中心 → 悬浮球 DIY 里查看预设', 'info'); }); }, 0); return win; } /* 主题包 / 悬浮球方案 / 命令面板 的配置化工具(3.5 / 3.6 / 3.10) */ function shThemePackGet() { return { _app: 'OpenCD 工具箱', _kind: 'theme', _version: VERSION, theme: store.get(THEME_KEY, 'dark') }; } function shThemePackApply(pack) { try { const th = String((pack && pack.theme) || ''); if (THEMES.indexOf(th) !== -1) applyTheme(th); /* V9.76.17(用户 m13487 ②):颜色微调已下线 —— 老主题包里带的 tweak 字段一并忽略 ✓ */ toastSafe('🎨 已应用主题包:' + (THEME_NAMES[th] || th), 'ok'); return true; } catch (e) { toastSafe('主题包应用失败:' + ((e && e.message) || e), 'err'); return false; } } function shThemeSlotSave(slot) { const packs = store.get('ocd_helper_theme_slots_v1', {}) || {}; packs[slot] = shThemePackGet(); store.set('ocd_helper_theme_slots_v1', packs); toastSafe('已保存主题方案 ' + slot, 'ok'); return packs[slot]; } function shThemeSlotList() { const packs = store.get('ocd_helper_theme_slots_v1', {}) || {}; return ['A', 'B', 'C'].map(k => ({ slot: k, pack: packs[k] || null })); } function shExportThemePack() { const p = shThemePackGet(); dlFile(JSON.stringify(p, null, 1), 'ocd_theme_' + dateTag() + '.json', 'application/json'); toastSafe('🎨 已导出主题包', 'ok'); return p; } function shImportThemePack() { const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.json,application/json'; inp.addEventListener('change', async () => { const f = inp.files && inp.files[0]; if (!f) return; try { const pack = JSON.parse(await f.text()); const okGo = await shConfirm({ title: '应用该主题包?', html: '主题:' + esc(String(pack.theme || '?')) + '
将覆盖当前主题与颜色微调', buttons: [{ text: '取消', value: false }, { text: '应用', value: true, primary: true }] }); if (okGo) shThemePackApply(pack); } catch (e) { toastSafe('主题包解析失败', 'err'); } }); inp.click(); } /* 悬浮球 DIY 多套方案(A/B/C) */ function shFabSlotSave(slot, cur) { const packs = store.get('ocd_helper_fabdiy_slots_v1', {}) || {}; packs[slot] = Object.assign({ _ts: Date.now() }, cur || (store.get(FABDIY_KEY, {}) || {})); store.set('ocd_helper_fabdiy_slots_v1', packs); toastSafe('已把当前悬浮球样式保存为方案 ' + slot, 'ok'); return packs[slot]; } function shFabSlotApply(slot) { const packs = store.get('ocd_helper_fabdiy_slots_v1', {}) || {}; const p = packs[slot]; if (!p) { toastSafe('方案 ' + slot + ' 还是空的', 'warn'); return false; } store.set(FABDIY_KEY, p); try { if (typeof shApplyFabDiy === 'function') shApplyFabDiy(); else if (typeof applyFabDiy === 'function') applyFabDiy(); } catch (e) {} toastSafe('🧿 已切换到悬浮球方案 ' + slot, 'ok'); return true; } function shFabSlotList() { const packs = store.get('ocd_helper_fabdiy_slots_v1', {}) || {}; return ['A', 'B', 'C'].map(k => ({ slot: k, has: !!packs[k], ts: (packs[k] && packs[k]._ts) || 0 })); } function shExportFabPack() { const packs = { _app: 'OpenCD 工具箱', _kind: 'fabdiy', _version: VERSION, current: store.get(FABDIY_KEY, {}) || {}, slots: store.get('ocd_helper_fabdiy_slots_v1', {}) || {} }; dlFile(JSON.stringify(packs, null, 1), 'ocd_fabdiy_' + dateTag() + '.json', 'application/json'); toastSafe('🧿 已导出悬浮球样式', 'ok'); return packs; } function shImportFabPack() { const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.json,application/json'; inp.addEventListener('change', async () => { const f = inp.files && inp.files[0]; if (!f) return; try { const d = JSON.parse(await f.text()); const cur = d.current || d; if (!cur || typeof cur !== 'object') { toastSafe('文件格式不正确', 'err'); return; } store.set(FABDIY_KEY, cur); if (d.slots && typeof d.slots === 'object') store.set('ocd_helper_fabdiy_slots_v1', d.slots); try { if (typeof shApplyFabDiy === 'function') shApplyFabDiy(); else if (typeof applyFabDiy === 'function') applyFabDiy(); } catch (e) {} toastSafe('🧿 已导入悬浮球样式(含 ' + Object.keys(d.slots || {}).length + ' 个方案)', 'ok'); } catch (e) { toastSafe('悬浮球样式解析失败', 'err'); } }); inp.click(); } /* 命令面板:最近使用 / 别名 / 自定义快捷键(3.10) */ function advCmdPrefs() { const a = advPrefs(); const c = a.cmd || {}; return { recent: Array.isArray(c.recent) ? c.recent.slice(0, 12) : [], aliases: (c.aliases && typeof c.aliases === 'object') ? c.aliases : {}, hotkeys: (c.hotkeys && typeof c.hotkeys === 'object') ? c.hotkeys : {}, }; } function advCmdSave(patch) { const cur = advCmdPrefs(); return advSave({ cmd: Object.assign({}, cur, patch || {}) }); } function advRecordCmd(id) { if (!id) return; try { const c = advCmdPrefs(); const list = [id].concat(c.recent.filter(x => x !== id)).slice(0, 12); advCmdSave({ recent: list }); } catch (e) {} } function shRunCommandById(id) { try { const cmds = shBuildCommands(); const c = cmds.find(x => x.id === id); if (!c) { toastSafe('未找到命令:' + id, 'err'); return false; } advRecordCmd(c.id); c.run(); return true; } catch (e) { toastSafe('执行失败:' + ((e && e.message) || e), 'err'); return false; } } function shResolveAlias(text) { const t = String(text || '').trim().toLowerCase(); if (!t) return null; const al = advCmdPrefs().aliases; const id = al[t]; if (!id) return null; const cmds = shBuildCommands(); const c = cmds.find(x => x.id === id); return c ? { label: '🔖 别名「' + t + '」→ ' + c.cn, run: () => { advRecordCmd(c.id); c.run(); } } : null; } function shValidateFolderName(raw, prefs) { const p = prefs || shNamePrefs(); const s = String(raw || '').trim(); if (!s) return { ok: false, issues: ['名称为空'], warns: [], suggestion: '', raw: '' }; const issues = [], warns = []; if (/[\\/:*?"<>|]/.test(s)) issues.push('含非法字符(\\ / : * ? " < > |)'); if (/[. ]$/.test(s)) issues.push('结尾有空格或点号'); if (/[\x00-\x1f]/.test(s)) issues.push('含控制字符'); if (s.length > p.maxLen) issues.push('长度 ' + s.length + ' 超过上限 ' + p.maxLen); if (p.brackets) { [['[', ']'], ['(', ')'], ['【', '】'], ['(', ')']].forEach((pair) => { const n1 = s.split(pair[0]).length - 1; const n2 = s.split(pair[1]).length - 1; if (n1 !== n2) issues.push('括号不配对:' + pair[0] + '×' + n1 + ' / ' + pair[1] + '×' + n2); }); } if (p.year && !/(^|[^0-9])(19|20)\d{2}([^0-9]|$)/.test(s)) warns.push('未包含 4 位年份'); if (p.sep && !/\s-\s/.test(s)) warns.push('未用「艺人 - 专辑」的 “ - ” 分隔'); if (p.fmtTag && !FORMAT_TAGS.some(t => s.toUpperCase().indexOf(t) !== -1)) warns.push('未包含格式标签(FLAC / 24BIT 等)'); const nr = advGroup('nameRules') || {}; const mustInc = String(nr.mustInclude || '').split(/[,,\s]+/).filter(Boolean); mustInc.forEach((kw) => { if (s.indexOf(kw) === -1) issues.push('缺少必含关键词:' + kw); }); String(nr.forbid || '').split(/[,,\s]+/).filter(Boolean).forEach((kw) => { if (s.indexOf(kw) !== -1) issues.push('含禁止关键词:' + kw); }); if (nr.allowRe) { try { if (!new RegExp(nr.allowRe).test(s)) issues.push('不匹配允许正则:' + nr.allowRe); } catch (e) { warns.push('允许正则无效:' + nr.allowRe); } } if (nr.forbidRe) { try { if (new RegExp(nr.forbidRe).test(s)) issues.push('命中禁止正则:' + nr.forbidRe); } catch (e) { warns.push('禁止正则无效:' + nr.forbidRe); } } const suggestion = s .replace(/[\\/:*?"<>|]/g, '_') .replace(/[\x00-\x1f]/g, '') .replace(/\s{2,}/g, ' ') .replace(/[. ]+$/g, ''); return { ok: issues.length === 0, issues: issues, warns: warns, suggestion: suggestion, raw: s }; } function shNameCheckRows(lines) { return String(lines || '').split('\n') .map(x => x.trim()) .filter(Boolean) .map(x => Object.assign({ input: x }, shValidateFolderName(x))); } function shShowNameCheckTool() { const prefs = shNamePrefs(); const render = () => { const rows = shNameCheckRows(store.get('ocd_helper_namechk_last_v1', '')); const bad = rows.filter(r => !r.ok).length; const warn = rows.filter(r => r.ok && r.warns.length).length; return '
' + '' + '' + '' + '' + '' + '' + '
' + '' + '
' + ' ' + ' ' + (rows.length ? (rows.length + ' 条 · 不合格 ' + bad + ' · 有建议 ' + warn) : '尚未校验') + '' + '
' + (rows.length ? '' + '' + rows.map(r => '' + '' + '' + '').join('') + '
状态原始名称问题 / 建议
' + (r.ok ? (r.warns.length ? '⚠ 建议' : '✓ 合格') : '✗ 不合格') + '' + esc(r.input) + '' + (r.issues.concat(r.warns).map(x => esc(x)).join(';') || '—') + (r.suggestion && r.suggestion !== r.input ? '
建议:' + esc(r.suggestion) + '' : '') + '
' : ''); }; const win = shUtilityModal('📁 目录命名规范校验', render()); const bind = () => { ['year', 'sep', 'br', 'fmt'].forEach((key) => { const el = win.querySelector('#ocd-nc-' + key); if (!el) return; el.addEventListener('change', () => { const patch = key === 'year' ? { year: el.checked } : key === 'sep' ? { sep: el.checked } : key === 'br' ? { brackets: el.checked } : { fmtTag: el.checked }; Object.assign(prefs, shNamePrefsSave(patch)); refresh(); }); }); const runBtn = win.querySelector('#ocd-nc-run'); if (runBtn) runBtn.addEventListener('click', () => { store.set('ocd_helper_namechk_last_v1', win.querySelector('#ocd-nc-in').value); refresh(); }); const batchBtn = win.querySelector('#ocd-nc-batch'); if (batchBtn) batchBtn.addEventListener('click', () => { try { const tasks = getBatchTasks().tasks || []; const text = tasks.map(t => String(t.raw || '').split(/[\\/]/).pop()).filter(Boolean).join('\n'); if (!text) { toastSafe('批量队列为空', 'err'); return; } store.set('ocd_helper_namechk_last_v1', text); refresh(); toastSafe('📥 已载入 ' + tasks.length + ' 个任务的目录名', 'ok'); } catch (e) { toastSafe('读取批量队列失败:' + (e.message || e), 'err'); } }); }; const refresh = () => { const body = win.querySelector('#ocd-util-body'); if (!body) return; body.innerHTML = render(); bind(); }; bind(); return win; } /* ================================================================ * §59 种子内容比对 * ================================================================ */ function shBencodeDecode(arrayBuffer) { const u8 = arrayBuffer instanceof Uint8Array ? arrayBuffer : new Uint8Array(arrayBuffer); let p = 0; const dec = new TextDecoder('utf-8'); const parse = () => { const c = u8[p]; if (c === 0x69) { p++; let s = ''; while (p < u8.length && u8[p] !== 0x65) { s += String.fromCharCode(u8[p]); p++; } p++; return parseInt(s, 10); } if (c === 0x6c) { p++; const arr = []; while (p < u8.length && u8[p] !== 0x65) arr.push(parse()); p++; return arr; } if (c === 0x64) { p++; const obj = {}; while (p < u8.length && u8[p] !== 0x65) { const k = parse(); obj[typeof k === 'string' ? k : String(k)] = parse(); } p++; return obj; } if (c >= 0x30 && c <= 0x39) { let s = ''; while (p < u8.length && u8[p] >= 0x30 && u8[p] <= 0x39) { s += String.fromCharCode(u8[p]); p++; } if (u8[p] !== 0x3a) throw new Error('bencode 格式错误:字符串长度后缺少 “:”'); p++; const len = parseInt(s, 10); const slice = u8.subarray(p, p + len); p += len; return dec.decode(slice); } throw new Error('bencode 非法字节:0x' + ((c || 0).toString(16))); }; return parse(); } /* ============ 6.7 Web Worker:bencode 解析放后台线程 ============ */ let _bcWorker = null, _bcWorkerUrl = '', _bcWorkerBroken = false, _bcWorkerFail = 0; function shBencodeWorkerMarkBroken(why) { try { wlog('[Worker] 后台线程不可用(' + why + '),后续改用主线程解析'); } catch (e) {} _bcWorkerBroken = true; shBencodeWorkerStop(); } function shBencodeWorkerCode() { return [ 'self.onmessage = function (e) {', ' var buf = e && e.data ? e.data.buf : null;', ' try {', ' var u8 = (buf instanceof Uint8Array) ? buf : new Uint8Array(buf);', ' var p = 0, dec = new TextDecoder("utf-8");', ' function parse() {', ' if (p >= u8.length) throw new Error("bencode 数据提前结束");', ' var c = u8[p];', ' if (c === 0x69) {', ' p++; var s0 = "";', ' while (p < u8.length && u8[p] !== 0x65) { s0 += String.fromCharCode(u8[p]); p++; }', ' p++; return parseInt(s0, 10);', ' }', ' if (c === 0x6c) {', ' p++; var arr = [];', ' while (p < u8.length && u8[p] !== 0x65) arr.push(parse());', ' p++; return arr;', ' }', ' if (c === 0x64) {', ' p++; var obj = {};', ' while (p < u8.length && u8[p] !== 0x65) { var k = parse(); obj[typeof k === "string" ? k : String(k)] = parse(); }', ' p++; return obj;', ' }', ' if (c >= 0x30 && c <= 0x39) {', ' var s1 = "";', ' while (p < u8.length && u8[p] >= 0x30 && u8[p] <= 0x39) { s1 += String.fromCharCode(u8[p]); p++; }', ' if (u8[p] !== 0x3a) throw new Error("bencode 格式错误:字符串长度后缺少冒号");', ' p++; var len = parseInt(s1, 10); var sl = u8.subarray(p, p + len); p += len; return dec.decode(sl);', ' }', ' throw new Error("bencode 非法字节:0x" + ((c || 0).toString(16)));', ' }', ' var root = parse();', ' self.postMessage({ ok: true, root: root });', ' } catch (err) {', ' self.postMessage({ ok: false, err: String((err && err.message) || err) });', ' }', '};', ].join('\n'); } function shBencodeWorkerGet() { if (_bcWorkerBroken) return null; if (_bcWorker) return _bcWorker; try { if (typeof Worker !== 'function' || typeof Blob !== 'function' || !URL || !URL.createObjectURL) { _bcWorkerBroken = true; return null; } const blob = new Blob([shBencodeWorkerCode()], { type: 'application/javascript' }); _bcWorkerUrl = URL.createObjectURL(blob); _bcWorker = new Worker(_bcWorkerUrl); ilog('[Worker] bencode 后台线程已就绪'); return _bcWorker; } catch (e) { _bcWorkerBroken = true; wlog('[Worker] 创建失败,回退主线程解析:' + ((e && e.message) || e)); return null; } } function shBencodeWorkerStop() { try { if (_bcWorker) _bcWorker.terminate(); } catch (e) {} try { if (_bcWorkerUrl) URL.revokeObjectURL(_bcWorkerUrl); } catch (e) {} _bcWorker = null; _bcWorkerUrl = ''; } /* 异步解析:大种子优先走 Worker(3 秒超时自动回退主线程) */ function shBencodeDecodeAsync(arrayBuffer, opts) { const o = opts || {}; const u8 = (arrayBuffer instanceof Uint8Array) ? arrayBuffer : new Uint8Array(arrayBuffer); const kb = u8.length / 1024; const perf = advGroup('perf') || {}; const minKB = Math.max(16, Number(perf.workerMinKB) || 256); const wantWorker = perf.worker !== false && kb >= minKB; if (!wantWorker) return Promise.resolve({ root: shBencodeDecode(u8), via: 'sync' }); const wk = shBencodeWorkerGet(); if (!wk) return Promise.resolve({ root: shBencodeDecode(u8), via: 'sync-fallback' }); return new Promise((resolve) => { let done = false; const finish = (root, via) => { if (done) return; done = true; try { wk.onmessage = null; wk.onerror = null; } catch (e) {} resolve({ root: root, via: via }); }; const timer = setTimeout(() => { wlog('[Worker] 解析超时,回退主线程'); _bcWorkerFail++; if (_bcWorkerFail >= 2) shBencodeWorkerMarkBroken('连续 2 次解析超时'); else { try { shBencodeWorkerStop(); } catch (e) {} } try { finish(shBencodeDecode(u8), 'sync-timeout'); } catch (e) { finish(null, 'error'); } }, Math.max(500, Number(o.timeoutMs) || 3000)); wk.onmessage = (ev) => { clearTimeout(timer); const d = ev && ev.data; if (d && d.ok) finish(d.root, 'worker'); else { wlog('[Worker] 解析失败,回退主线程:' + ((d && d.err) || '未知')); try { finish(shBencodeDecode(u8), 'sync-error'); } catch (e) { finish(null, 'error'); } } }; wk.onerror = (ev) => { clearTimeout(timer); shBencodeWorkerMarkBroken('创建或运行失败,可能被页面 CSP 限制:' + ((ev && ev.message) || '')); try { finish(shBencodeDecode(u8), 'sync-onerror'); } catch (e) { finish(null, 'error'); } }; /* 注意:必须传副本 —— 若转移(transfer)原 buffer,回退到主线程时会读到已分离的缓冲区(全 0) */ let pay = null; try { pay = u8.slice(0); } catch (e) { pay = u8; } try { wk.postMessage({ buf: pay }); } catch (e) { clearTimeout(timer); try { finish(shBencodeDecode(u8), 'sync-postfail'); } catch (e2) { finish(null, 'error'); } } }); } async function shParseTorrentBufAsync(arrayBuffer) { const r = await shBencodeDecodeAsync(arrayBuffer); if (!r || !r.root) throw new Error('bencode 解析失败'); return { parsed: shParseTorrentRoot(r.root, r.via, arrayBuffer), via: r.via }; } function shTorrentPiecesBytes(arrayBuffer) { const u8 = arrayBuffer instanceof Uint8Array ? arrayBuffer : new Uint8Array(arrayBuffer); const key = [0x36, 0x3a, 0x70, 0x69, 0x65, 0x63, 0x65, 0x73]; for (let i = 0; i + key.length < u8.length; i++) { let ok = true; for (let j = 0; j < key.length; j++) { if (u8[i + j] !== key[j]) { ok = false; break; } } if (!ok) continue; let p = i + key.length, s = ''; while (u8[p] >= 0x30 && u8[p] <= 0x39) { s += String.fromCharCode(u8[p]); p++; } return u8[p] === 0x3a ? (parseInt(s, 10) || 0) : 0; } return 0; } function shParseTorrentBuf(arrayBuffer) { return shParseTorrentRoot(shBencodeDecode(arrayBuffer), 'sync', arrayBuffer); } function shParseTorrentRoot(root, via, arrayBuffer) { const info = root && root.info; if (!info || typeof info !== 'object') throw new Error('不是有效的 .torrent 文件(缺少 info 段)'); const files = []; if (Array.isArray(info.files)) { info.files.forEach((f) => { const parts = Array.isArray(f['path.utf-8']) ? f['path.utf-8'] : (Array.isArray(f.path) ? f.path : []); files.push({ path: parts.map(String).join('/'), length: Number(f.length) || 0 }); }); } else if (info.name) { files.push({ path: String(info.name), length: Number(info.length) || 0 }); } const total = files.reduce((a, f) => a + (Number(f.length) || 0), 0); const ann = []; if (root.announce) ann.push(String(root.announce)); if (Array.isArray(root['announce-list'])) { root['announce-list'].forEach((t) => { if (Array.isArray(t)) t.forEach(x => ann.push(String(x))); else ann.push(String(t)); }); } const piecesBytes = shTorrentPiecesBytes(arrayBuffer); return { name: String(info.name || ''), files: files, fileCount: files.length, totalSize: total, pieceLength: Number(info['piece length']) || 0, pieceCount: piecesBytes ? Math.floor(piecesBytes / 20) : 0, priv: !!info.private, via: via || 'sync', announce: ann, announceCount: ann.length, createdBy: String(root['created by'] || ''), comment: String(root.comment || ''), encoding: String(root.encoding || ''), }; } async function shWalkDirToMap(dirHandle, prefix, out) { for await (const entry of dirHandle.entries()) { const name = entry[0], handle = entry[1]; const rel = prefix ? (prefix + '/' + name) : name; if (handle.kind === 'file') { try { const f = await handle.getFile(); out[rel] = f.size; } catch (e) { out[rel] = -1; } } else if (handle.kind === 'directory') { await shWalkDirToMap(handle, rel, out); } } return out; } function shCompareTorrentDir(ti, dirMap) { const torrent = {}; const rootName = String(ti.name || '').trim(); ti.files.forEach((f) => { let p = String(f.path || ''); if (rootName && p.indexOf(rootName + '/') === 0) p = p.slice(rootName.length + 1); torrent[p] = Number(f.length) || 0; }); const local = {}; Object.keys(dirMap).forEach((k) => { let p = k; if (rootName && p.indexOf(rootName + '/') === 0) p = p.slice(rootName.length + 1); local[p] = dirMap[k]; }); const onlyTorrent = [], onlyLocal = [], sizeDiff = []; Object.keys(torrent).forEach((p) => { if (!(p in local)) onlyTorrent.push({ path: p, size: torrent[p] }); else if (local[p] !== torrent[p]) sizeDiff.push({ path: p, torrent: torrent[p], local: local[p] }); }); Object.keys(local).forEach((p) => { if (!(p in torrent)) onlyLocal.push({ path: p, size: local[p] }); }); return { torrentCount: Object.keys(torrent).length, localCount: Object.keys(local).length, onlyTorrent: onlyTorrent, onlyLocal: onlyLocal, sizeDiff: sizeDiff, ok: !onlyTorrent.length && !onlyLocal.length && !sizeDiff.length, }; } function shFmtBytes(n) { const v = Number(n) || 0; if (v >= 1024 * 1024 * 1024) return (v / 1024 / 1024 / 1024).toFixed(2) + ' GB'; if (v >= 1024 * 1024) return (v / 1024 / 1024).toFixed(2) + ' MB'; if (v >= 1024) return (v / 1024).toFixed(1) + ' KB'; return v + ' B'; } function shShowTorrentCompareTool() { const state = { ti: null, cmp: null, dirName: '' }; const chooseFile = () => { const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.torrent,application/x-bittorrent'; inp.addEventListener('change', async () => { const f = inp.files && inp.files[0]; if (!f) return; try { const buf = await f.arrayBuffer(); if ((buf.byteLength || 0) / 1024 >= (Math.max(16, Number((advGroup('perf') || {}).workerMinKB) || 256))) { const _r = await shParseTorrentBufAsync(buf); state.ti = _r.parsed; toastSafe('🧵 已在后台线程解析种子(' + _r.via + ')', 'info'); } else { state.ti = shParseTorrentBuf(buf); } state.ti.fileName = f.name; state.cmp = null; draw(); toastSafe('✅ 已解析 ' + state.ti.fileName + '(' + state.ti.fileCount + ' 个文件)', 'ok'); } catch (e) { toastSafe('解析失败:' + (e.message || e), 'err'); } }); inp.click(); }; const chooseDir = async () => { if (typeof window.showDirectoryPicker !== 'function') { toastSafe('当前浏览器不支持目录选择(需 Chrome/Edge)', 'err'); return; } try { const dir = await window.showDirectoryPicker({ mode: 'read' }); const perm = await dir.queryPermission({ mode: 'read' }); if (perm !== 'granted') { toastSafe('未授予目录读取权限', 'err'); return; } const map = await shWalkDirToMap(dir, '', {}); state.dirName = dir.name; state.cmp = state.ti ? shCompareTorrentDir(state.ti, map) : null; state.dirMap = map; draw(); toastSafe('📂 已扫描目录「' + dir.name + '」(' + Object.keys(map).length + ' 个文件)', 'ok'); } catch (e) { if (e && e.name === 'AbortError') return; toastSafe('目录扫描失败:' + (e.message || e), 'err'); } }; const draw = () => { const ti = state.ti, c = state.cmp; let html = '
' + ' ' + ' ' + '
'; if (!ti) { html += '
选择种子文件后可查看:文件数 / 总大小 / 分片 / 创建工具 / 是否私有 / Tracker 数;再选本地目录即可逐文件比对差异。
'; } else { html += '
' + shCard('种子名', ti.name || '—') + shCard('文件数', ti.fileCount) + shCard('总大小', shFmtBytes(ti.totalSize)) + shCard('分片', ti.pieceCount + ' × ' + shFmtBytes(ti.pieceLength)) + shCard('Tracker', ti.announceCount) + shCard('私有', ti.priv ? '是' : '否') + '
'; html += '
' + '文件:' + esc(ti.fileName || '') + ' · 创建工具:' + esc(ti.createdBy || '—') + ' · 编码:' + esc(ti.encoding || '—') + (ti.comment ? '
备注:' + esc(ti.comment.slice(0, 120)) : '') + '
'; html += '
📄 种子内文件清单(' + ti.fileCount + ')' + '
' + ti.files.slice(0, 400).map(f => esc(f.path) + ' ' + shFmtBytes(f.length) + '').join('
') + (ti.files.length > 400 ? '
…(仅显示前 400 项)' : '') + '
'; if (c) { html += '
' + (c.ok ? '✅ 与目录「' + esc(state.dirName) + '」完全一致' : '⚠️ 与目录「' + esc(state.dirName) + '」存在差异') + '
'; html += '
种子 ' + c.torrentCount + ' 个文件 / 本地 ' + c.localCount + ' 个文件 · 仅在种子 ' + c.onlyTorrent.length + ' · 仅在本地 ' + c.onlyLocal.length + ' · 大小不一致 ' + c.sizeDiff.length + '
'; const sec = (title, arr, fmt) => arr.length ? '
' + title + '(' + arr.length + ')
' + arr.slice(0, 200).map(fmt).join('
') + '
' : ''; html += sec('仅在种子中', c.onlyTorrent, x => esc(x.path) + ' ' + shFmtBytes(x.size) + ''); html += sec('仅在本地目录中', c.onlyLocal, x => esc(x.path) + ' ' + shFmtBytes(x.size) + ''); html += sec('大小不一致', c.sizeDiff, x => esc(x.path) + ' 种子 ' + shFmtBytes(x.torrent) + ' / 本地 ' + shFmtBytes(x.local) + ''); } else if (state.dirMap) { html += '
已扫描目录但未解析种子文件,请选择 .torrent。
'; } } const body = win.querySelector('#ocd-util-body'); if (body) { body.innerHTML = html; const b1 = body.querySelector('#ocd-tc-file'); if (b1) b1.addEventListener('click', chooseFile); const b2 = body.querySelector('#ocd-tc-dir'); if (b2) b2.addEventListener('click', chooseDir); } }; const win = shUtilityModal('🧩 种子内容比对(.torrent ↔ 本地目录)', ''); draw(); return win; } /* ================================================================ * §60 智能订阅 * ================================================================ */ /* 通知 Webhook(订阅命中 / H&R / 断种 / 批量失败 → POST JSON) */ function advWebhook(payload) { try { const url = String((advGroup('notify') || {}).webhook || '').trim(); if (!url) return false; const body = JSON.stringify(Object.assign({ app: 'OpenCD 工具箱', version: VERSION, ts: Date.now() }, payload || {})); GM_xmlhttpRequest({ method: 'POST', url: url, headers: { 'Content-Type': 'application/json' }, data: body, timeout: advTimeoutMs('fetch', 15000), onload: (r) => console.log('[Webhook] ' + (r.status || 0) + ' ' + url), onerror: () => console.warn('[Webhook] 推送失败:' + url), ontimeout: () => console.warn('[Webhook] 推送超时:' + url), }); return true; } catch (e) { console.warn('[Webhook] 异常:', e && e.message); return false; } } /* 通知 → RSS 2.0(本地 blob,可直接订阅/分享) */ function shBuildNotifyRss(items) { const esc2 = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); const list = (items || []).slice(0, 200); const d = new Date(); let xml = '\n\n'; xml += 'OpenCD 工具箱 · 通知\nhttps://open.cd/\n'; xml += '由 OpenCD 工具箱生成的本地通知订阅(共 ' + list.length + ' 条)\n'; xml += '' + d.toUTCString() + '\n'; xml += 'OpenCD 工具箱 v' + VERSION + '\n'; list.forEach((it) => { const t = it.ts ? new Date(it.ts) : d; xml += '\n' + esc2(it.title || '通知') + '\n'; xml += '' + esc2(it.detail || '') + '\n'; xml += '' + t.toUTCString() + '\n'; xml += '' + esc2(it.id || ('ocd-' + t.getTime())) + '\n'; if (it.url) xml += '' + esc2(it.url) + '\n'; xml += '\n'; }); xml += '\n'; return xml; } function shOpenNotifyRss() { try { const p = shNotifyPrefs(); const xml = shBuildNotifyRss(p.items || []); const blob = new Blob([xml], { type: 'application/rss+xml;charset=utf-8' }); const url = URL.createObjectURL(blob); window.open(url, '_blank'); setTimeout(() => { try { URL.revokeObjectURL(url); } catch (e) {} }, 60000); toastSafe('📡 已生成 RSS(本地 blob,' + (p.items || []).length + ' 条)', 'ok'); return url; } catch (e) { toastSafe('生成 RSS 失败:' + ((e && e.message) || e), 'err'); return ''; } } /* 完整备份:配置 + IndexedDB + 批量队列(可选包含数据体) */ /* 10.1 可选导出加密:PBKDF2(150k) + AES-GCM,纯本地、不上传 */ const ENC_MAGIC = '__ocdEnc'; function _b64(buf) { try { let s2 = ''; const bytes = new Uint8Array(buf); for (let i = 0; i < bytes.length; i++) s2 += String.fromCharCode(bytes[i]); return btoa(s2); } catch (e) { return ''; } } function _unb64(str) { try { const bin = atob(String(str || '')); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; } catch (e) { return new Uint8Array(0); } } function _wc() { try { if (typeof window !== 'undefined' && window.crypto && window.crypto.subtle && window.crypto.subtle.importKey) return window.crypto; if (typeof crypto !== 'undefined' && crypto.subtle && crypto.subtle.importKey) return crypto; if (typeof unsafeWindow !== 'undefined' && unsafeWindow.crypto && unsafeWindow.crypto.subtle && unsafeWindow.crypto.subtle.importKey) return unsafeWindow.crypto; } catch (e) {} return null; } function shEncAvailable() { return !!_wc(); } async function shEncPack(text, password) { if (!shEncAvailable()) throw new Error('当前浏览器不支持 WebCrypto,无法加密'); const _c = _wc(); if (!_c) throw new Error('当前浏览器不支持 WebCrypto,无法加密'); const enc = new TextEncoder(); const salt = _c.getRandomValues(new Uint8Array(16)); const iv = _c.getRandomValues(new Uint8Array(12)); const base = await _c.subtle.importKey('raw', enc.encode(String(password)), 'PBKDF2', false, ['deriveKey']); const key = await _c.subtle.deriveKey( { name: 'PBKDF2', salt: salt, iterations: 150000, hash: 'SHA-256' }, base, { name: 'AES-GCM', length: 256 }, false, ['encrypt']); const ct = await _c.subtle.encrypt({ name: 'AES-GCM', iv: iv }, key, enc.encode(String(text))); return { [ENC_MAGIC]: 1, v: 1, alg: 'PBKDF2-SHA256-150k/AES-GCM', salt: _b64(salt), iv: _b64(iv), data: _b64(ct), _ts: Date.now(), _version: VERSION }; } async function shEncUnpack(obj, password) { if (!obj || !obj[ENC_MAGIC]) return String(obj); const _c2 = _wc(); if (!_c2) throw new Error('当前浏览器不支持 WebCrypto,无法解密'); const enc = new TextEncoder(), dec = new TextDecoder(); const base = await _c2.subtle.importKey('raw', enc.encode(String(password)), 'PBKDF2', false, ['deriveKey']); const salt = _unb64(obj.salt), iv = _unb64(obj.iv); const key = await _c2.subtle.deriveKey( { name: 'PBKDF2', salt: salt, iterations: 150000, hash: 'SHA-256' }, base, { name: 'AES-GCM', length: 256 }, false, ['decrypt']); const pt = await _c2.subtle.decrypt({ name: 'AES-GCM', iv: iv }, key, _unb64(obj.data)); return dec.decode(pt); } async function shExportFullBackup() { try { ocdAudit('data', '导出完整备份'); } catch (e) {} try { toastSafe('⏳ 正在打包完整备份…', 'info'); const out = { _app: 'OpenCD 工具箱', _kind: 'full', _version: VERSION, _ts: Date.now(), keys: {}, idb: {} }; let n = 0; for (const it of STORAGE_KEYS) { const v = store.get(it.k, undefined); if (v === undefined || v === null) continue; out.keys[it.k] = v; n++; } const _rd2 = advRedactMap(out.keys); out.keys = _rd2.out; if (_rd2.n) console.log('[Backup] 已脱敏 ' + _rd2.n + ' 项'); const stores = (typeof ocdDB !== 'undefined' && ocdDB.STORES) ? ocdDB.STORES : ['snapshots', 'events', 'pubRecords', 'metaLibrary', 'reqLedger']; for (const st of stores) { try { const rows = await ocdDB.getAll(st); out.idb[st] = Array.isArray(rows) ? rows : []; } catch (e) { out.idb[st] = []; } } const idbCount = Object.keys(out.idb).reduce((a, k) => a + (out.idb[k] || []).length, 0); /* 10.1 可选加密:输入口令则导出加密包(留空 = 明文 JSON) */ let payload = JSON.stringify(out), fname = 'ocd_fullbackup_' + dateTag() + '.json', encNote = ''; let pw = ''; try { pw = await shPrompt({ title: '可选:设置备份密码(留空 = 不加密)', value: '', placeholder: '设置密码后备份为加密文件', hint: '使用 PBKDF2(15 万次) + AES-GCM 本地加密;密码不会保存,忘记密码将无法恢复', type: 'password' }); } catch (e) { pw = null; } if (pw === null) { toastSafe('已取消导出', 'info'); return null; } if (pw && pw.trim()) { try { payload = JSON.stringify(await shEncPack(payload, pw.trim())); fname = 'ocd_fullbackup_' + dateTag() + '.enc.json'; encNote = ' · 🔒 已加密'; store.set('ocd_helper_lastenc_v1', Date.now()); } catch (e) { toastSafe('加密失败,已改为明文导出:' + ((e && e.message) || e), 'warn'); } } dlFile(payload, fname, 'application/json'); store.set('ocd_helper_lastbackup_v1', Date.now()); toastSafe('💾 完整备份完成:' + n + ' 项配置 · ' + idbCount + ' 条数据库记录' + encNote, 'ok'); return out; } catch (e) { toastSafe('完整备份失败:' + ((e && e.message) || e), 'err'); return null; } } async function shRestoreFullBackup() { try { ocdAudit('data', '恢复完整备份'); } catch (e) {} const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.json,application/json'; inp.addEventListener('change', async () => { const f = inp.files && inp.files[0]; if (!f) return; const text = await f.text(); let data = null; try { data = JSON.parse(text); } catch (e) { toastSafe('❌ 备份文件解析失败', 'err'); return; } /* 10.1 加密备份:先问密码再解密 */ if (data && data[ENC_MAGIC]) { let pw2 = ''; try { pw2 = await shPrompt({ title: '🔒 这是加密备份,请输入密码', value: '', placeholder: '备份密码', hint: '密码仅在本机用于解密,不会被保存或上传', type: 'password' }); } catch (e) { pw2 = null; } if (pw2 === null) { toastSafe('已取消恢复', 'info'); return; } try { data = JSON.parse(await shEncUnpack(data, pw2)); toastSafe('🔓 解密成功,正在恢复…', 'ok'); } catch (e) { ocdAudit('data', '解密备份失败'); toastSafe('❌ 解密失败(密码错误或文件损坏)', 'err'); return; } } const keys = (data && data.keys) || {}; const idb = (data && data.idb) || {}; const idbCount = Object.keys(idb).reduce((a, k) => a + ((idb[k] || []).length), 0); const okGo = await shConfirm({ title: '恢复备份?', html: '备份时间:' + (data && data._ts ? new Date(data._ts).toLocaleString('zh-CN') : '未知') + '
' + '配置 ' + Object.keys(keys).length + ' 项 · 数据库记录 ' + idbCount + ' 条
' + '同键配置会被覆盖;数据库记录为合并写入(不删除现有记录)。', buttons: [{ text: '取消', value: false }, { text: '恢复', value: true, primary: true }], }); if (!okGo) return; let n = 0; for (const k of Object.keys(keys)) { try { store.set(k, keys[k]); n++; } catch (e) {} } let m = 0; for (const st of Object.keys(idb)) { const rows = Array.isArray(idb[st]) ? idb[st] : []; for (let i = 0; i < rows.length; i += 200) { try { await ocdDB.bulkPut(st, rows.slice(i, i + 200)); m += Math.min(200, rows.length - i); } catch (e) {} } } toastSafe('✅ 已恢复 ' + n + ' 项配置 · ' + m + ' 条记录,建议刷新页面', 'ok'); }); inp.click(); } /* 自动备份:每天一次,保留最近 3 份(仅配置,体积小) */ function shAutoBackupTick() { try { const last = Number(store.get('ocd_helper_lastbackup_v1', 0)) || 0; if (Date.now() - last < 24 * 3600 * 1000) return false; const snap = { _ts: Date.now(), _version: VERSION, keys: {} }; for (const it of STORAGE_KEYS) { const v = store.get(it.k, undefined); if (v === undefined || v === null) continue; try { if (JSON.stringify(v).length > 60000) continue; } catch (e) { continue; } snap.keys[it.k] = v; } const slots = [1, 2, 3].map(i => Number(store.get('ocd_helper_backup_at_' + i, 0)) || 0); let slot = 1; for (let i = 0; i < 3; i++) { if (!slots[i]) { slot = i + 1; break; } } if (slots.every(x => x)) slot = slots.indexOf(Math.min.apply(null, slots)) + 1; store.set('ocd_helper_backup_' + slot, snap); store.set('ocd_helper_backup_at_' + slot, snap._ts); store.set('ocd_helper_lastbackup_v1', snap._ts); console.log('[Backup] 自动备份到槽 ' + slot + '(' + Object.keys(snap.keys).length + ' 项)'); return true; } catch (e) { console.warn('[Backup] 自动备份失败:', e && e.message); return false; } } function shListAutoBackups() { return [1, 2, 3].map((i) => { const s = store.get('ocd_helper_backup_' + i, null); return { slot: i, ts: Number(store.get('ocd_helper_backup_at_' + i, 0)) || 0, keys: s && s.keys ? Object.keys(s.keys).length : 0, data: s }; }).filter(x => x.data); } async function shRestoreAutoBackup(slot) { const s = store.get('ocd_helper_backup_' + slot, null); if (!s || !s.keys) { toastSafe('该备份槽为空', 'err'); return false; } const okGo = await shConfirm({ title: '恢复自动备份(槽 ' + slot + ')?', html: '备份时间:' + new Date(s._ts || 0).toLocaleString('zh-CN') + '
配置 ' + Object.keys(s.keys).length + ' 项(同键覆盖)', buttons: [{ text: '取消', value: false }, { text: '恢复', value: true, primary: true }] }); if (!okGo) return false; let n = 0; for (const k of Object.keys(s.keys)) { try { store.set(k, s.keys[k]); n++; } catch (e) {} } toastSafe('✅ 已恢复 ' + n + ' 项配置(来自槽 ' + slot + ')', 'ok'); return true; } const SUBS_KEY = 'ocd_helper_subs_v1'; const SUBS_TTL = advTtlMs('subs', 1440); /* 旧版兼容:早期版本把「站内翻页上限 / 每条保留命中」存在 subs 私有字段里, 现在统一以「高级设置 → 限制与阈值」为唯一数据源;此处把旧值一次性搬过去(仅当高级设置仍是默认值) */ let _subLimitMigrated = false; function shSubMigrateLegacyLimits() { if (_subLimitMigrated) return; _subLimitMigrated = true; try { const raw = store.get(SUBS_KEY, null); if (!raw || typeof raw !== 'object') return; const patch = {}; const hasP = raw.maxPages !== undefined && raw.maxPages !== null && raw.maxPages !== ''; const hasH = raw.maxHits !== undefined && raw.maxHits !== null && raw.maxHits !== ''; if (hasP) { const v = Number(raw.maxPages); if (isFinite(v) && v >= 0 && advNum('subPages', 6) === 6) patch.subPages = Math.floor(v); } if (hasH) { const v = Number(raw.maxHits); if (isFinite(v) && v >= 0 && advNum('subHits', 300) === 300) patch.subHits = Math.floor(v); } if (Object.keys(patch).length) { advSave(patch); console.log('[Subs] 已把旧版的订阅上限迁移到高级设置:', JSON.stringify(patch)); } if (hasP || hasH) { const next = Object.assign({}, raw); delete next.maxPages; delete next.maxHits; try { store.set(SUBS_KEY, next); } catch (e) {} } } catch (e) { wlog('[Subs] 旧版上限迁移失败:' + ((e && e.message) || e)); } } function shSubPrefs() { shSubMigrateLegacyLimits(); /* hitLimit:每列默认渲染的命中条数(0 = 全部) */ const p = store.get(SUBS_KEY, {}) || {}; return { list: Array.isArray(p.list) ? p.list : [], auto: p.auto !== false, lastAuto: Number(p.lastAuto) || 0, /* 唯一数据源 = 高级设置(subPages / subHits);0 或 -1 都表示不限 */ maxPages: (function () { const v = Number(advNum('subPages', 6)); return (v === 0 || v === -1 || !isFinite(v)) ? 0 : Math.max(1, Math.floor(v)); })(), maxHits: (function () { const v = Number(advNum('subHits', 300)); return (v === 0 || v === -1 || !isFinite(v)) ? 0 : Math.max(1, Math.floor(v)); })(), /* 每列默认渲染的命中条数:20 / 50 / 100 / 0(0 = 全部);未设置或非法时回落到 20 */ hitLimit: (p.hitLimit === undefined || p.hitLimit === null || p.hitLimit === '') ? 20 : (Number.isFinite(Number(p.hitLimit)) && Number(p.hitLimit) >= 0 ? Math.floor(Number(p.hitLimit)) : 20), }; } function shSubSave(patch) { const p = Object.assign(shSubPrefs(), patch || {}); try { store.set(SUBS_KEY, p); } catch (e) {} return p; } /* 判断一个 是否指向「种子」详情: 必须排除 userdetails.php(用户/上传者链接,其文本常是纯数字 uid —— 早期用 *="details.php?id=" 会把 userdetails.php?id=81966 命中成「标题=81966」的假命中)。 */ function shIsTorrentLink(a) { try { const h = String((a && a.getAttribute && a.getAttribute('href')) || ''); if (!h || /userdetails\.php/i.test(h)) return false; if (/plugin_details\.php\?[^"'#]*\bid=(\d+)/i.test(h)) return true; return /(^|[\/.])(?:plugin_)?details\.php\?[^"'#]*\bid=(\d+)/i.test(h); } catch (e) { return false; } } function shTorrentIdOf(a) { const h = String((a && a.getAttribute && a.getAttribute('href')) || ''); const m = h.match(/[?&]id=(\d+)/); return m ? m[1] : ''; } /* 在节点内找种子链接:优先取「文本不是纯数字」的那个(真正的标题链接) */ function shTorrentLinkIn(node) { if (!node || !node.querySelectorAll) return null; const all = Array.from(node.querySelectorAll('a[href]')).filter(shIsTorrentLink); if (!all.length) return null; const named = all.find(a => { const txt = String((a.getAttribute('title') || a.textContent || '')).replace(/\s+/g, ' ').trim(); return txt && !/^\d{2,}$/.test(txt); }); return named || null; } function shParseSearchPage(html) { const rows = []; if (!html) return rows; const doc = shParserDocOf(html); const seen = {}; const pick = (node) => { /* 只认真正的种子链接,且必须有像样的标题(纯数字 = 上传者 uid 链接,直接丢弃) */ const a = shTorrentLinkIn(node); if (!a) return null; const id = shTorrentIdOf(a); if (!id || seen[id]) return null; const title = String(a.getAttribute('title') || a.textContent || '').replace(/\s+/g, ' ').trim(); if (!title || /^\d{2,}$/.test(title)) return null; seen[id] = 1; const txt = String(node.textContent || '').replace(/\s+/g, ' '); const sz = txt.match(/([\d.,]+)\s*(TB|GB|MB|KB)/i); const pro = node.querySelector('img[class*="pro_"]'); return { id: id, title: title, size: sz ? (sz[1] + ' ' + sz[2].toUpperCase()) : '', sizeMB: sz ? dpSizeToMB(sz[1], sz[2]) : 0, promo: pro ? String(pro.getAttribute('title') || pro.className || '').slice(0, 12) : '', url: 'https://open.cd/details.php?id=' + id, src: '站内', }; }; if (doc) { doc.querySelectorAll('table.torrentname tr, table.torrents tr').forEach((tr) => { const r = pick(tr); if (r) rows.push(r); }); if (!rows.length) { Array.from(doc.querySelectorAll('a[href]')).filter(shIsTorrentLink).forEach((a) => { const row = a.closest('tr') || a.parentElement; if (!row) return; const r = pick(row); if (r && !rows.some(x => x.id === r.id)) rows.push(r); }); } } return rows; } function shExtractPageCandidates(html, doc) { const found = {}; const keyed = {}; const push = (num, url, src) => { const n = Number(num) || 0; if (n < 1 || n > 50 || keyed[n]) return; if (!url) return; keyed[n] = 1; found[n] = { page: n, url: url, src: src || 'attr' }; }; const s = String(html || ''); let m; const attrRe = /(?:href|onclick|data-url|value|action)\s*=\s*["']([^"']*torrents\.php[^"']*)["']/gi; while ((m = attrRe.exec(s)) !== null) { const rawUrl = m[1].replace(/&/g, '&'); const num = (rawUrl.match(/[?&]page=(\d+)/) || [])[1]; if (!num) continue; push(num, rawUrl.indexOf('http') === 0 ? rawUrl : ('https://open.cd/' + rawUrl.replace(/^\/+/, '')), 'attr'); } const inlineRe = /torrents\.php\?([^"\'\s<>]*?)page=(\d+)/gi; while ((m = inlineRe.exec(s)) !== null) { const num = Number(m[2]) || 0; if (keyed[num]) continue; push(num, 'https://open.cd/torrents.php?' + m[1].replace(/&/g, '&') + 'page=' + num, 'inline'); } const ranges = []; const body = String((doc && doc.body && doc.body.textContent) || '').replace(/\s+/g, ' '); const rre = /(\d+)\s*[-–~]\s*(\d+)/g; let rm; while ((rm = rre.exec(body)) !== null) { const a = parseInt(rm[1], 10), b = parseInt(rm[2], 10); if (a >= 1 && b >= a && (b - a) < 5000) ranges.push({ from: a, to: b }); } return { found: found, ranges: ranges }; } function shParseSearchPageInfo(doc, html, baseUrl) { const info = { total: 0, range: '', links: [], ranges: [] }; if (!doc) return info; const seen = {}; doc.querySelectorAll('a[href*="page="]').forEach((a) => { const href = String(a.getAttribute('href') || ''); if (!/torrents\.php/i.test(href)) return; const abs = (href.indexOf('http') === 0 ? href : ('https://open.cd/' + href.replace(/^\/+/, ''))).replace(/&/g, '&'); const num = Number((abs.match(/[?&]page=(\d+)/) || [])[1] || 0); const m = String(a.textContent || '').trim().match(/(\d+)\s*[-–~]\s*(\d+)/); if (m) info.total = Math.max(info.total, parseInt(m[2], 10) || 0); if (!num && num !== 0) return; if (seen[abs]) return; seen[abs] = 1; info.links.push({ page: num + 1, sitePage: num, url: abs, src: 'attr' }); }); info.links.sort((x, y) => x.page - y.page); const body = String((doc.body && doc.body.textContent) || '').replace(/\s+/g, ' '); const tm = body.match(/共\s*(\d+)\s*个/) || body.match(/(\d+)\s*个种子/) || body.match(/总计\s*(\d+)/); if (tm) info.total = Math.max(info.total, parseInt(tm[1], 10) || 0); const rm = body.match(/(\d+)\s*[-–~]\s*(\d+)\s*(?:\||\/|,)/); if (rm) info.range = rm[0].replace(/\s+/g, ' '); const cand = shExtractPageCandidates(html, doc); info.ranges = cand.ranges; Object.keys(cand.found).forEach((k) => { const cand1 = cand.found[k]; if (!cand1 || !cand1.url) return; if (info.links.some(l => l.url === cand1.url)) return; info.links.push(cand1); }); if (cand.ranges.length) { const per = Math.max(1, cand.ranges[0].to - cand.ranges[0].from + 1); let end = cand.ranges[0].to; let guard = 0; for (;;) { const next = cand.ranges.find(r => r.from === end + 1 && (r.to - r.from + 1) <= per); if (!next || guard++ > 50) break; end = next.to; } info.chainEnd = end; if (end > info.total) info.total = end; info.perPage = per; const pages = Math.ceil(end / per); for (let p = 2; p <= Math.min(pages, 50); p++) { const sitePage = p - 1; if (!baseUrl) break; if (info.links.some(l => l.url === baseUrl && sitePage === 0)) continue; const hasPage = /[?&]page=\d+/.test(baseUrl); const u = hasPage ? baseUrl.replace(/([?&])page=\d+/, '$1page=' + sitePage) : (baseUrl + '&page=' + sitePage); if (info.links.some(l => l.url === u)) continue; info.links.push({ page: p, url: u, src: 'built' }); } } info.links.sort((x, y) => x.page - y.page); info.realLinks = info.links.filter(l => l.src && l.src !== 'built').length; return info; } async function shSubScanSite(kw, onStep) { const pref = shSubPrefs(); const out = []; const seen = {}; const baseUrl = 'https://open.cd/torrents.php?search=' + encodeURIComponent(kw) + '&incldead=1&search_area=0'; let declaredTotal = 0; let pages = 0; const queue = []; const seenUrl = {}; const pushPage = (url) => { if (!url || seenUrl[url] || queue.some(q => q.url === url)) return; seenUrl[url] = 1; queue.push({ url: url, page: queue.length + 1 }); }; pushPage(baseUrl); let emptyStreak = 0; const softPages = advNum('subSoftPages', 6); let softAsked = false; while (queue.length && (pref.maxPages <= 0 || pages < pref.maxPages)) { if (softPages > 0 && pages >= softPages && !softAsked) { softAsked = true; const _go = await shConfirm({ title: '⚠️ 已扫描 ' + pages + ' 页(超过推荐值 ' + softPages + ')', html: '站点声明共 ' + (declaredTotal || '?') + ' 条 · 当前已解析 ' + out.length + ' 条
' + '继续可能带来:站点风控 / 页面卡顿 / 内存占用上升。
' + '当前设置:页数上限 ' + (pref.maxPages <= 0 ? '不限' : pref.maxPages + ' 页') + '(可在订阅工具或 ⚙ 高级设置里调整)', buttons: [{ text: '到此为止', value: false }, { text: '不再提醒 24h', value: 'snooze' }, { text: '继续扫描', value: true, primary: true }], }); if (_go === 'snooze') advSnooze('subPages', 1440); if (_go === false) break; } const job = queue.shift(); job.page = pages + 1; let html = ''; try { html = await gmFetchText(job.url, { Referer: 'https://open.cd/torrents.php' }); } catch (e) { if (onStep) onStep('第 ' + job.page + ' 页抓取失败:' + ((e && e.message) || e)); break; } const doc = shParserDocOf(html); const rows = shParseSearchPage(html); let added = 0; rows.forEach((r) => { if (r.id && !seen[r.id]) { seen[r.id] = 1; out.push(r); added++; } }); pages++; if (pages === 1) { const info = shParseSearchPageInfo(doc, html, baseUrl); declaredTotal = info.total || 0; const mkPage = (k) => { const has = /[?&]page=\d+/.test(baseUrl); return has ? baseUrl.replace(/([?&])page=\d+/, '$1page=' + k) : (baseUrl + '&page=' + k); }; const pageCount = (info.chainEnd && info.perPage) ? Math.max(1, Math.ceil(info.chainEnd / info.perPage)) : 1; const seq = {}; for (let k = 1; k < pageCount && (pref.maxPages <= 0 || k < pref.maxPages); k++) { seq[k] = 1; pushPage(mkPage(k)); } info.links.forEach(l => { if (l.sitePage === undefined || !seq[l.sitePage]) pushPage(l.url); }); if (onStep) onStep('站点声明共 ' + (declaredTotal || '?') + ' 条 · 区间链 ' + (info.chainEnd || '?') + ' · 翻页链接 ' + info.links.length + ' 个(真实 ' + (info.realLinks || 0) + ' / 构造 ' + (info.links.length - (info.realLinks || 0)) + ',站点 page 为 0 基)'); if (!info.links.length) { for (let p = 2; p <= pageCount; p++) { if (pref.maxPages > 0 && p > pref.maxPages) break; pushPage(baseUrl + '&page=' + (p - 1)); } } } if (onStep) onStep('第 ' + job.page + ' 页(HTTP ' + (html ? html.length : 0) + ' 字节) 解析 ' + rows.length + ' 条 · 新增 ' + added + ' · 累计 ' + out.length); if (added === 0) { emptyStreak++; if (emptyStreak >= 2 || (rows.length === 0 && !queue.length)) break; } else { emptyStreak = 0; } if (declaredTotal && out.length >= declaredTotal) break; if (out.length >= 2000) break; if (queue.length) await new Promise(r => setTimeout(r, 350)); } console.log('[Subs] 站内搜索「' + kw + '」' + pages + ' 页共 ' + out.length + ' 条 · 站点声明 ' + (declaredTotal || '未知') + ' 条(页数上限 ' + pref.maxPages + ')'); return { rows: out, total: declaredTotal, pages: pages }; } async function shSubPageDiag(kw) { const baseUrl = 'https://open.cd/torrents.php?search=' + encodeURIComponent(kw) + '&incldead=1&search_area=0'; const out = []; let html = ''; try { html = await gmFetchText(baseUrl, { Referer: 'https://open.cd/torrents.php' }); } catch (e) { return { err: '首页抓取失败:' + ((e && e.message) || e), pages: [] }; } const doc = shParserDocOf(html); const info = shParseSearchPageInfo(doc, html, baseUrl); const rows1 = shParseSearchPage(html); out.push({ page: 1, bytes: html.length, rows: rows1.length, url: baseUrl }); for (const l of info.links.slice(0, 2)) { let h2 = ''; try { h2 = await gmFetchText(l.url, { Referer: 'https://open.cd/torrents.php' }); } catch (e) { out.push({ page: l.page, bytes: 0, rows: -1, url: l.url, err: (e && e.message) || String(e) }); continue; } const r2 = shParseSearchPage(h2); const ids1 = {}; rows1.forEach(r => { ids1[r.id] = 1; }); const dup = r2.filter(r => ids1[r.id]).length; out.push({ page: l.page, bytes: h2.length, rows: r2.length, dupWithP1: dup, url: l.url }); await new Promise(r => setTimeout(r, 350)); } let pagerHtml = ''; let pagerKey = ''; try { const s = String(html); const ranges = info.ranges || []; const last = ranges.length ? ranges[ranges.length - 1] : null; const keys = []; if (last) keys.push(last.from + ' - ' + last.to, last.from + '-' + last.to, last.from + ' - ' + last.to); keys.push('page='); for (const k of keys) { const idx = s.lastIndexOf(k); if (idx < 0) continue; pagerKey = k; pagerHtml = s.slice(Math.max(0, idx - 700), idx + 700).replace(/\s+/g, ' '); break; } } catch (e) {} const pageParamCount = (String(html).match(/[?&](?:amp;)?page=\d+/g) || []).length; return { total: info.total, chainEnd: info.chainEnd, perPage: info.perPage, links: info.links.length, realLinks: info.realLinks || 0, linkPages: info.links.map(l => l.page), linkSrc: info.links.map(l => l.page + ':' + (l.src || '?')), ranges: info.ranges || [], pageParamCount: pageParamCount, pages: out, firstRange: info.range, pagerKey: pagerKey, pagerHtml: pagerHtml, note: (info.realLinks || 0) ? ('已找到站点真实翻页链接 ' + info.realLinks + ' 个,按它逐页抓取') : ('未找到站点真实翻页链接(页面里 page= 出现 ' + pageParamCount + ' 次);本次用「区间链」构造的页 URL 抓取,若第 2 页与第 1 页条数不匹配,请把 pagerHtml 发我'), }; } function shSubDefaultApis() { return Object.keys(PLATFORM_SPECS).slice(0, 8); } function shSubApiNames(apis) { const list = (Array.isArray(apis) && apis.length) ? apis : shSubDefaultApis(); return list.map(k => (PLATFORM_SPECS[k] && PLATFORM_SPECS[k].name) || k); } async function shSubScanPlatform(kw, onStep, apis) { const out = []; const diag = []; const list = (Array.isArray(apis) && apis.length) ? apis : shSubDefaultApis(); if (onStep) onStep('平台:' + shSubApiNames(list).join(' / ')); for (const api of list) { const spec = PLATFORM_SPECS[api]; if (!spec) { if (onStep) onStep(api + ' 不是可用平台,已跳过'); continue; } const name = spec.name || api; let hitRows = []; let cands = []; try { shCandCollectStart(); const r = await spec.search({ artist: '', album: kw, year: '', platform: null }); cands = shCandCollectTake(); if (r && Array.isArray(r.hits)) { hitRows = r.hits.map(h => ({ id: api + ':' + (h.id || h.url || h.name || ''), title: String(h.name || h.title || '').slice(0, 160), size: '', sizeMB: 0, promo: '', url: String(h.url || ''), src: name, })); } else if (r && r.error) { diag.push(name + ':' + String(r.error).slice(0, 60)); } } catch (e) { try { cands = shCandCollectTake(); } catch (e2) {} diag.push(name + ':' + (((e && e.message) || e) + '').slice(0, 60)); } if (!hitRows.length && cands.length) { hitRows = shCandsToHits(cands, name + '·候选').slice(0, 8); if (onStep) onStep(name + ' 精确匹配未通过,改用平台候选 ' + hitRows.length + ' 条'); } else if (hitRows.length) { if (onStep) onStep(name + ' 命中 ' + hitRows.length + ' 条'); } else if (onStep) { onStep(name + ' 无候选'); } out.push.apply(out, hitRows.slice(0, 8)); await new Promise(r => setTimeout(r, 300)); } if (!out.length) console.warn('[Subs] 平台扫描无结果:', diag.join(';')); return out.slice(0, 60); } async function shSubScanOne(sub, onStep) { const hits = []; const before = {}; (sub.hits || []).forEach(h => { before[h.id] = 1; }); let scanned = 0, total = 0, pages = 0; try { if (sub.kind === 'platform') { const rows = await shSubScanPlatform(sub.kw, onStep, sub.apis); scanned = rows.length; rows.forEach(r => { if (!before[r.id]) hits.push(r); }); } else { const variants = (advGroup('kwRules') || {}).variants ? advKwVariants(sub.kw) : []; if (variants.length && onStep) onStep('关键词模板启用:追加 ' + variants.length + ' 个变体(' + variants.join(' / ') + ')'); const kws = [sub.kw].concat(variants); for (const kwOne of kws) { if (kws.length > 1 && onStep) onStep('扫描变体:' + kwOne); const r = await shSubScanSite(kwOne, onStep); scanned += r.rows.length; total = Math.max(total, r.total || 0); pages = Math.max(pages, r.pages || 0); r.rows.forEach(x => { if (!before[x.id]) { before[x.id] = 1; hits.push(x); } }); } } } catch (e) { if (onStep) onStep('扫描失败:' + ((e && e.message) || e)); return { hits: [], scanned: 0, total: 0, pages: 0, err: (e && e.message) || String(e) }; } let err = ''; if (total && scanned < total) err = '站点共 ' + total + ' 条,本次解析到 ' + scanned + ' 条(' + pages + ' 页)'; return { hits: hits, scanned: scanned, total: total, pages: pages, err: err }; } async function shSubScanAll(force) { const p = shSubPrefs(); const now = Date.now(); const list = p.list.slice(); let total = 0, scanned = 0; for (let i = 0; i < list.length; i++) { const sub = list[i]; if (!sub.enabled && !force) continue; if (!force && sub.lastScan && (now - sub.lastScan) < SUBS_TTL) continue; const r = await shSubScanOne(sub); scanned++; sub.lastScan = Date.now(); sub.err = r.err || ''; sub.scanned = r.scanned || 0; sub.siteTotal = r.total || 0; sub.pages = r.pages || 0; if (r.hits.length) { { const _cap = shSubPrefs().maxHits; const _merged = r.hits.concat(sub.hits || []); sub.hits = _cap > 0 ? _merged.slice(0, _cap) : _merged; sub.hitOverflow = _cap > 0 && _merged.length > _cap; if (sub.hitOverflow) advWarnOnce('subHits:' + sub.kw, '「' + sub.kw + '」命中保留已满(' + _cap + ' 条),旧命中滚动淘汰', '可在 ⚙ 高级设置里把「订阅命中保留」调大或设为 0(不限)'); } total += r.hits.length; } list[i] = sub; await new Promise(res => setTimeout(res, 400)); } shSubSave({ list: list, lastAuto: scanned ? now : p.lastAuto }); if (total) { console.log('[Subs] 本次新增命中 ' + total + ' 条(已写入订阅,可在通知中心查看)'); try { await shCollectNotifications(true); } catch (e) {} } return { scanned: scanned, hits: total, list: list }; } /* 做种守护:定时比对做种/断种/H&R,出现「做种归零」或新增 H&R 时高优先级提醒 */ const SEEDGUARD_KEY = 'ocd_helper_seedguard_v1'; function shSeedGuardPrefs() { const p = store.get(SEEDGUARD_KEY, {}) || {}; return { on: p.on !== false, min: Math.max(5, Math.min(720, Number(p.min) || 30)), dropMin: Math.max(1, Math.min(1000, Number(p.dropMin) || 1)), lastRun: Number(p.lastRun) || 0, lastSeeding: (p.lastSeeding === undefined ? null : p.lastSeeding), lastHr: Number(p.lastHr) || 0, }; } function shSeedGuardSave(patch) { const p = Object.assign(shSeedGuardPrefs(), patch || {}); store.set(SEEDGUARD_KEY, p); return p; } function shSeedGuardTick(force) { try { const p = shSeedGuardPrefs(); if (!p.on && !force) return { skipped: 'off' }; if (!force && p.lastRun && (Date.now() - p.lastRun) < p.min * 60000) return { skipped: 'interval' }; const sb = store.get(SEEDBOARD_KEY, null); if (!sb || !sb.totals) return { skipped: 'nodata' }; const seeding = Number(sb.totals.seeding) || 0; const hr = Number(sb.totals.hr) || 0; const dead = Number(sb.totals.dead) || 0; const out = { seeding: seeding, hr: hr, dead: dead, alerts: [] }; if (p.lastSeeding !== null && p.lastSeeding > 0 && seeding <= p.lastSeeding - p.dropMin) { const drop = p.lastSeeding - seeding; out.alerts.push({ kind: 'seed', title: '做种数下降 ' + drop + ' 个(' + p.lastSeeding + ' → ' + seeding + ')', detail: '建议打开做种看板助手 → 仅断种/排序做种数,确认是否掉种', url: '' }); } if (p.lastSeeding !== null && p.lastSeeding > 0 && seeding === 0) { out.alerts.push({ kind: 'seed', title: '⚠️ 当前做种为 0(上次 ' + p.lastSeeding + ')', detail: '可能全部掉种或客户端未做种,请立即检查', url: 'https://open.cd/userdetails.php?id=' + (store.get('ocd_helper_dp_uid_v1', '') || '') }); } if (hr > p.lastHr) { out.alerts.push({ kind: 'hr', title: '新增 H&R ' + (hr - p.lastHr) + ' 项(共 ' + hr + ')', detail: '尽快补种避免警告', url: 'https://open.cd/userdetails.php?id=' + (store.get('ocd_helper_dp_uid_v1', '') || '') }); } shSeedGuardSave({ lastRun: Date.now(), lastSeeding: seeding, lastHr: hr }); out.alerts.forEach((a) => { try { const np = shNotifyPrefs(); np.items = [{ id: 'guard-' + a.kind + '-' + Math.floor(Date.now() / 60000), kind: a.kind, ts: Date.now(), title: '🛡 ' + a.title, detail: a.detail, url: a.url }].concat(np.items || []).slice(0, 60); shNotifySave({ items: np.items }); } catch (e) {} if (!advInQuietHours()) ocdDesktopNotify('OpenCD 做种守护', a.title + (a.detail ? ' · ' + a.detail : ''), a.url); toastSafe('🛡 ' + a.title, a.kind === 'hr' ? 'err' : 'warn'); advWebhook({ kind: 'seedguard', alert: a }); }); console.log('[SeedGuard] 做种 ' + seeding + ' · 断种 ' + dead + ' · H&R ' + hr + ' · 提醒 ' + out.alerts.length + ' 条'); return out; } catch (e) { console.warn('[SeedGuard] 异常:', e && e.message); return { error: (e && e.message) || String(e) }; } } /* 通用排序助手(8.3):为列表项渲染 ▲▼ 按钮,交换后回调保存 */ function shMakeSortable(opts) { const o = opts || {}; const list = Array.isArray(o.list) ? o.list : []; const mk = (i, dir) => { const live = (typeof o.getList === 'function' ? (o.getList() || []) : list); const n = live.length || list.length; const dis = (dir === 'up' && i === 0) || (dir === 'down' && i === n - 1); return ''; }; const html = (i) => mk(i, 'up') + mk(i, 'down'); const bind = (root) => { if (!root) return; root.querySelectorAll('.' + (o.cls || 'ocd-sort') + '-up').forEach((b) => b.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const i = Number(b.dataset.i); if (i <= 0) return; const arr = o.getList(); const tmp = arr[i - 1]; arr[i - 1] = arr[i]; arr[i] = tmp; o.onReorder(arr); if (o.onAfter) o.onAfter(); })); root.querySelectorAll('.' + (o.cls || 'ocd-sort') + '-down').forEach((b) => b.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const i = Number(b.dataset.i); const arr = o.getList(); if (i >= arr.length - 1) return; const tmp = arr[i + 1]; arr[i + 1] = arr[i]; arr[i] = tmp; o.onReorder(arr); if (o.onAfter) o.onAfter(); })); }; return { html: html, bind: bind }; } function shShowWhitelistTool() { const m = dcWhitelistGet(); const keys = Object.keys(m).sort((a, b) => (m[b].ts || 0) - (m[a].ts || 0)); const rows = keys.map((fp, i) => { const it = m[fp] || {}; const ids = Array.isArray(it.hitIds) ? it.hitIds : []; return '' + '' + (i + 1) + '' + '' + esc(String(it.kw || '').slice(0, 60) || fp.slice(0, 24)) + '' + '' + esc(fp.slice(0, 18)) + '…' + '' + ids.length + ' 条 · ' + ids.slice(0, 3).map(x => '
' + esc(x) + '').join(' ') + '' + '' + (it.ts ? new Date(it.ts).toLocaleDateString('zh-CN') : '—') + '' + '' + ''; }).join(''); const stats = dcWhitelistStats(); const html = '
' + '白名单用于「查重命中但我确认可以发布」的专辑:命中指纹与种子 ID 全部在名单内时,查重会跳过告警。
' + '共 ' + stats.albums + ' 个专辑指纹 · ' + stats.hitIds + ' 条命中 ID
' + '
' + ' ' + ' ' + '
' + (keys.length ? ('
' + '' + '' + rows + '
#关键词指纹命中种子加入时间
') : '
白名单为空:在查重结果里点「加入白名单」后会出现在这里。
'); shUtilityModal('🗒 查重白名单管理', html, { width: 720 }); setTimeout(() => { const pop = document.getElementById('ocd-util-pop'); if (!pop) return; pop.querySelectorAll('.ocd-wl-del').forEach((b) => { b.addEventListener('click', async () => { const fp = b.dataset.fp; const okDel = await shConfirm({ title: '删除该白名单条目?', html: '删除后该专辑的命中会重新触发查重告警。', buttons: [{ text: '取消', value: false }, { text: '删除', value: true, primary: true }] }); if (!okDel) return; const mm = dcWhitelistGet(); const backup = JSON.parse(JSON.stringify(mm)); delete mm[fp]; dcWhitelistSet(mm); ocdUndoPush('删除白名单条目', () => dcWhitelistSet(backup)); toastAction('已删除 1 条白名单', '撤销', () => { dcWhitelistSet(backup); toastSafe('已恢复白名单条目', 'ok'); }); pop.querySelector('#ocd-wl-close-x') && null; try { pop.remove(); } catch (e) {} shShowWhitelistTool(); }); }); const ex = pop.querySelector('#ocd-wl-export'); if (ex) ex.addEventListener('click', () => { const text = JSON.stringify(dcWhitelistGet(), null, 2); try { const blob = new Blob([text], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'opencd-dup-whitelist.json'; document.body.appendChild(a); a.click(); a.remove(); toastSafe('已导出白名单 JSON', 'ok'); } catch (e) { try { GM_setClipboard(text); toastSafe('已复制白名单 JSON 到剪贴板', 'ok'); } catch (e2) { toastSafe('导出失败:' + ((e && e.message) || e), 'err'); } } }); const cl = pop.querySelector('#ocd-wl-clear'); if (cl) cl.addEventListener('click', async () => { const okClr = await shConfirm({ title: '清空全部白名单?', html: '共 ' + stats.albums + ' 个专辑指纹将被移除,5 分钟内可撤销。', buttons: [{ text: '取消', value: false }, { text: '清空', value: true, primary: true }] }); if (!okClr) return; const backup = dcWhitelistGet(); dcWhitelistClear(); ocdUndoPush('清空白名单', () => dcWhitelistSet(backup)); toastAction('白名单已清空(' + stats.albums + ' 条)', '撤销', () => { dcWhitelistSet(backup); toastSafe('已恢复白名单', 'ok'); }); try { pop.remove(); } catch (e) {} }); }, 0); } /* 每条订阅的命中视图:筛选(关键词)+ 排序(发布时间 / 标题 / 体积) */ const SUB_HIT_SORTS = [ { k: 'default', cn: '默认(站点/接口顺序)' }, { k: 'time_desc', cn: '发布时间 ↓ 新→旧' }, { k: 'time_asc', cn: '发布时间 ↑ 旧→新' }, { k: 'title_asc', cn: '标题 A→Z' }, { k: 'title_desc', cn: '标题 Z→A' }, { k: 'size_desc', cn: '体积 ↓ 大→小' }, { k: 'size_asc', cn: '体积 ↑ 小→大' }, ]; /* ===== C (ported from V10.0.0#3, step 1): stable hit sorter + key diagnostics ===== Sorts subscription hits so that a click on a sort control always has a visible effect: level -> score -> time (back-filled when missing) -> size -> id/title (final stable key). */ function shHitTimeOf(h) { try { if (!h) return 0; const cands = [h.pubTime, h.publish_time, h.pubtime, h.addedAt, h.addTime, h.ts, h.time]; for (const c of cands) { if (c === undefined || c === null || c === "") continue; if (typeof c === "number" && isFinite(c)) return c > 1e12 ? c : (c > 1e9 ? c * 1000 : c); const str = String(c).trim(); const n = Number(str); if (isFinite(n) && n > 1e8) return n > 1e12 ? n : n * 1000; const d = Date.parse(str.replace(/\//g, "-")); if (isFinite(d)) return d; } /* 缺失:回填抓取时间(没有就取当前时间,保证排序稳定可预期) */ const f = Number(h.fetchedAt || h.crawledAt || 0); if (isFinite(f) && f > 0) return f > 1e12 ? f : f * 1000; return Number(h.__sortNow || 0) || 0; } catch (e) { return 0; } } function shHitSizeOf(h) { try { if (!h) return -1; const mb = (typeof shSubHitSizeMB === "function") ? Number(shSubHitSizeMB(h)) : NaN; if (isFinite(mb) && mb > 0) return mb; const n = Number(h.sizeBytes || h.size || 0); if (isFinite(n) && n > 0) return n > 1e6 ? n / 1048576 : n; const m = String(h.sizeText || "").match(/([\d.]+)\s*(GB|GIB|MB|MIB|KB|KIB)/i); if (m) { const v = Number(m[1]); const u = m[2].toUpperCase(); return u.indexOf("G") === 0 ? v * 1024 : (u.indexOf("K") === 0 ? v / 1024 : v); } return -1; } catch (e) { return -1; } } function shStableHitsOrder(list, opt) { const arr = Array.isArray(list) ? list.slice() : []; const o = opt || {}; const now = Date.now(); const ORD = (typeof window !== "undefined" && window.__ocdORD) ? window.__ocdORD : { "确定重复": 0, "高度疑似": 1, "疑似": 2, "无重复": 3, "新": 4 }; const ordo = function (h) { const k = String((h && h.level) || ""); return (k in ORD) ? ORD[k] : 9; }; const dir = o.desc === false ? 1 : -1; arr.forEach(function (h) { if (h && !h.__sortNow) { try { h.__sortNow = now; } catch (e) {} } }); arr.sort(function (a, b) { const lv = ordo(a) - ordo(b); if (lv) return lv; const sc = (Number(b && b.score || 0)) - (Number(a && a.score || 0)); if (sc) return sc; const ta = shHitTimeOf(a), tb = shHitTimeOf(b); if (ta !== tb) return dir * (ta - tb); const sa = shHitSizeOf(a), sb = shHitSizeOf(b); if (sa !== sb) return dir * (sa - sb); /* 末级稳定键:任何情况下都给出确定顺序 */ const ia = String((a && (a.id || a.tid)) || ""), ib = String((b && (b.id || b.tid)) || ""); if (ia !== ib) return ia < ib ? -1 : 1; const na = String((a && a.title) || ""), nb = String((b && b.title) || ""); return na < nb ? -1 : (na > nb ? 1 : 0); }); return arr; } /* 排序键区分度诊断:告诉用户"为什么点了排序没变化" */ function shHitsSortDiag(list) { const arr = Array.isArray(list) ? list : []; const uniq = function (fn) { const m = new Set(); arr.forEach(function (h) { try { m.add(fn(h)); } catch (e) {} }); return m.size; }; const n = arr.length; const d = { total: n, levelKinds: uniq(function (h) { return String((h && h.level) || ""); }), scoreKinds: uniq(function (h) { return Number(h && h.score || 0); }), timeKinds: uniq(function (h) { return shHitTimeOf(h); }), sizeKinds: uniq(function (h) { return shHitSizeOf(h); }), }; d.noDistinct = (d.levelKinds <= 1 && d.scoreKinds <= 1 && d.timeKinds <= 1 && d.sizeKinds <= 1); d.note = d.noDistinct ? "这些命中在 等级/相似度/时间/体积 上都没有差异,排序键无区分度(顺序已按 id/标题稳定,所以看起来没变化)" : ""; return d; } function shSubHitSizeMB(h) { try { const raw = String((h && h.size) || ''); const m = raw.match(/([\d.]+)\s*(TB|GB|MB|KB|B)/i); if (m) { const v = Number(m[1]) || 0; const u = m[2].toUpperCase(); const f = u === 'TB' ? 1048576 : u === 'GB' ? 1024 : u === 'MB' ? 1 : u === 'KB' ? 1 / 1024 : 1 / 1048576; return v * f; } const n = Number((h && h.sizeMB) || 0); return isFinite(n) ? n : 0; } catch (e) { return 0; } } function shSubViewHits(sub) { const list = ((sub && sub.hits) || []).slice(); const ui = (sub && sub.ui) || {}; const q = String(ui.q || '').trim().toLowerCase(); let out = q ? list.filter(h => ((String((h && h.title) || '') + ' ' + String((h && h.src) || '') + ' ' + String((h && h.size) || '')).toLowerCase().indexOf(q) !== -1)) : list; const sort = String(ui.sort || 'default'); /* V10.0.0#3: default must also be deterministic - level -> score -> time (back-filled) -> size -> id/title */ if (sort === 'default') { out = (typeof shStableHitsOrder === 'function') ? shStableHitsOrder(out) : out; } else { const byTime = (h) => (typeof shHitTimeOf === 'function' ? shHitTimeOf(h) : Number((h && h.ts) || 0)); const byTitle = (h) => String((h && h.title) || ''); const bySize = (h) => (typeof shHitSizeOf === 'function' ? shHitSizeOf(h) : shSubHitSizeMB(h)); out = out.slice().sort((a, b) => { let d = 0; if (sort === 'time_desc') d = byTime(b) - byTime(a); else if (sort === 'time_asc') d = byTime(a) - byTime(b); else if (sort === 'title_asc') d = byTitle(a).localeCompare(byTitle(b), 'zh-Hans-CN'); else if (sort === 'title_desc') d = byTitle(b).localeCompare(byTitle(a), 'zh-Hans-CN'); else if (sort === 'size_desc') d = bySize(b) - bySize(a); else if (sort === 'size_asc') d = bySize(a) - bySize(b); if (d) return d; /* stable tail key (fixes 'clicked sort but nothing changed') */ const ia = String((a && (a.id || a.tid)) || ''), ib = String((b && (b.id || b.tid)) || ''); if (ia !== ib) return ia < ib ? -1 : 1; const na = String((a && a.title) || ''), nb = String((b && b.title) || ''); return na < nb ? -1 : (na > nb ? 1 : 0); }); try { if (typeof shHitsSortDiag === 'function' && typeof advWarnOnce === 'function') { const dg = shHitsSortDiag(out); if (dg && dg.noDistinct) advWarnOnce('subhitnodist', '这批命中在 等级/相似度/时间/体积 上没有差异,排序键无区分度(已按 id/标题给出稳定顺序)', '提示'); } } catch (e) {} } return out; } function shSubSetUi(idx, patch) { try { const p = shSubPrefs(); const list = (p.list || []).slice(); const sub = list[Number(idx)]; if (!sub) return; sub.ui = Object.assign({ q: '', sort: 'default' }, sub.ui || {}, patch || {}); store.set(SUBS_KEY, Object.assign({}, p, { list: list })); } catch (e) { wlog('[Subs] 保存命中视图设置失败:' + ((e && e.message) || e)); } } /* 从订阅命中记录解析种子 id(命中链接形如 plugin_details.php?id=123&hit=1 / details.php?id=123) */ function shSubHitTorrentId(h) { try { if (!h) return ''; const cand = [h.id, h.url, h.link].map(x => String(x == null ? '' : x)); for (const c of cand) { const m = c.match(/(?:^|\D)(\d{3,})/) ; if (/^\d+$/.test(c) && c.length >= 3) return c; const m2 = c.match(/[?&]id=(\d+)/); if (m2) return m2[1]; if (m && /details\.php|plugin_details\.php|download\.php|report\.php/.test(c)) return m[1]; } return ''; } catch (e) { return ''; } } /* 收藏:优先调用站点自身的 setbookmark(el, id, 0);不可用时退回打开详情页 */ function shSubBookmarkTorrent(id) { const tid = String(id || ''); if (!tid) return false; try { const fn = (typeof unsafeWindow !== 'undefined' && typeof unsafeWindow.setbookmark === 'function') ? unsafeWindow.setbookmark : (typeof window !== 'undefined' && typeof window.setbookmark === 'function' ? window.setbookmark : null); if (fn) { const el = document.createElement('a'); el.style.display = 'none'; document.body.appendChild(el); fn.call(typeof unsafeWindow !== 'undefined' ? unsafeWindow : window, el, tid, 0); setTimeout(() => { try { el.remove(); } catch (e) {} }, 1500); toastSafe('☆ 已调用站点收藏(' + tid + ')', 'ok'); ocdAudit('subs', '收藏种子', tid); return true; } } catch (e) { wlog('[Subs] setbookmark 调用失败:' + ((e && e.message) || e)); } window.open('https://open.cd/details.php?id=' + tid, '_blank'); toastSafe('⚠️ 本页没有站点的收藏脚本,已打开详情页,请在那里点「收藏」', 'warn'); return false; } function shShowSubsTool() { /* 每条订阅的「显示全部命中」状态(本次面板会话内有效;显示条数上限由 shSubPrefs().hitLimit 控制,0 = 全部) */ const _subExpand = {}; const _hitLim = (prefs) => { const v = (prefs && (prefs.hitLimit === undefined || prefs.hitLimit === null)) ? 20 : Number(prefs.hitLimit); if (v === 0) return 0; /* 0 = 全部 */ return (v > 0) ? v : 20; }; const _subSort = shMakeSortable({ cls: 'ocd-sub-sort', getList: () => shSubPrefs().list, onReorder: (arr) => { shSubSave({ list: arr }); }, onAfter: () => { try { win.remove(); } catch (e) {} shShowSubsTool(); toastSafe('订阅顺序已更新(扫描按此顺序)', 'ok'); }, }); const render = () => { const p = shSubPrefs(); const subs = p.list || []; const totalHits = subs.reduce((a, s) => a + ((s.hits || []).length), 0); let html = '
' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + '
' + '' + '
' + ' ' + ' ' + ' ' + '
' + '
订阅 ' + subs.length + ' 条 · 累计命中 ' + totalHits + ' 条' + (p.lastAuto ? ' · 上次自动扫描 ' + new Date(p.lastAuto).toLocaleString('zh-CN', { hour12: false }).slice(5, 16) : '') + '
'; html += '
' + '两种模式的作用:
' + '· 站内搜索:在 open.cd 搜索该关键词,逐页抓取收录情况(用于盯「站内是否已有人发布」)—— 命中说明站点已有,因此不提供入队 / 求种,改为站内实用动作:⬇ 下载 · ☆ 收藏 · 🚨 举报 · 📋 复制(点标题可直接打开详情页)。
' + '· 音乐平台:在网易云 / QQ / 酷狗 / 酷我 / Qobuz / Apple 等平台检索该关键词,列出 专辑 / 单曲候选(不要求精确匹配),用于发现新发行、为发布页找素材;平台接口失败时会退回显示原始候选而非空手而归。' + '
'; html += '
'; if (!subs.length) { html += '
还没有订阅:输入关键词后点「添加订阅」。
'; } else { html += subs.map((s, i) => '
' + '
' + ' ' + ' ' + esc(s.kw) + '' + ' ' + (s.kind === 'platform' ? ('音乐平台 · ' + shSubApiNames(s.apis).join('/')) : '站内搜索') + '' + (function () { const all = (s.hits || []).length; const view = shSubViewHits(s); const lim = _subExpand[i] ? 0 : _hitLim(p); const shownN = (lim && view.length > lim) ? lim : view.length; const filtered = view.length !== all; return ' ' + (filtered ? ('筛出 ' + view.length + ' / ' + all) : (all + '')) + ' 条命中' + (view.length > shownN ? ('(已显示 ' + shownN + ' / ' + view.length + ')') : '') + (s.scanned ? '(上次抓取 ' + s.scanned + (s.siteTotal ? ' / 站点 ' + s.siteTotal : '') + ' 条 · ' + (s.pages || 0) + ' 页)' : '') + ''; })() + ' ' + _subSort.html(i) + (s.kind === 'platform' ? '' : '') + ' ' + ' ' + '
' + ((s.hits || []).length ? (function () { const ui = s.ui || {}; return '
' + '' + '' + (String(ui.q || '') || (ui.sort && ui.sort !== 'default') ? '' : '') + '
'; })() : '') + (s.lastScan ? '
上次扫描 ' + new Date(s.lastScan).toLocaleString('zh-CN', { hour12: false }).slice(5, 16) + (s.err ? ' · ⚠ ' + esc(s.err.slice(0, 60)) : '') + '
' : '') + ((s.hits || []).length ? '
' + (function () { const view = shSubViewHits(s); const lim = _subExpand[i] ? 0 : _hitLim(p); return (lim && view.length > lim) ? view.slice(0, lim) : view; })().map((h, hi) => '
' + '' + (h.url ? '' : '') + shHlKw(h.title || h.id, s.kw) + (h.src ? ' [' + esc(h.src) + ']' : '') + (h.size ? ' ' + esc(h.size) + '' : '') + (h.url ? '' : '') + '' + /* 站内命中 = 站点已有该资源:入队 / 求种无意义;改为提供站内实用动作:下载 / 收藏 / 举报 / 复制(+ 点标题看详情) */ (s.kind === 'platform' ? ('' + '' + '') : (function () { const hid = shSubHitTorrentId(h); if (!hid) return '站内已有' + ''; return '站内已有' + '' + '' + '' + ''; })()) + '
').join('') + (function () { const all = shSubViewHits(s).length; const lim = _subExpand[i] ? 0 : _hitLim(p); if (lim && all > lim) { return '
' + '当前只渲染前 ' + lim + ' 条(全量 ' + all + ' 条已存入本地)
'; } if (_subExpand[i]) { return '
' + '已展开全部 ' + all + ' 条
'; } return ''; })() + '
' : '') + '
').join(''); } html += '
'; return html; }; const win = shUtilityModal('🔔 智能订阅(站内 + 音乐平台)', render()); const bindHitActions = () => { win.querySelectorAll('.ocd-sub-hit').forEach((row) => { const sub = shSubPrefs().list[Number(row.dataset.i)]; /* data-hi 是「筛选 / 排序后」的下标,须按同一视图取值(否则筛选后点按钮会错行) */ const view = sub ? shSubViewHits(sub) : []; const h = view[Number(row.dataset.hi)]; if (!h) return; const title = String(h.title || h.id || '').trim(); const q = row.querySelector('.ocd-sub-hit-q'); const c = row.querySelector('.ocd-sub-hit-c'); const r = row.querySelector('.ocd-sub-hit-r'); if (q) q.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); try { batchAddTasks([title]); toastAction('📥 已加入批量队列:' + title.slice(0, 30), '打开批量', () => shOpenModuleById('batch')); } catch (err) { toastSafe('入队失败:' + ((err && err.message) || err), 'err'); } }); if (c) c.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const flash = (ok) => { try { const oldText = c.textContent; const oldBorder = c.style.borderColor, oldColor = c.style.color, oldBg = c.style.background; c.textContent = ok ? '✓ 已复制' : '✗ 失败'; c.style.borderColor = ok ? '#3fd68f' : '#ff6b6b'; c.style.color = ok ? '#3fd68f' : '#ff6b6b'; c.style.background = ok ? 'rgba(63,214,143,.16)' : 'rgba(255,107,107,.16)'; c.style.transition = 'all .15s'; setTimeout(() => { c.textContent = oldText; c.style.borderColor = oldBorder; c.style.color = oldColor; c.style.background = oldBg; }, 1200); } catch (err) {} }; let copied = false; try { if (typeof GM_setClipboard === 'function') { GM_setClipboard(title); copied = true; } } catch (err) { copied = false; } if (!copied) { try { if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(title); copied = true; } } catch (err) { copied = false; } } flash(copied); if (copied) toastSafe('📋 已复制标题:' + title.slice(0, 40) + (title.length > 40 ? '…' : ''), 'ok'); else toastSafe('复制失败:当前环境不支持剪贴板(可在站点页面直接复制)', 'err'); }); const dlB = row.querySelector('.ocd-sub-hit-dl'); if (dlB) dlB.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const tid = dlB.dataset.tid; window.open('https://open.cd/download.php?id=' + tid, '_blank'); ocdAudit('subs', '从订阅下载种子', tid); toastSafe('⬇ 已打开种子下载(' + tid + ')', 'ok'); }); const bmB = row.querySelector('.ocd-sub-hit-bm'); if (bmB) bmB.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); shSubBookmarkTorrent(bmB.dataset.tid); }); const rpB = row.querySelector('.ocd-sub-hit-rep'); if (rpB) rpB.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const tid = rpB.dataset.tid; window.open('https://open.cd/report.php?torrent=' + tid, '_blank'); ocdAudit('subs', '打开种子举报页', tid); toastSafe('🚨 已打开站点举报表单(' + tid + ')', 'info'); }); if (r) r.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const body = '【求种】' + title + '\n\n' + '· 来源:智能订阅「' + (sub.kw || '') + '」命中' + (h.src ? '(' + h.src + ')' : '') + '\n' + (h.url ? '· 参考链接:' + h.url + '\n' : '') + (h.size ? '· 体积:' + h.size + '\n' : '') + '· 悬赏:请按需填写(建议先搜索站内是否已有收录)'; try { GM_setClipboard(body); } catch (err) {} window.open('https://open.cd/viewrequests.php', '_blank'); toastSafe('🙋 已复制求种正文并打开求种区,粘贴即可发帖', 'ok'); }); }); }; const bind = () => { bindHitActions(); const add = win.querySelector('#ocd-sub-add'); if (add) add.addEventListener('click', () => { const kw = String(win.querySelector('#ocd-sub-kw').value || '').trim(); if (!kw) { toastSafe('请先填写关键词', 'err'); return; } const kind = win.querySelector('#ocd-sub-kind').value; const apis = kind === 'platform' ? readPlatPick() : []; const p = shSubPrefs(); if (p.list.some(s => s.kw === kw && s.kind === kind)) { toastSafe('该订阅已存在', 'err'); return; } p.list.unshift({ id: 'sub_' + Date.now(), kw: kw, kind: kind, enabled: true, hits: [], lastScan: 0, apis: apis }); shSubSave({ list: p.list }); refresh(); toastSafe('✅ 已添加订阅「' + kw + '」' + (kind === 'platform' ? ('(平台:' + shSubApiNames(apis).join(' / ') + ')') : ''), 'ok'); }); const platBox = win.querySelector('#ocd-sub-plats'); const readPlatPick = () => Array.from(win.querySelectorAll('.ocd-sub-plat')).filter(c => c.checked).map(c => c.value); const setPlatPick = (apis) => { const list = (Array.isArray(apis) && apis.length) ? apis : []; win.querySelectorAll('.ocd-sub-plat').forEach(c => { c.checked = list.indexOf(c.value) !== -1; }); }; const syncKind = () => { const kindEl = win.querySelector('#ocd-sub-kind'); if (platBox) platBox.style.display = (kindEl && kindEl.value === 'platform') ? 'block' : 'none'; }; const kindEl = win.querySelector('#ocd-sub-kind'); if (kindEl) kindEl.addEventListener('change', syncKind); syncKind(); const pAll = win.querySelector('#ocd-sub-plat-all'); if (pAll) pAll.addEventListener('click', () => { win.querySelectorAll('.ocd-sub-plat').forEach(c => { c.checked = true; }); }); const pNone = win.querySelector('#ocd-sub-plat-none'); if (pNone) pNone.addEventListener('click', () => { win.querySelectorAll('.ocd-sub-plat').forEach(c => { c.checked = false; }); }); win.querySelectorAll('.ocd-sub-apis').forEach(el => el.addEventListener('click', () => { const p = shSubPrefs(); const i = Number(el.dataset.i); const s = p.list[i]; if (!s) return; const cur = shSubApiNames(s.apis); const input = prompt('输入要检索的平台,用逗号分隔(可填英文键或中文名)\n可用:' + Object.keys(PLATFORM_SPECS).map(k => ((PLATFORM_SPECS[k] && PLATFORM_SPECS[k].name) || k) + '(' + k + ')').join('、'), cur.join(',')); if (input === null) return; const keys = Object.keys(PLATFORM_SPECS); const picked = String(input).split(/[,,、\s]+/).map(x => x.trim()).filter(Boolean).map((x) => { if (keys.indexOf(x) !== -1) return x; const hitK = keys.find(k => ((PLATFORM_SPECS[k] && PLATFORM_SPECS[k].name) || '') === x); return hitK || ''; }).filter(Boolean); if (!picked.length) { toastSafe('未识别到有效平台,保持原设置', 'err'); return; } s.apis = picked.slice(0, keys.length); p.list[i] = s; shSubSave({ list: p.list }); refresh(); toastSafe('已更新平台:' + shSubApiNames(s.apis).join(' / '), 'ok'); })); const diag = win.querySelector('#ocd-sub-diag'); if (diag) diag.addEventListener('click', async () => { const kwEl = win.querySelector('#ocd-sub-kw'); const kw = String((kwEl && kwEl.value) || '').trim() || (shSubPrefs().list[0] && shSubPrefs().list[0].kw) || ''; if (!kw) { toastSafe('请先填写关键词', 'err'); return; } diag.disabled = true; const old = diag.textContent; diag.textContent = '诊断中…'; const r = await shSubPageDiag(kw); diag.disabled = false; diag.textContent = old; console.log('[Subs][诊断] 关键词「' + kw + '」', r); if (r.err) { toastSafe('诊断失败:' + r.err, 'err'); return; } shUtilityModal('🔎 分页诊断 · ' + kw, '
' + '站点声明总数:' + (r.total || '未识别') + ' · 页内翻页链接:' + r.links + ' 个' + (r.linkPages && r.linkPages.length ? '(页 ' + esc(r.linkPages.join(',')) + ')' : '') + (r.ranges && r.ranges.length ? ' · 区间 ' + esc(r.ranges.map(x => x.from + '-' + x.to).join(' / ')) : '') + (r.firstRange ? ' · 首区间 ' + esc(r.firstRange) : '') + '
' + '
真实翻页链接 ' + esc(r.realLinks || 0) + ' 个 · 页面内 page= 出现 ' + esc(r.pageParamCount || 0) + ' 次 · 每页 ' + esc(r.perPage || '?') + ' 条 · 区间链推导总数 ' + esc(r.chainEnd || '?') + '
' + (r.linkSrc && r.linkSrc.length ? '
页来源:' + esc(r.linkSrc.join(' ')) + '
' : '') + (r.note ? '
' + esc(r.note) + '
' : '') + (r.pagerHtml ? '
查看翻页区 HTML(若仍抓不全请复制给作者)
' + esc(r.pagerHtml) + '
' : '') + '' + '' + (r.pages || []).map(x => '' + '' + '' + '' + '' + '').join('') + '
页响应字节解析条数与首页重复URL
' + esc(x.page) + '' + esc(x.bytes) + '' + esc(x.rows) + '' + esc(x.dupWithP1 === undefined ? '—' : x.dupWithP1) + '' + esc(String(x.url).slice(0, 120)) + '
' + '
判读:若第 2 页解析条数为 0 或「与首页重复 = 解析条数」,说明站点忽略了翻页参数(需要在原页面重新提交搜索);若解析条数为 100 但总数 204,说明还有后续页未抓到,可把「站内翻页上限」调大再扫。
', { copyText: JSON.stringify(r, null, 1) }); }); const scan = win.querySelector('#ocd-sub-scan'); if (scan) scan.addEventListener('click', async () => { scan.disabled = true; const old = scan.textContent; scan.textContent = '扫描中…'; try { const r = await shSubScanAll(true); toastSafe('🔔 扫描 ' + r.scanned + ' 条订阅,新增命中 ' + r.hits + ' 条', r.hits ? 'ok' : 'err'); } catch (e) { toastSafe('扫描失败:' + (e.message || e), 'err'); } scan.disabled = false; scan.textContent = old; refresh(); }); const pagesEl = win.querySelector('#ocd-sub-pages'); if (pagesEl) pagesEl.addEventListener('change', () => { /* 上限放宽到 500 页,0 = 不限;直接写入高级设置(与 ⚙ 高级设置同源) */ const v = Math.max(0, Math.min(500, Math.floor(Number(pagesEl.value) || 0))); pagesEl.value = String(v); advSave({ subPages: v }); toastSafe('站内翻页上限已设为 ' + (v ? v + ' 页' : '不限') + '(已同步到 ⚙ 高级设置)', 'ok'); }); const mhEl = win.querySelector('#ocd-sub-maxhits'); if (mhEl) mhEl.addEventListener('change', () => { const v = Math.max(0, Math.min(20000, Math.floor(Number(mhEl.value) || 0))); mhEl.value = String(v); advSave({ subHits: v }); toastSafe('每条保留命中已设为 ' + (v ? v + ' 条' : '不限') + '(已同步到 ⚙ 高级设置)', 'ok'); }); const auto = win.querySelector('#ocd-sub-auto'); if (auto) auto.addEventListener('change', () => { shSubSave({ auto: auto.checked }); toastSafe(auto.checked ? '已开启每日自动扫描' : '已关闭自动扫描', 'ok'); }); win.querySelectorAll('.ocd-sub-en').forEach(el => el.addEventListener('change', () => { const p = shSubPrefs(); const i = Number(el.dataset.i); if (!p.list[i]) return; p.list[i].enabled = el.checked; shSubSave({ list: p.list }); refresh(); })); _subSort.bind(win); const hlSel = win.querySelector('#ocd-sub-hitlimit'); if (hlSel) hlSel.addEventListener('change', () => { const v = Number(hlSel.value) || 0; shSubSave({ hitLimit: v }); toastSafe(v ? ('每列默认显示 ' + v + ' 条命中') : '每列显示全部命中(命中很多时面板会变长)', 'info'); const b1 = win.querySelector('#ocd-util-body'); if (b1) b1.innerHTML = render(); bind(); }); const _subRerender = () => { const b = win.querySelector('#ocd-util-body'); if (b) b.innerHTML = render(); bind(); }; win.querySelectorAll('.ocd-sub-hsort').forEach(el => el.addEventListener('change', () => { shSubSetUi(el.dataset.i, { sort: String(el.value || 'default') }); _subRerender(); })); win.querySelectorAll('.ocd-sub-hclear').forEach(el => el.addEventListener('click', () => { shSubSetUi(el.dataset.i, { q: '', sort: 'default' }); _subRerender(); })); let _hqT = null; win.querySelectorAll('.ocd-sub-hq').forEach(el => el.addEventListener('input', () => { const idx = el.dataset.i; const val = el.value; if (_hqT) clearTimeout(_hqT); _hqT = setTimeout(() => { shSubSetUi(idx, { q: val }); _subRerender(); }, 400); })); win.querySelectorAll('.ocd-sub-more').forEach(b => b.addEventListener('click', (e) => { e.stopPropagation(); const i = Number(b.dataset.i); _subExpand[i] = !_subExpand[i]; const b2 = win.querySelector('#ocd-util-body'); if (b2) b2.innerHTML = render(); bind(); })); const csvBtn = win.querySelector('#ocd-sub-csv'); if (csvBtn) csvBtn.addEventListener('click', () => { try { const list = shSubPrefs().list || []; const rows = []; list.forEach((s2) => { (s2.hits || []).forEach((h) => { rows.push([ s2.kw, s2.kind === 'platform' ? ('平台:' + shSubApiNames(s2.apis).join('/')) : '站内', String(h.title || h.id || ''), h.src || '', h.size || '', h.url || '', h.ts ? new Date(h.ts).toLocaleString('zh-CN', { hour12: false }) : '', ]); }); }); if (!rows.length) { toastSafe('还没有命中记录可导出', 'warn'); return; } const head = ['订阅关键词', '类型', '标题', '来源', '大小', '链接', '时间']; const csv = head.join(',') + '\n' + rows.map(r => r.map(v => '"' + String(v).replace(/"/g, '""') + '"').join(',')).join('\n'); const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'opencd-sub-hits-' + new Date().toISOString().slice(0, 10) + '.csv'; a.click(); setTimeout(() => { try { URL.revokeObjectURL(a.href); } catch (e) {} }, 4000); ocdAudit('subs', '导出订阅命中 CSV', rows.length + ' 条'); toastSafe('已导出 ' + rows.length + ' 条命中', 'ok'); } catch (e) { toastSafe('导出失败:' + ((e && e.message) || e), 'err'); } }); win.querySelectorAll('.ocd-sub-del').forEach(el => el.addEventListener('click', async () => { const p = shSubPrefs(); const i = Number(el.dataset.i); const s = p.list[i]; if (!s) return; const okDel = await shConfirm({ title: '删除订阅「' + s.kw + '」?', html: '删除后该订阅的命中记录也会一并移除,5 分钟内可在提示条点「撤销」恢复。', buttons: [{ text: '取消', value: false }, { text: '删除', value: true, primary: true }] }); if (!okDel) return; const backup = JSON.parse(JSON.stringify(p.list)); p.list.splice(i, 1); shSubSave({ list: p.list }); refresh(); ocdUndoPush('删除订阅', () => { shSubSave({ list: backup }); }); toastAction('已删除订阅「' + s.kw + '」', '撤销', () => { shSubSave({ list: backup }); refresh(); toastSafe('已恢复订阅', 'ok'); }); })); win.querySelectorAll('.ocd-sub-one').forEach(el => el.addEventListener('click', async () => { const p = shSubPrefs(); const i = Number(el.dataset.i); const s = p.list[i]; if (!s) return; el.disabled = true; el.textContent = '…'; const r = await shSubScanOne(s); s.lastScan = Date.now(); s.err = r.err || ''; s.scanned = r.scanned || 0; s.siteTotal = r.total || 0; s.pages = r.pages || 0; if (r.hits.length) { const _cap = shSubPrefs().maxHits; const _merged = r.hits.concat(s.hits || []); s.hits = _cap > 0 ? _merged.slice(0, _cap) : _merged; s.hitOverflow = _cap > 0 && _merged.length > _cap; } p.list[i] = s; shSubSave({ list: p.list }); el.disabled = false; el.textContent = '扫描'; refresh(); toastSafe('抓取 ' + (r.scanned || 0) + (r.total ? (' / 站点 ' + r.total) : '') + ' 条 · ' + (r.pages || 0) + ' 页 · 新增 ' + r.hits.length + ' 条' + (r.err ? (' · ⚠ ' + r.err) : ''), r.err ? 'err' : (r.hits.length ? 'ok' : 'err')); })); }; const refresh = () => { const body = win.querySelector('#ocd-util-body'); if (!body) return; body.innerHTML = render(); bind(); }; bind(); return win; } /* ================================================================ * §61 求种账本 * ================================================================ */ const REQLEDGER_KEY = 'ocd_helper_reqledger_v1'; function shLedgerPrefs() { const p = store.get(REQLEDGER_KEY, {}) || {}; return { income: Array.isArray(p.income) ? p.income : [], manualSpend: Array.isArray(p.manualSpend) ? p.manualSpend : [] }; } function shLedgerSave(patch) { const p = Object.assign(shLedgerPrefs(), patch || {}); try { store.set(REQLEDGER_KEY, p); } catch (e) {} return p; } function shLedgerRecordIncome(entry) { if (!entry || !entry.id) return; const p = shLedgerPrefs(); if (p.income.some(x => String(x.id) === String(entry.id))) return; p.income.unshift({ id: String(entry.id), title: String(entry.title || '').slice(0, 120), bonus: Number(entry.bonus) || 0, ts: Date.now() }); shLedgerSave({ income: p.income.slice(0, 300) }); console.log('[Ledger] 记录应求收入:', entry.title, entry.bonus); } function shBuildReqLedger() { const p = shLedgerPrefs(); const posts = (typeof shGetMyPosts === 'function' ? shGetMyPosts() : []) || []; const spend = posts.map(x => ({ id: String(x.id || x.reqid || ''), title: String(x.title || '').slice(0, 120), bonus: Number(x.bonus || x.bounty || x.reward || 0) || 0, status: String(x.status || x.state || ''), ts: Number(x.ts || 0), })); p.manualSpend.forEach(x => spend.push({ id: String(x.id || ''), title: String(x.title || ''), bonus: Number(x.bonus) || 0, status: '手动', ts: Number(x.ts) || 0 })); const income = p.income.slice(); const seenIn = {}; income.forEach(x => { seenIn[String(x.id)] = 1; }); shGetMyRes().forEach((x) => { const id = String(x.id || ''); if (!id || seenIn[id]) return; seenIn[id] = 1; income.push({ id: id, title: String(x.title || ''), bonus: Number(x.bonus) || 0, ts: Number(x.ts) || 0, src: '站点应求' }); }); const spendTotal = spend.reduce((a, x) => a + (x.bonus || 0), 0); const incomeTotal = income.reduce((a, x) => a + (Number(x.bonus) || 0), 0); const done = spend.filter(x => /完成|成功|completed|done/i.test(x.status)).length; return { spend: spend, income: income, spendTotal: spendTotal, incomeTotal: incomeTotal, net: incomeTotal - spendTotal, count: spend.length, done: done, doneRate: spend.length ? Math.round(done / spend.length * 100) : 0, }; } function shShowReqLedgerTool() { const render = () => { const L = shBuildReqLedger(); const row = (x, kind) => '
' + '' + esc(x.title || ('#' + x.id)) + '' + '' + (kind === 'income' ? '+' : '-') + (x.bonus || 0) + '' + '' + (x.ts ? new Date(x.ts).toLocaleDateString('zh-CN') : '') + '' + (x.status ? '' + esc(x.status) + '' : '') + '
'; return '
' + shCard('悬赏支出', L.spendTotal, '#ff9f43', '我发出的求种悬赏合计(来自「我的求种」缓存 + 手动记账)') + shCard('应求收入', L.incomeTotal, '#3fd68f', '脚本记录的我应求所得(点应求时自动记账)') + shCard('净收支', (L.net >= 0 ? '+' : '') + L.net, L.net >= 0 ? '#3fd68f' : '#ff6b6b') + shCard('求种数', L.count) + shCard('完成率', L.doneRate + '%', 'var(--fg-muted)', '已完成的求种 / 全部求种') + '
' + '
' + ' ' + ' ' + ' ' + '
' + '
📤 我发出的悬赏(' + L.spend.length + ')
' + (L.spend.length ? L.spend.map(x => row(x, 'spend')).join('') : '
暂无记录:先在「搜索求助助手 / 求种」模块发布或同步一次「我的求种」。
') + '
📥 我应求所得(' + L.income.length + ')
' + (L.income.length ? L.income.map(x => row(x, 'income')).join('') : '
暂无记录:通过求种帖预览点「⚡ 应求」时会自动记账。
'); }; const win = shUtilityModal('💰 求种投资回报', render()); const bind = () => { const ex = win.querySelector('#ocd-led-export'); if (ex) ex.addEventListener('click', () => { const L = shBuildReqLedger(); const head = ['类型', 'ID', '标题', '悬赏/所得', '状态', '时间']; const rows = L.spend.map(x => ['支出', x.id, x.title, x.bonus, x.status, x.ts ? new Date(x.ts).toLocaleString('zh-CN') : '']) .concat(L.income.map(x => ['收入', x.id, x.title, x.bonus, '', x.ts ? new Date(x.ts).toLocaleString('zh-CN') : ''])); const csv = [head].concat(rows).map(r => r.map(v => '"' + String(v == null ? '' : v).replace(/"/g, '""') + '"').join(',')).join('\r\n'); dlFile('\ufeff' + csv, 'ocd_reqledger_' + dateTag() + '.csv', 'text/csv'); toastSafe('📄 已导出 ' + rows.length + ' 条', 'ok'); }); const man = win.querySelector('#ocd-led-manual'); if (man) man.addEventListener('click', () => { const title = prompt('支出说明(标题)', ''); if (!title) return; const bonus = Number(prompt('支出数量(魔力值/悬赏)', '0')) || 0; const p = shLedgerPrefs(); p.manualSpend.unshift({ id: 'm_' + Date.now(), title: String(title).slice(0, 120), bonus: bonus, ts: Date.now() }); shLedgerSave({ manualSpend: p.manualSpend.slice(0, 300) }); refresh(); toastSafe('已记账', 'ok'); }); const cl = win.querySelector('#ocd-led-clear'); if (cl) cl.addEventListener('click', () => { if (!confirm('清空脚本记录的应求收入与手动支出?(不影响站点数据)')) return; shLedgerSave({ income: [], manualSpend: [] }); refresh(); toastSafe('🧹 已清空记账', 'ok'); }); }; const refresh = () => { const body = win.querySelector('#ocd-util-body'); if (!body) return; body.innerHTML = render(); bind(); }; bind(); return win; } /* ================================================================ * §62 维护工具 * ================================================================ */ const OCD_LOG_MAX = 400; const _ocdLogs = []; function ocdLogTap() { if (window.__ocdLogTapped) return; window.__ocdLogTapped = true; ['log', 'warn', 'error'].forEach(level => { const orig = console[level]; if (typeof orig !== 'function') return; console[level] = function () { try { const parts = Array.prototype.slice.call(arguments).map(a => { if (a instanceof Error) return a.message; if (a && typeof a === 'object') { try { return JSON.stringify(a).slice(0, 300); } catch (e) { return String(a); } } return String(a); }); _ocdLogs.push({ t: Date.now(), lv: level, s: parts.join(' ').slice(0, 500) }); if (_ocdLogs.length > OCD_LOG_MAX) _ocdLogs.splice(0, _ocdLogs.length - OCD_LOG_MAX); } catch (e) {} return orig.apply(console, arguments); }; }); console.log('[Log] 已开始收集调试日志(上限 ' + OCD_LOG_MAX + ' 条)'); } /* ================================================================ * 🛠️ 工具箱维护(独立模块):工具清单 + 可自定义(排序 / 置顶 / 隐藏 / 搜索 / 布局) * ================================================================ */ const TOOLBOX_KEY = 'ocd_helper_toolbox_v1'; const TOOLBOX_GROUPS = [ { k: 'star', cn: '⭐ 置顶' }, { k: 'check', cn: '🔍 自检与诊断' }, { k: 'backup', cn: '💾 配置与备份' }, { k: 'data', cn: '📊 数据与存储' }, { k: 'tools', cn: '🔧 工具面板' }, { k: 'help', cn: '🧭 引导与帮助' }, ]; /* 工具清单(懒构建:内部引用的函数在文件后面定义,函数声明会提升,调用时才取用) */ function toolboxItems() { return [ { id: 'selfcheck', group: 'check', cn: '站点结构自检', desc: '抓取列表页 / 详情页,检查脚本依赖的选择器是否仍然匹配(站点改版首选)', run: () => { shSelectorSelfCheck(); } }, { id: 'diag', group: 'check', cn: '一键自检', desc: '汇总版本 / 数据版本 / 模块数 / 周期任务 / 请求统计 / 存储占用,可复制用于反馈', run: () => shShowDiagnosis() }, { id: 'reqstat', group: 'check', cn: '请求统计', desc: '按域名看请求次数 / 失败 / 平均耗时与去重命中(判断站点是否变慢或风控)', run: () => shShowRequestStats() }, { id: 'audit', group: 'check', cn: '操作审计日志', desc: '发布 / 批量 / 设置 / 数据等关键操作时间线,可筛选、导出 CSV、清空', run: () => shShowAuditTool() }, { id: 'log', group: 'check', cn: '调试日志', desc: '查看脚本运行日志(最近 ' + OCD_LOG_MAX + ' 条),可复制给作者', run: () => shShowLogPanel() }, { id: 'exportcfg', group: 'backup', cn: '导出配置', desc: '把全部设置导出为 JSON 文件(跨设备迁移 / 备份)', run: () => shExportConfig() }, { id: 'importcfg', group: 'backup', cn: '导入配置', desc: '从 JSON 文件恢复设置(同键覆盖,有确认框)', run: () => shImportConfig() }, { id: 'fullbackup', group: 'backup', cn: '完整备份', desc: '配置 + 数据库记录打包导出,可选密码加密(PBKDF2 + AES-GCM)', run: () => shExportFullBackup() }, { id: 'fullrestore', group: 'backup', cn: '恢复完整备份', desc: '读取完整备份文件并合并恢复(自动识别加密包)', run: () => shRestoreFullBackup() }, { id: 'autobak', group: 'backup', cn: '自动备份', desc: '查看 / 恢复脚本自动生成的备份(默认每 8 秒检查一次到期任务)', run: () => shShowAutoBackups() }, { id: 'modexport', group: 'backup', cn: '单模块导出', desc: '只导出某个模块的数据:订阅 / 通知 / 看板 / 账本 / 白名单 / 批量队列 / 高级设置 / 主题', run: () => { const items = Object.keys(MODULE_EXPORT).map(k => k + '(' + MODULE_EXPORT[k].n + ')').join('、'); shPrompt({ title: '单模块导出', html: '可用模块:
' + esc(items), value: 'subs', placeholder: '输入模块键,如 subs', hint: '输入后回车即可导出该模块的 JSON 文件;文件名形如 ocd_subs_日期.json', }).then((k) => { if (k) shExportModule(String(k).trim()); }); } }, { id: 'modimport', group: 'backup', cn: '导入模块数据', desc: '导入单模块导出的 JSON(同键覆盖,危险操作有确认框)', run: () => shImportModule() }, { id: 'storage', group: 'data', cn: '存储占用', desc: '查看各存储键占用并清理缓存类键(体积异常时先看这里)', run: () => shShowStorageUsage() }, { id: 'schema', group: 'data', cn: '数据版本 / 迁移', desc: '查看当前数据版本并手动执行迁移', run: () => { const cur = Number(store.get(SCHEMA_KEY, 0)) || 0; const top = MIGRATIONS[MIGRATIONS.length - 1].v; toastSafe('数据版本:' + cur + ' / 最新 ' + top, cur >= top ? 'ok' : 'warn'); if (cur < top) shRunMigrations(); } }, { id: 'ledger', group: 'data', cn: '求种账本', desc: '悬赏支出 / 应求收入 / 净收支 / 完成率,可导出 CSV', run: () => shShowReqLedgerTool() }, { id: 'pubparams', group: 'data', cn: '发布参数记忆', desc: '发布成功记录的分类 / 平台 / 标签,可查看、导出 JSON、清空', run: () => shShowPubParamsTool() }, { id: 'snapcmp', group: 'data', cn: '快照对比(今日 vs 昨日)', desc: '8 项指标带 ▲▼ 与幅度:魔力值 / 分享率 / 上传下载 / 做种 / 发布 / H&R', run: () => shShowSnapshotCompare() }, { id: 'namecheck', group: 'tools', cn: '目录命名校验', desc: '按规范校验目录名(年份 / 分隔 / 括号 / 格式标签 / 非法字符)并给出建议名', run: () => shShowNameCheckTool() }, { id: 'torrentcmp', group: 'tools', cn: '种子内容比对', desc: '解析 .torrent(文件数 / 大小 / 分片 / 创建工具)并与本地目录逐文件比对差异', run: () => shShowTorrentCompareTool() }, { id: 'subs', group: 'tools', cn: '智能订阅', desc: '关键词订阅(站内搜索 / 音乐平台),每日自动扫描,命中写入通知中心', run: () => shShowSubsTool() }, { id: 'mask', group: 'tools', cn: '分享打码', desc: '截图 / 发帖前把邮箱、passkey、token、长串密钥、IP 抹成 ***(可选打码 uid)', run: () => shShowMaskTool() }, { id: 'whitelist', group: 'tools', cn: '查重白名单', desc: '管理查重白名单:命中的关键词可一键加入,避免反复追问', run: () => shShowWhitelistTool() }, { id: 'onboard', group: 'help', cn: '新手引导', desc: '三条核心动线速览,可一键打开常用模块', run: () => shShowOnboarding(true) }, { id: 'palette', group: 'help', cn: '命令面板', desc: 'Alt+K 也可打开;可搜索全部功能与模块,支持 #类别 / @关键词 / >直接执行', run: () => shShowCommandPalette() }, { id: 'changelog', group: 'help', cn: '更新记录', desc: '查看版本变更历史(当前 v' + VERSION + ')', run: () => shShowChangelog() }, { id: 'manual', group: 'help', cn: '使用手册', desc: '打开同目录下的「OpenCD 工具箱 使用手册.html」(新标签页)', run: () => { try { openManual(); /* V9.73.19:改为线上链接入口 */ } catch (e) { toastSafe('手册文件位于脚本同目录:OpenCD 工具箱 使用手册.html', 'info'); } } }, { id: 'settings', group: 'help', cn: '设置中心', desc: '模块开关、界面与全局、悬浮球 DIY、高级设置入口', run: () => { try { openSettingsCenter(); } catch (e) { toastSafe('打开设置中心失败:' + ((e && e.message) || e), 'err'); } } }, ]; } function toolboxPrefs() { const d = store.get(TOOLBOX_KEY, null) || {}; return { order: Array.isArray(d.order) ? d.order.slice() : [], pinned: (d.pinned && typeof d.pinned === 'object') ? Object.assign({}, d.pinned) : {}, hidden: (d.hidden && typeof d.hidden === 'object') ? Object.assign({}, d.hidden) : {}, collapsed: (d.collapsed && typeof d.collapsed === 'object') ? Object.assign({}, d.collapsed) : {}, view: (d.view === 'flat' ? 'flat' : 'group'), showHidden: !!d.showHidden, }; } function toolboxSave(patch) { const next = Object.assign(toolboxPrefs(), patch || {}); try { store.set(TOOLBOX_KEY, next); } catch (e) {} return next; } function toolboxReset() { try { store.set(TOOLBOX_KEY, { order: [], pinned: {}, hidden: {}, collapsed: {}, view: 'group', showHidden: false }); } catch (e) {} ocdAudit('system', '工具箱维护:恢复默认布局'); return toolboxPrefs(); } /* 依用户自定义顺序排列(未在 order 里的按默认顺序追加) */ function toolboxSorted() { const items = toolboxItems(); const p = toolboxPrefs(); const idx = {}; p.order.forEach((id, i) => { idx[id] = i; }); return items.slice().sort((a, b) => { const ia = (idx[a.id] === undefined ? 1000 : idx[a.id]); const ib = (idx[b.id] === undefined ? 1000 : idx[b.id]); if (ia !== ib) return ia - ib; return items.indexOf(a) - items.indexOf(b); }); } function toolboxStartPanel() { const api = shModulePanel({ id: 'ocd-toolbox-panel', moduleId: 'toolbox', title: '工具箱维护', icon: '🛠️', color: '#0f9b8e', posKey: 'ocd_helper_toolbox_pos_v1', wKey: 'ocd_helper_toolbox_w_v1', width: 760, headHtml: '', barHtml: ' ' + ' ' + ' ' + ' ', }); const render = () => { const p = toolboxPrefs(); const all = toolboxSorted(); const kw = String((api.$('q') || {}).value || '').trim().toLowerCase(); const shown = all.filter(it => (p.showHidden || !p.hidden[it.id]) && (!kw || (it.cn + ' ' + it.desc).toLowerCase().indexOf(kw) !== -1)); const pinnedN = shown.filter(it => p.pinned[it.id]).length; const groups = []; if (p.view === 'flat') { /* 平铺:单一列表,置顶项用行内 ⭐ 标记(不再单独成组,避免折叠逻辑自相矛盾) */ groups.push({ k: 'all', cn: '🧰 全部工具(' + shown.length + ')', items: shown }); } else { if (pinnedN) groups.push({ k: 'star', cn: '⭐ 置顶(' + pinnedN + ')', items: shown.filter(it => p.pinned[it.id]) }); TOOLBOX_GROUPS.filter(g => g.k !== 'star').forEach((g) => { const list = shown.filter(it => it.group === g.k && !p.pinned[it.id]); if (list.length) groups.push({ k: g.k, cn: g.cn + '(' + list.length + ')', items: list }); }); } const hiddenN = Object.keys(p.hidden).filter(k => p.hidden[k]).length; api.setCards( shCard('可用工具', all.length, 'var(--fg-muted)', '含被隐藏的 ' + hiddenN + ' 个') + shCard('置顶', pinnedN, pinnedN ? '#f59e0b' : 'var(--fg-muted)', '常用工具可 ⭐ 置顶') + shCard('当前布局', p.view === 'flat' ? '平铺' : '分组', 'var(--fg-muted)', '工具栏「布局」可切换') ); const cnt = api.$('count'); if (cnt) cnt.textContent = '共 ' + all.length + ' 个工具 · 显示 ' + shown.length + (hiddenN && !p.showHidden ? (' · 已隐藏 ' + hiddenN) : ''); api.setBody(groups.length ? groups.map((g) => { const folded = !!p.collapsed[g.k]; const head = '
' + '' + (folded ? '▸' : '▾') + '' + '' + esc(g.cn) + '' + '' + '' + (folded ? '已折叠' : '点标题折叠') + '' + '
'; const rows = g.items.map((it) => { const isPin = !!p.pinned[it.id]; const isHid = !!p.hidden[it.id]; return '
' + '
' + '
' + esc(it.cn) + (isPin ? ' ⭐' : '') + (isHid ? ' (已隐藏)' : '') + '
' + '
' + esc(it.desc) + '
' + '
' + '
' + ' ' + ' ' + ' ' + ' ' + '
' + '
'; }).join(''); return head + (folded ? '' : rows); }).join('') : '
没有匹配的工具' + (kw ? '(换个关键词)' : '') + '
'); /* 行为绑定 */ const body = api.$('body'); body.querySelectorAll('.ocd-tb-ghead').forEach(el => el.addEventListener('click', () => { const pk = toolboxPrefs(); const g = el.dataset.g; const c = Object.assign({}, pk.collapsed); if (c[g]) delete c[g]; else c[g] = 1; toolboxSave({ collapsed: c }); render(); })); body.querySelectorAll('.ocd-tb-run').forEach(el => el.addEventListener('click', () => { const it = toolboxItems().find(x => x.id === el.dataset.id); if (!it) return; try { it.run(); } catch (e) { toastSafe('执行失败:' + ((e && e.message) || e), 'err'); } })); body.querySelectorAll('.ocd-tb-pin').forEach(el => el.addEventListener('click', (e) => { e.stopPropagation(); const pk = toolboxPrefs(); const pin = Object.assign({}, pk.pinned); if (pin[el.dataset.id]) delete pin[el.dataset.id]; else pin[el.dataset.id] = 1; toolboxSave({ pinned: pin }); render(); })); const move = (id, dir) => { const pk = toolboxPrefs(); const list = toolboxSorted().map(x => x.id); const i = list.indexOf(id); const j = i + dir; if (i < 0 || j < 0 || j >= list.length) return; const tmp = list[i]; list[i] = list[j]; list[j] = tmp; toolboxSave({ order: list }); render(); }; body.querySelectorAll('.ocd-tb-up').forEach(el => el.addEventListener('click', (e) => { e.stopPropagation(); move(el.dataset.id, -1); })); body.querySelectorAll('.ocd-tb-down').forEach(el => el.addEventListener('click', (e) => { e.stopPropagation(); move(el.dataset.id, 1); })); body.querySelectorAll('.ocd-tb-hide').forEach(el => el.addEventListener('click', (e) => { e.stopPropagation(); const pk = toolboxPrefs(); const hid = Object.assign({}, pk.hidden); if (hid[el.dataset.id]) delete hid[el.dataset.id]; else hid[el.dataset.id] = 1; toolboxSave({ hidden: hid }); render(); })); /* 顶部按钮只绑一次 */ const q = api.$('q'); if (q && !q.__bound) { q.__bound = true; q.addEventListener('input', render); } const vb = api.$('view'); if (vb && !vb.__bound) { vb.__bound = true; vb.addEventListener('click', () => { const pk = toolboxPrefs(); toolboxSave({ view: pk.view === 'flat' ? 'group' : 'flat' }); render(); }); } const hb = api.$('hidden'); if (hb && !hb.__bound) { hb.__bound = true; hb.addEventListener('click', () => { const pk = toolboxPrefs(); const v = !pk.showHidden; toolboxSave({ showHidden: v }); toastSafe(v ? '已显示被隐藏的工具' : '已隐藏不常用工具', 'info'); render(); }); } const rb = api.$('reset'); if (rb && !rb.__bound) { rb.__bound = true; rb.addEventListener('click', () => { toolboxReset(); toastSafe('已恢复默认布局(顺序 / 置顶 / 隐藏)', 'ok'); render(); }); } }; render(); toolboxStartPanel._render = render; return api; } function openToolboxUI() { if (isModuleEnabled('toolbox') === false) { toastSafe('「工具箱维护」模块已在设置中心关闭', 'warn'); return; } const api = toolboxStartPanel(); try { applyOpenAnim(document.getElementById('ocd-toolbox-panel')); } catch (e) {} ilog('[Toolbox] 打开工具箱维护模块'); return api; } function shUtilityModal(title, bodyHtml, opt) { const old = document.getElementById('ocd-util-pop'); if (old) old.remove(); const mask = document.createElement('div'); mask.id = 'ocd-util-pop'; mask.className = shGetThemeCls(); mask.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:' + ocdTopZ(20) + ';display:flex;align-items:center;justify-content:center;'; const win = document.createElement('div'); win.style.cssText = 'width:min(920px,95vw);max-height:90vh;display:flex;flex-direction:column;background:var(--bg,#242632);color:var(--fg,#e8eaf0);border:1px solid #4a69bd;border-radius:12px;padding:12px 14px;font:12px/1.7 -apple-system,"Microsoft YaHei",sans-serif;box-shadow:0 20px 60px rgba(0,0,0,.6);'; win.innerHTML = '
' + ' ' + esc(title) + '' + (opt && opt.copyText !== undefined ? ' ' : '') + ' ✕' + '
' + '
' + bodyHtml + '
'; mask.appendChild(win); document.body.appendChild(mask); const close = () => mask.remove(); win.querySelector('#ocd-util-close').addEventListener('click', close); mask.addEventListener('click', (e) => { if (e.target === mask) close(); }); const cp = win.querySelector('#ocd-util-copy'); if (cp) cp.addEventListener('click', () => copyText(String(opt.copyText || ''), () => toastSafe('📋 已复制', 'ok'))); return win; } function shShowChangelog() { const html = CHANGELOG.map(c => '
' + '
v' + esc(c.v) + ' ' + esc(c.d) + '
' + c.items.map(it => '
· ' + esc(it) + '
').join('') + '
').join(''); shUtilityModal('📜 更新记录(当前 v' + VERSION + ')', html); } function shShowLogPanel() { const lines = _ocdLogs.map(l => new Date(l.t).toTimeString().slice(0, 8) + ' [' + l.lv + '] ' + l.s).join('\n'); const text = lines || '(暂无日志)'; const win = shUtilityModal('📋 调试日志(' + _ocdLogs.length + ' / ' + OCD_LOG_MAX + ')', '' + '
' + ' ' + '
', { copyText: text }); const clr = win.querySelector('#ocd-log-clear'); if (clr) clr.addEventListener('click', () => { _ocdLogs.length = 0; win.remove(); toastSafe('日志已清空', 'ok'); }); } /* 单模块导出(9.2):把某一模块的数据单独导出 JSON,便于分享与迁移 */ const MODULE_EXPORT = { subs: { n: '智能订阅', keys: ['ocd_helper_subs_v1'] }, notify: { n: '通知中心', keys: ['ocd_helper_notify_v1'] }, seedboard: { n: '做种看板', keys: ['ocd_helper_seedboard_v1', 'ocd_helper_seed_w_v1', 'ocd_helper_seed_ui_v1'] }, ledger: { n: '求种账本', keys: ['ocd_helper_reqledger_v1', 'ocd_helper_sh_myposts_v1', 'ocd_helper_sh_myres_v1'] }, whitelist: { n: '查重白名单', local: ['ocd_helper_dupwhitelist_v1'] }, batch: { n: '批量队列', keys: ['ocd_helper_batch_v1'] }, adv: { n: '高级设置', keys: ['ocd_helper_advanced_v1'] }, shell: { n: '界面与主题', keys: ['ocd_helper_shell_v1', 'ocd_helper_fabdiy_v1', 'ocd_helper_theme_v1', 'ocd_helper_tweak_v1'] }, }; function shExportModule(kind) { try { ocdAudit('data', '导出模块配置', String(kind)); } catch (e) {} const m = MODULE_EXPORT[kind]; if (!m) { toastSafe('未知模块:' + kind, 'err'); return; } const out = { _app: 'OpenCD 工具箱', _kind: 'module:' + kind, _version: VERSION, _ts: Date.now(), keys: {}, local: {} }; let n = 0; (m.keys || []).forEach((k) => { const v = store.get(k, undefined); if (v !== undefined && v !== null) { out.keys[k] = v; n++; } }); (m.local || []).forEach((k) => { try { const raw = localStorage.getItem(k); if (raw) { out.local[k] = raw; n++; } } catch (e) {} }); if (!n) { toastSafe('「' + m.n + '」暂无可导出的数据', 'warn'); return null; } dlFile(JSON.stringify(out, null, 1), 'ocd_' + kind + '_' + dateTag() + '.json', 'application/json'); toastSafe('📤 已导出「' + m.n + '」' + n + ' 项', 'ok'); return out; } function shImportModule() { try { ocdAudit('data', '导入模块配置'); } catch (e) {} const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.json,application/json'; inp.addEventListener('change', async () => { const f = inp.files && inp.files[0]; if (!f) return; let data = null; try { data = JSON.parse(await f.text()); } catch (e) { toastSafe('❌ 文件解析失败', 'err'); return; } const keys = (data && data.keys) || {}; const local = (data && data.local) || {}; const okGo = await shConfirm({ title: '导入「' + String(data._kind || '模块') + '」数据?', html: '配置 ' + Object.keys(keys).length + ' 项 · 本地 ' + Object.keys(local).length + ' 项(同键覆盖)', buttons: [{ text: '取消', value: false }, { text: '导入', value: true, primary: true }], }); if (!okGo) return; let n = 0; for (const k of Object.keys(keys)) { try { store.set(k, keys[k]); n++; } catch (e) {} } for (const k of Object.keys(local)) { try { localStorage.setItem(k, local[k]); n++; } catch (e) {} } toastSafe('✅ 已导入 ' + n + ' 项,建议刷新页面', 'ok'); }); inp.click(); } /* 数据迁移(9.3):结构变更时在此登记,启动检查一次并自动升级 */ const SCHEMA_KEY = 'ocd_helper_schema_v1'; const MIGRATIONS = [ { v: 1, n: '初始化高级设置与通知规则默认值', run: () => { const a = advPrefs(); advSave({ nameRules: a.nameRules, kwRules: a.kwRules, seed: a.seed }); } }, { v: 2, n: '引入日志级别与缓存上限配置', run: () => { const a = advPrefs(); advSave({ log: a.log || { level: 'info' }, cacheCap: a.cacheCap || 300 }); } }, { v: 3, n: '单模块导出与数据版本登记', run: () => {} }, ]; function shRunMigrations() { const cur = Number(store.get(SCHEMA_KEY, 0)) || 0; const todo = MIGRATIONS.filter(m => m.v > cur); if (!todo.length) return 0; todo.forEach((m) => { try { m.run(); console.log('[Migrate] v' + m.v + ' ' + m.n); } catch (e) { console.warn('[Migrate] v' + m.v + ' 失败:', e && e.message); } }); const top = MIGRATIONS[MIGRATIONS.length - 1].v; store.set(SCHEMA_KEY, top); console.log('[Migrate] 数据版本 ' + cur + ' → ' + top); return todo.length; } function shExportConfig() { const out = { _app: 'OpenCD 工具箱', _version: VERSION, _ts: Date.now(), keys: {} }; let n = 0; for (const it of STORAGE_KEYS) { const v = store.get(it.k, undefined); if (v === undefined || v === null) continue; out.keys[it.k] = v; n++; } const _rd = advRedactMap(out.keys); out.keys = _rd.out; if (_rd.n) console.log('[Export] 已脱敏 ' + _rd.n + ' 项(可在高级设置关闭)'); if (!n) { toastSafe('没有可导出的配置', 'err'); return; } dlFile(JSON.stringify(out, null, 1), 'ocd_config_' + dateTag() + '.json', 'application/json'); toastSafe('💾 已导出 ' + n + ' 项配置', 'ok'); } function shImportConfig() { const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.json,application/json'; inp.addEventListener('change', () => { const f = inp.files && inp.files[0]; if (!f) return; const fr = new FileReader(); fr.onload = () => { try { const data = JSON.parse(String(fr.result || '{}')); const keys = data && data.keys; if (!keys || typeof keys !== 'object') { toastSafe('❌ 配置格式不正确(缺少 keys)', 'err'); return; } const names = Object.keys(keys); if (!confirm('将导入 ' + names.length + ' 项配置(同键覆盖,当前设置会被替换)。\n\n继续?')) return; let n = 0; for (const k of names) { store.set(k, keys[k]); n++; } toastSafe('✅ 已导入 ' + n + ' 项配置,建议刷新页面生效', 'ok'); } catch (e) { toastSafe('❌ 导入失败:' + (e.message || e), 'err'); } }; fr.readAsText(f); }); inp.click(); } function shShowStorageUsage() { const rows = []; const seen = {}; let total = 0; for (const it of STORAGE_KEYS) { const v = store.get(it.k, undefined); if (v === undefined || v === null) continue; let sz = 0; try { sz = JSON.stringify(v).length; } catch (e) { sz = String(v).length; } total += sz; seen[it.k] = 1; rows.push({ k: it.k, n: it.n, sz: sz }); } try { for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (!k || k.indexOf('ocd') !== 0 || seen[k]) continue; const v = localStorage.getItem(k) || ''; total += v.length; rows.push({ k: k, n: '(localStorage 直存)', sz: v.length }); } } catch (e) {} rows.sort((a, b) => b.sz - a.sz); const html = '
合计 ' + (total / 1024).toFixed(1) + ' KB(单键上限 2 MB,超限会拒写并提示)
' + '' + '' + rows.map(r => '' + '' + '' + '').join('') + '
键用途大小
' + esc(r.k) + '' + esc(r.n) + '' + (r.sz / 1024).toFixed(1) + ' KB
' + '
' + ' ' + '
'; const win = shUtilityModal('📊 存储占用(' + rows.length + ' 个键)', html); const clr = win.querySelector('#ocd-st-clear'); if (clr) clr.addEventListener('click', () => { if (!confirm('清理「种子详情 / 文件列表 / 频谱图」三类缓存?\n(不影响设置与任务队列)')) return; [TORRENT_INFO_CACHE_KEY, FILE_LIST_CACHE_KEY, SPECTROGRAM_CACHE_KEY].forEach(k => store.set(k, null)); toastSafe('🧹 缓存已清理', 'ok'); win.remove(); shShowStorageUsage(); }); } async function shSelectorSelfCheck() { const checks = []; const add = (group, name, ok, detail) => checks.push({ group: group, name: name, ok: !!ok, detail: detail || '' }); const q = (sel, root) => { try { return (root || document).querySelectorAll(sel).length; } catch (e) { return -1; } }; add('壳层', '#ocd-fab 悬浮球', q('#ocd-fab') > 0, q('#ocd-fab') + ' 个'); for (const [key, cfg] of Object.entries(FIELDS)) { if (/upload/i.test(location.pathname)) { const n = (cfg.selectors || []).reduce((acc, s) => acc + (q(s) > 0 ? 1 : 0), 0); add('发布页字段', cfg.cn + '(' + key + ')', n > 0, n + '/' + (cfg.selectors || []).length + ' 命中'); } } const probe = async (url, group, rules) => { let html = ''; try { html = await gmFetchText(url, { Referer: 'https://open.cd/' }); } catch (e) { add(group, '抓取 ' + url, false, (e && e.message) || String(e)); return null; } if (!html || html.length < 200) { add(group, '抓取 ' + url, false, '内容过短 ' + (html || '').length); return null; } const doc = new DOMParser().parseFromString(html, 'text/html'); for (const r of rules) { const n = q(r.sel, doc); add(group, r.name, n >= (r.min || 1), n + ' 命中(期望 ≥' + (r.min || 1) + ')'); } return doc; }; const listDoc = await probe('https://open.cd/viewrequests.php', '求种列表页', [ { sel: 'a[href*="viewrequests.php?action=view&id="]', name: '帖子标题链接' }, { sel: 'td', name: '单元格', min: 6 }, { sel: 'font[color="#ff0000"]', name: '悬赏数字(红色 font,追加按钮锚点)' }, { sel: 'a[href*="viewrequests.php?action=res"]', name: '应求链接', min: 0 }, ]); await probe('https://open.cd/torrents.php', '种子列表页', [ { sel: 'table.torrentname', name: 'torrentname 表' }, { sel: 'img[src*="share_rule_"]', name: '转载规则图标(预览按钮锚点)' }, { sel: 'a[href*="download.php?id="]', name: '下载链接' }, { sel: 'a[href*="bookmark("]', name: '收藏链接' }, ]); let detailUrl = ''; if (listDoc) { const a = listDoc.querySelector('a[href*="viewrequests.php?action=view&id="]'); if (a) { const idm = String(a.getAttribute('href') || '').match(/id=(\d+)/); if (idm) detailUrl = 'https://open.cd/viewrequests.php?action=view&id=' + idm[1]; } } if (detailUrl) { await probe(detailUrl, '求种详情页', [ { sel: 'td.rowhead', name: 'rowhead 信息行' }, { sel: '#startcomments', name: '评论区锚点(startcomments)', min: 0 }, { sel: 'table[id^="cid"]', name: '评论条目(cid 表)', min: 0 }, { sel: 'form[action*="comment.php"] textarea', name: '评论表单文本域', min: 0 }, ]); } const bad = checks.filter(c => !c.ok); const html = '
' + (bad.length ? '⚠️ ' + bad.length + ' / ' + checks.length + ' 项异常' : '✅ 全部 ' + checks.length + ' 项通过') + ' · 当前版本 v' + VERSION + ' · ' + new Date().toLocaleTimeString('zh-CN', { hour12: false }) + '
' + (['壳层', '发布页字段', '求种列表页', '种子列表页', '求种详情页'].map(g => { const list = checks.filter(c => c.group === g); if (!list.length) return ''; return '
' + esc(g) + '
' + list.map(c => '
' + (c.ok ? '✓' : '✗') + ' ' + esc(c.name) + ' · ' + esc(c.detail) + '
').join('') + '
'; }).join('')); const win = shUtilityModal('🔬 站点结构自检', html, { copyText: checks.map(c => (c.ok ? 'PASS' : 'FAIL') + '\t' + c.group + '\t' + c.name + '\t' + c.detail).join('\n'), }); return { checks: checks, bad: bad, win: win }; } /* ================================================================ * §62b 第三方模块生态(声明式模块 v1 · V9.76.0) * ---------------------------------------------------------------- * 设计要点(详见 设计草案\OpenCD 模块生态-探讨稿-v1.md): * 1. 全部规则来自一份数据 ECOMOD_SCHEMA → 同时派生 ①导入校验器 ②模块设置界面 ③AI 提示词 * 这样"提示词 / 校验器 / 界面"永不漂移 * 2. 模块是纯数据(不含任何代码):只能贡献 标签别名 / 标签→类别映射 / 备注模板 * 3. 一切写入都可撤销:卸载或停用会精确回滚该模块新增的条目(只加不减语义) * 4. 排序:官方模块默认置顶;第三方模块默认不允许排在官方之前(可在设置里解锁); * 执行顺序不依赖拖拽 * 5. 冲突:同一字段被两个模块写入 → 弹窗让用户选"谁优先"(写入 overrides 留痕) * 6. V9.76.5:①贡献可「门控」—— when = {"key","equals"} 引用模块自己的参数,条件不满足就不写进表, * 改参数即时生效 / 即时回收;②可「否决」单条(excluded,重装 / 重新启用都不再写回); * ③面板带一致性检查(模块宣称 vs 表里实际,可一键修复);④导入前先预览(dry-run + 覆盖 diff); * ⑤整包导出 / 导入(换机迁移);⑥示例模块库;⑦模块数与条目总量上限护栏 * ================================================================ */ const ECOMOD_KEY = 'ocd_helper_moduleeco_v1'; const ECOMOD_ORDER_KEY = 'ocd_helper_modorder_v1'; const ECOMOD_API = 1; const ECOMOD_ID_RE = /^[a-z0-9][a-z0-9._-]{2,63}$/; const ECOMOD_SETKEY_RE = /^[a-z0-9_]{1,32}$/; const ECOMOD_LIM = { id: 64, name: 40, ver: 20, author: 40, desc: 200, settings: 20, selectOpts: 30, label: 40, help: 200, tagAlias: 500, genreMap: 500, noteTemplate: 500, /* V9.76.11(用户 m10870):模块可声明「菜单里的一条跳转链接」(纯数据:名称 + https 网址)✓ */ links: 20, linkLabel: 24, linkUrl: 300, /* V9.76.5 护栏:装太多 / 条目太多会拖慢面板与匹配,也让冲突无从下手 ✓ */ mods: 20, packMods: 50, totalItems: 2000, whenEquals: 40 }; /* ---- 规范数据(单一事实来源:校验器 / 界面 / AI 提示词都从这里派生) ---- */ const ECOMOD_SCHEMA = { apiVersion: ECOMOD_API, top: [ { key: 'apiVersion', type: 'number', req: true, desc: '规范版本,固定为 1' }, { key: 'id', type: 'string', req: true, max: 64, re: '^[a-z0-9][a-z0-9._-]{2,63}$', desc: '全局唯一 id,小写字母/数字/点/下划线/连字符' }, { key: 'name', type: 'string', req: true, max: 40, desc: '显示名称(中文可)' }, { key: 'version', type: 'string', req: false, max: 20, re: '^[0-9]+\\.[0-9]+(\\.[0-9]+)?$', desc: '模块版本,如 1.0.0' }, { key: 'author', type: 'string', req: false, max: 40, desc: '作者署名' }, { key: 'desc', type: 'string', req: false, max: 200, desc: '一句话说明这个模块做什么' }, { key: 'settings', type: 'array', req: false, max: 20, desc: '模块自己的开关/参数(可选)' }, { key: 'contributes', type: 'object', req: false, desc: '模块贡献的数据(可选)' } ], settingTypes: ['switch', 'text', 'number', 'select', 'textarea'], contributes: [ { key: 'tagAlias', type: 'array', max: 500, item: '{ "from": "某种流派/风格的写法", "to": "标签 id(1~27)", "when": { "key": "参数键", "equals": true }(可选) }', desc: '把一种写法归到某个固定标签(写入你的别名表,卸载时移除)' }, { key: 'genreMap', type: 'array', max: 500, item: '{ "tag": "标签 id(1~27)", "site": "站点类别名(见清单)", "when": { "key": "参数键", "equals": "值" }(可选) }', desc: '把某个标签映射到站点"类别"下拉的选项' }, { key: 'noteTemplate', type: 'string', max: 500, item: '文本,可用占位符 {artist} {album} {year} {title} {platform}', desc: '任务备注模板(只生成文本,不自动写入)' }, { key: 'links', type: 'array', max: 20, item: '{ "label": "菜单里显示的名称", "url": "https://…(只允许 https 网址)", "when": { "key": "参数键", "equals": true }(可选) }', desc: '在悬浮球菜单的「🔗 模块链接(N)」组里加一个按钮,点了用新标签打开这个网址(宿主渲染,模块只声明名称与网址)' } ], whenDoc: 'when(可选,V9.76.5):给单条贡献加条件 —— 只有你声明的参数取到这个值时,这条才写进表。形状 {"key":"参数键","equals":true|"字符串"|数字};key 必须是本模块 settings 里声明过的键。条件不满足时这条只是"暂时不生效"(模块照旧装着,改一下参数立刻生效)。', forbidden: [ '不得包含任何 JS / HTML / CSS / 网络请求;网址只允许出现在 contributes.links 里(必须 https,宿主只把它当数据渲染成菜单按钮)', '不得出现 __ocd、GM_、eval、