diff --git a/manifest.config.ts b/manifest.config.ts index d556f45..67e15bb 100644 --- a/manifest.config.ts +++ b/manifest.config.ts @@ -22,7 +22,7 @@ export default defineManifest({ }, ], host_permissions: ['https://*/*', 'http://*/*'], - permissions: ['storage', 'tabs', 'scripting', 'activeTab', 'windows'], + permissions: ['storage', 'tabs', 'scripting', 'activeTab', 'windows', 'alarms'], background: { service_worker: 'src/background/index.ts', type: 'module', diff --git a/package.json b/package.json index 1bd3a0f..dffdaa1 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "axios": "^1.16.0", "pinia": "^3.0.4", "vue": "^3.5.32", "vue-router": "^5.0.6" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6062f7..08267c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + axios: + specifier: ^1.16.0 + version: 1.16.0 pinia: specifier: ^3.0.4 version: 3.0.4(typescript@5.9.3)(vue@3.5.33(typescript@5.9.3)) @@ -152,36 +155,42 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.6': resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.6': resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.6': resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.6': resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.6': resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [musl] '@parcel/watcher-win32-arm64@2.5.6': resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} @@ -240,36 +249,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} @@ -342,24 +357,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.4': resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.4': resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.4': resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.4': resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==} @@ -510,6 +529,12 @@ packages: resolution: {integrity: sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==} engines: {node: '>=20.19.0'} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.16.0: + resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==} + birpc@2.9.0: resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} @@ -520,6 +545,10 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -528,6 +557,10 @@ packages: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} @@ -560,6 +593,10 @@ packages: supports-color: optional: true + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -577,6 +614,10 @@ packages: domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + enhanced-resolve@5.21.0: resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==} engines: {node: '>=10.13.0'} @@ -589,9 +630,25 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@0.10.5: resolution: {integrity: sha512-+7IwY/kiGAacQfY+YBhKMvEmyAJnw5grTUgjG85Pe7vcUI/6b7pZjZG8nQ7+48YhzEAEqrEgD2dCz/JIK+AYvw==} + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} @@ -618,6 +675,19 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + fs-extra@10.1.0: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} @@ -627,13 +697,40 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + he@1.2.0: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true @@ -712,24 +809,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -758,6 +859,10 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -766,6 +871,14 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} @@ -834,6 +947,10 @@ packages: resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==} engines: {node: ^10 || ^12 || >=14} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -1472,6 +1589,16 @@ snapshots: '@babel/parser': 7.29.2 ast-kit: 2.2.0 + asynckit@0.4.0: {} + + axios@1.16.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.5 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + birpc@2.9.0: {} boolbase@1.0.0: {} @@ -1480,6 +1607,11 @@ snapshots: dependencies: fill-range: 7.1.1 + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -1488,6 +1620,10 @@ snapshots: dependencies: readdirp: 5.0.0 + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + confbox@0.1.8: {} confbox@0.2.4: {} @@ -1514,6 +1650,8 @@ snapshots: dependencies: ms: 2.1.3 + delayed-stream@1.0.0: {} + detect-libc@2.1.2: {} dom-serializer@2.0.0: @@ -1534,6 +1672,12 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + enhanced-resolve@5.21.0: dependencies: graceful-fs: 4.2.11 @@ -1543,8 +1687,23 @@ snapshots: entities@7.0.1: {} + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + es-module-lexer@0.10.5: {} + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + estree-walker@2.0.2: {} exsolve@1.0.8: {} @@ -1569,6 +1728,16 @@ snapshots: dependencies: to-regex-range: 5.0.1 + follow-redirects@1.16.0: {} + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.3 + mime-types: 2.1.35 + fs-extra@10.1.0: dependencies: graceful-fs: 4.2.11 @@ -1578,12 +1747,44 @@ snapshots: fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 + gopd@1.2.0: {} + graceful-fs@4.2.11: {} + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + he@1.2.0: {} hookable@5.5.3: {} @@ -1675,6 +1876,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -1682,6 +1885,12 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mitt@3.0.1: {} mlly@1.8.2: @@ -1748,6 +1957,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + proxy-from-env@2.1.0: {} + quansync@0.2.11: {} queue-microtask@1.2.3: {} diff --git a/src/api/me.ts b/src/api/me.ts new file mode 100644 index 0000000..be0ad3a --- /dev/null +++ b/src/api/me.ts @@ -0,0 +1,24 @@ +import request from "@/shared/request"; + +/** + * 获取扩展侧可用的配额/配置(扩展 token 鉴权) + * + * 中文备注: + * - API 文档:GET `/api/me/scan-quota` + * - 按你的要求:返回值/参数全部用 any,不定义复杂类型 + */ +export async function getScanQuotaApi(): Promise { + return await request.get("/me/scan-quota"); +} + +/** + * 扩展心跳(用于后端记录扩展活跃/跳过原因) + * + * 中文备注: + * - API 文档:POST `/api/extension/heartbeat` + * - 这里不强依赖心跳成功,失败不应影响爬取主流程 + */ +export async function extensionHeartbeatApi(body: any): Promise { + return await request.post("/extension/heartbeat", body); +} + diff --git a/src/api/scan.ts b/src/api/scan.ts new file mode 100644 index 0000000..9cc7642 --- /dev/null +++ b/src/api/scan.ts @@ -0,0 +1,12 @@ +import request from "@/shared/request"; + +/** + * 上传扫描数据(扩展侧直传后端)。 + * 中文备注:接口定义来自 `StoreAI-New/BACKEND_API_DOCUMENTATION.md` 的 POST `/api/ingest/scan`。 + * + * 注意:按你的要求:返回值、参数都用 any,不定义复杂类型。 + */ +export async function ingestScanApi(body: any): Promise { + return await request.post("/ingest/scan", body); +} + diff --git a/src/background/index.ts b/src/background/index.ts index c973d8a..0754805 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -1,58 +1,68 @@ -import {broadcastCrawlStorageChange, handleExternalConnect, handleExternalMessage} from './service/externalBridge'; -import {MessageAction} from "@/shared/message"; +import {broadcastCrawlStorageChange, handleExternalConnect, handleExternalMessage} from "./service/externalBridge"; +import type {MessageAction} from "@/shared/message"; import { cancelCrawl, dismissCrawl, pauseCrawlOnTabRemoved, pauseCrawlOnWindowRemoved, resumeCrawl, - startCrawl + startCrawl, } from "./task/crawlTask"; import {getCrawlTaskState, updateCrawlTaskState} from "./task/taskState"; +import {initAuthState} from "@/shared/store"; +import {initAutoScanScheduler, rescheduleAutoScanAlarms} from "@/background/service/autoScanScheduler"; + +// 中文备注:MV3 service worker 可能随时被系统回收;启动时先从 storage 恢复 authState 到内存,供 axios 同步读取。 +void initAuthState(); + +// 中文备注:初始化定时爬取调度器(注册 alarms 监听 + 启动时重建 alarms)。 +initAutoScanScheduler(); chrome.runtime.onInstalled.addListener(() => { + // 中文备注:安装/更新后尝试重建一次定时任务(如果已配对过,会从 storage 取到配置)。 + void rescheduleAutoScanAlarms("onInstalled"); }); chrome.runtime.onStartup.addListener(() => { + // 中文备注:浏览器启动时重建定时任务,避免 service worker 回收导致 alarms 丢失。 + void rescheduleAutoScanAlarms("onStartup"); }); /** - * 接受popup的指令 + * 接收 popup/content 的消息 */ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { - // 1. 统一提取 action 和 payload const action = message.action as MessageAction; const payload = message.payload; - // 2. 使用一个异步立即执行函数来处理逻辑 (async () => { try { let resultData: any = null; - // 3. 根据 action 分发任务 switch (action) { case "START_CRAWL": - resultData = await startCrawl(payload.platformId); + // 中文备注:手动触发 + resultData = await startCrawl(payload.platformId, "manual"); + // 中文备注:startCrawl 可能返回 `{ error: ... }`,这里转成 ok:false 给 popup 处理 + if (resultData && typeof resultData === "object" && "error" in resultData && !("id" in resultData)) { + throw new Error(String((resultData as any).error || "start_failed")); + } break; - case "GET_CRAWL_STATE": resultData = await getCrawlTaskState(); break; - case "GET_CRAWL_STATE_FOR_TAB": { - // 中文备注:只允许“爬取窗口 tab”拿到状态,避免其它页面误显示悬浮面板。 + // 中文备注:只允许“爬取窗口 tab”拿到状态,避免其它页面误显示 overlay。 const state = await getCrawlTaskState(); const senderTabId = sender.tab?.id; resultData = state && senderTabId && state.tabId === senderTabId ? state : null; break; } - case "CANCEL_CRAWL": - await cancelCrawl() + await cancelCrawl(); break; - case "CANCEL_AUTOCLOSE": { - // 中文备注:兼容旧悬浮面板按钮;当前版本默认完成后会清理并关闭窗口,这里仅做状态字段兼容。 + // 中文备注:兼容旧悬浮面板按钮;当前版本默认完成后会清理并关闭窗口。 const state = await getCrawlTaskState(); if (state) { await updateCrawlTaskState(state.id, (s) => ({...s, autocloseAt: null})); @@ -62,23 +72,20 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { } break; } - case "RESUME_CRAWL": resultData = await resumeCrawl(); break; - case "DISMISS_CRAWL": await dismissCrawl(); break; default: - throw new Error(`未知的后台指令: ${action}`); + throw new Error(`未知的后台指令 ${action}`); } sendResponse({ok: true, data: resultData}); - } catch (error: any) { console.error(`[Background] Action ${action} failed:`, error); - sendResponse({ok: false, error: error.message || 'Unknown error'}); + sendResponse({ok: false, error: error?.message || "Unknown error"}); } })(); @@ -86,44 +93,46 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { }); /** - * 监听窗口关闭: - * 用户手动关掉爬虫窗口时,自动触发任务清理逻辑(取消任务、停掉后台循环)。 + * 监听窗口/Tab 关闭: + * - 用户手动关掉爬取窗口时,不直接取消任务,而是自动暂停,等待用户点击继续。 */ chrome.windows.onRemoved.addListener((windowId) => { - // 中文备注:用户手动关掉爬取窗口时,不要直接取消任务;要切换为暂停,等用户在 popup 点“继续”恢复。 void pauseCrawlOnWindowRemoved(windowId); }); chrome.tabs.onRemoved.addListener((tabId) => { - // 中文备注:兜底处理:有些情况下只会触发 tab 移除事件,这里同样按“窗口被关闭”暂停。 void pauseCrawlOnTabRemoved(tabId); }); /** - * 接收外部网页消息: - * 允许在 manifest.json 中授权的官网域名(如 your-app.com)直接调起插件功能。 + * 接收外部网页消息(onMessageExternal) */ chrome.runtime.onMessageExternal.addListener((message, _sender, sendResponse) => { - void handleExternalMessage(message).then(sendResponse).catch((error: unknown) => { - sendResponse({ - ok: false, - error: error instanceof Error ? error.message : String(error), + void handleExternalMessage(message) + .then(sendResponse) + .catch((error: unknown) => { + sendResponse({ + ok: false, + error: error instanceof Error ? error.message : String(error), + }); }); - }); - return true; // 保持异步响应通道开启 + return true; // 中文备注:保持异步响应通道开启 }); /** - * 处理外部长连接: - * 用于官网页面与插件后台建立持久通信,实现实时的数据流同步。 + * 外部网页长连接(onConnectExternal) */ chrome.runtime.onConnectExternal.addListener(handleExternalConnect); /** - * 监听存储变化: - * 只要插件的本地数据(storage)发生改动,就立即广播给所有 UI(Popup/网页),实现进度条同步。 + * 监听 storage 变化并广播给 popup/overlay/外部网页 */ chrome.storage.onChanged.addListener((changes, areaName) => { broadcastCrawlStorageChange(changes, areaName); + + // 中文备注:配对成功/退出登录会写入 auth_state,这里同步重建定时爬取 alarms + if (areaName === "local" && changes["auth_state"]) { + void rescheduleAutoScanAlarms("auth_state_changed"); + } }); diff --git a/src/background/service/autoScanScheduler.ts b/src/background/service/autoScanScheduler.ts new file mode 100644 index 0000000..b00ee17 --- /dev/null +++ b/src/background/service/autoScanScheduler.ts @@ -0,0 +1,231 @@ +import {platformConfigs} from "@/config/platforms"; +import {getAuthState} from "@/shared/store"; +import {getCrawlTaskState} from "@/background/task/taskState"; +import {startCrawl} from "@/background/task/crawlTask"; +import {extensionHeartbeatApi, getScanQuotaApi} from "@/api/me"; + +const ALARM_MORNING = "DIANSHAN_AUTO_SCAN_MORNING"; +const ALARM_EVENING = "DIANSHAN_AUTO_SCAN_EVENING"; + +let schedulerInited = false; + +/** + * 初始化定时爬取调度器(只需要调用一次) + * + * 中文备注: + * - MV3 service worker 可能被回收,模块会被重新执行;用 schedulerInited 防止重复注册监听 + */ +export function initAutoScanScheduler() { + if (schedulerInited) return; + schedulerInited = true; + + chrome.alarms.onAlarm.addListener((alarm) => { + void handleAlarm(alarm.name); + }); + + // 中文备注:启动时也做一次重建,避免用户只开扩展不打开浏览器启动事件时遗漏 + void rescheduleAutoScanAlarms("init"); +} + +/** + * 重建定时爬取 alarms + * + * 中文备注: + * - 需求:需要“调用接口拿到配置好的定时时间”,这里会先调用 `/api/me/scan-quota` 做一次拉取(并允许后端在该接口里下发定时字段) + * - 如果接口没有下发定时字段,则回退到配对时存下来的 `authState.morningBriefHour/eveningRecapHour/timezone` + */ +export async function rescheduleAutoScanAlarms(reason: string) { + const auth = getAuthState(); + if (!auth?.token) { + // 中文备注:未配对/未登录时,直接清掉所有自动爬取 alarm + await chrome.alarms.clear(ALARM_MORNING); + await chrome.alarms.clear(ALARM_EVENING); + return; + } + + // 中文备注:按需求“调用接口拿配置”,同时也能借机触发 ext_outdated/unauthorized 等错误暴露 + let quota: any = null; + try { + quota = await getScanQuotaApi(); + } catch { + quota = null; + } + + // 中文备注:配置字段优先级:接口下发 > authState 里缓存 + const timezone = quota?.timezone || auth?.timezone || "Asia/Shanghai"; + const morningHour = normaliseHour(quota?.morningBriefHour ?? quota?.morning_brief_hour ?? auth?.morningBriefHour); + const eveningHour = normaliseHour(quota?.eveningRecapHour ?? quota?.evening_recap_hour ?? auth?.eveningRecapHour); + + // 中文备注:分别创建两个一次性 alarm;触发后会在 handleAlarm 里再次续约下一次 + if (morningHour === null) { + await chrome.alarms.clear(ALARM_MORNING); + } else { + const when = getNextRunAtMs(timezone, morningHour); + chrome.alarms.create(ALARM_MORNING, {when}); + } + + if (eveningHour === null) { + await chrome.alarms.clear(ALARM_EVENING); + } else { + const when = getNextRunAtMs(timezone, eveningHour); + chrome.alarms.create(ALARM_EVENING, {when}); + } + + // 中文备注:心跳可选,用于后端观测扩展侧是否成功配置定时 + void extensionHeartbeatApi({ + skip_reason: null, + ext_version: chrome.runtime.getManifest().version, + tick_at: new Date().toISOString(), + reason, + }).catch(() => undefined); +} + +async function handleAlarm(name: string) { + if (name !== ALARM_MORNING && name !== ALARM_EVENING) return; + + const auth = getAuthState(); + if (!auth?.token) { + // 中文备注:没有 token 直接忽略,并清理 alarm + await chrome.alarms.clear(name); + return; + } + + // 中文备注:如果当前已有任务(running/paused),本次自动扫描跳过,避免并发打开多个窗口 + const state = await getCrawlTaskState(); + if (state && ["running", "paused"].includes(state.status)) { + void extensionHeartbeatApi({ + skip_reason: "task_in_progress", + ext_version: chrome.runtime.getManifest().version, + tick_at: new Date().toISOString(), + }).catch(() => undefined); + await rescheduleAutoScanAlarms("skip_task_in_progress"); + return; + } + + // 中文备注:scheduled 扫描默认使用第一个平台(当前仅 Shopee) + const platformId = platformConfigs[0]?.id; + if (!platformId) { + void extensionHeartbeatApi({ + skip_reason: "no_platform_config", + ext_version: chrome.runtime.getManifest().version, + tick_at: new Date().toISOString(), + }).catch(() => undefined); + await rescheduleAutoScanAlarms("no_platform_config"); + return; + } + + // 中文备注:触发 scheduled 扫描 + const started: any = await startCrawl(platformId, "scheduled"); + if (started && typeof started === "object" && "error" in started && !("id" in started)) { + void extensionHeartbeatApi({ + skip_reason: String((started as any).error || "start_failed"), + ext_version: chrome.runtime.getManifest().version, + tick_at: new Date().toISOString(), + }).catch(() => undefined); + await rescheduleAutoScanAlarms("start_failed"); + return; + } + + // 中文备注:触发后立刻续约下一次(一次性 alarm) + await rescheduleAutoScanAlarms("after_alarm_fired"); +} + +function normaliseHour(v: any): number | null { + if (v === null || v === undefined || v === "") return null; + const n = Number(v); + if (!Number.isFinite(n)) return null; + if (n < 0 || n > 23) return null; + return Math.floor(n); +} + +/** + * 计算“品牌时区”下,下一个整点触发时间(返回 UTC ms) + * + * 中文备注: + * - 这里不用第三方库,靠 Intl + 时区偏移换算 + * - 采用“一次性 alarm + 触发后续约”的方式,能更好适配 DST/时区变化 + */ +function getNextRunAtMs(timezone: string, targetHour: number) { + const now = new Date(); + const nowParts = getZonedParts(now, timezone); + + const nowTotalSec = nowParts.hour * 3600 + nowParts.minute * 60 + nowParts.second; + const targetTotalSec = targetHour * 3600; + + // 中文备注:如果当前时区时间已经过了目标小时,则取下一天 + const addDays = nowTotalSec >= targetTotalSec ? 1 : 0; + const next = addDays === 0 + ? {year: nowParts.year, month: nowParts.month, day: nowParts.day} + : addDaysToYmd(nowParts.year, nowParts.month, nowParts.day, addDays); + + return zonedTimeToUtcMs(timezone, next.year, next.month, next.day, targetHour, 0, 0); +} + +function getZonedParts(date: Date, timeZone: string) { + const dtf = new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); + + const parts = dtf.formatToParts(date); + const out: any = {}; + for (const p of parts) { + if (p.type === "year" || p.type === "month" || p.type === "day" || p.type === "hour" || p.type === "minute" || p.type === "second") { + out[p.type] = Number(p.value); + } + } + + return { + year: out.year, + month: out.month, + day: out.day, + hour: out.hour, + minute: out.minute, + second: out.second, + }; +} + +function addDaysToYmd(year: number, month: number, day: number, addDays: number) { + const d = new Date(Date.UTC(year, month - 1, day + addDays, 0, 0, 0)); + return {year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate()}; +} + +function zonedTimeToUtcMs(timeZone: string, year: number, month: number, day: number, hour: number, minute: number, second: number) { + // 中文备注:先用 UTC 作为“猜测”,再根据该时刻时区偏移修正到正确 UTC + const guessUtcMs = Date.UTC(year, month - 1, day, hour, minute, second); + let offsetMin = getTimeZoneOffsetMinutes(timeZone, new Date(guessUtcMs)); + let adjusted = guessUtcMs - offsetMin * 60 * 1000; + + // 中文备注:DST 场景下,第一次修正后偏移可能变化,再算一次确保收敛 + offsetMin = getTimeZoneOffsetMinutes(timeZone, new Date(adjusted)); + adjusted = guessUtcMs - offsetMin * 60 * 1000; + return adjusted; +} + +function getTimeZoneOffsetMinutes(timeZone: string, date: Date) { + const dtf = new Intl.DateTimeFormat("en-US", { + timeZone, + timeZoneName: "shortOffset", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + + const parts = dtf.formatToParts(date); + const tzName = parts.find((p) => p.type === "timeZoneName")?.value || "GMT+0"; + + // 兼容:GMT+8 / GMT+08:00 / GMT-5 + const m = tzName.match(/GMT([+-])(\d{1,2})(?::?(\d{2}))?/); + if (!m) return 0; + + const sign = m[1] === "-" ? -1 : 1; + const hh = Number(m[2] || 0); + const mm = Number(m[3] || 0); + return sign * (hh * 60 + mm); +} diff --git a/src/background/service/externalBridge.ts b/src/background/service/externalBridge.ts index 6de3034..ad0d669 100644 --- a/src/background/service/externalBridge.ts +++ b/src/background/service/externalBridge.ts @@ -2,6 +2,7 @@ import {platformConfigs} from '@/config/platforms'; import type {CrawlTaskState} from '@/types'; import {getCrawlTaskState} from "@/background/task/taskState"; import {cancelCrawl, startCrawl} from "@/background/task/crawlTask"; +import {setAuthState} from "@/shared/store"; /** 存储任务状态的 Key,需与存储层保持一致 */ const CRAWL_TASK_STORAGE_KEY = 'crawlTaskState'; @@ -11,6 +12,8 @@ const EXTERNAL_PORT_NAME = 'DIANSHAN_CRAWL'; /** 定义外部(网页侧)可以发起的动作类型 */ type ExternalAction = | 'DIANSHAN_PING' // 探测插件是否安装/活跃 + | 'DIANSHAN_AUTH_CHECK' // 网页侧检查扩展是否已配对 + | 'DIANSHAN_SSO_HANDOFF' // 网页侧回传 extension token(配对结果) | 'DIANSHAN_START_CRAWL' // 从网页发起爬取 | 'DIANSHAN_GET_CRAWL_STATE' // 获取当前进度 | 'DIANSHAN_CANCEL_CRAWL' // 取消爬取 @@ -22,6 +25,10 @@ interface ExternalMessage { action?: ExternalAction; payload?: { platformId?: string; + // 中文备注:网页侧配对后回传的登录信息(全部用 any,按要求不定义复杂类型) + authState?: any; + // 中文备注:兼容网页侧老参数(例如 ingestContext),扩展侧目前不使用,但保留以免网页传多字段报错。 + [key: string]: any; }; } @@ -69,6 +76,38 @@ export async function handleExternalMessage(message: ExternalMessage): Promise> { // 调用核心爬取逻辑 - const response: any = await startCrawl(platformId ?? platformConfigs[0]?.id ?? ''); + // 中文备注:网页侧触发属于手动触发(manual) + const response: any = await startCrawl(platformId ?? platformConfigs[0]?.id ?? '', "manual"); // 检查返回的是错误对象还是成功的任务状态 const isError = response && typeof response === 'object' && 'error' in response && !('id' in response); @@ -245,4 +285,4 @@ function postToExternalPort(port: chrome.runtime.Port, message: ExternalResponse /** 类型守卫:判断对象是否为有效的任务状态 */ function isCrawlTaskState(value: unknown): value is CrawlTaskState { return typeof value === 'object' && value !== null && 'id' in value && 'steps' in value; -} \ No newline at end of file +} diff --git a/src/background/service/scan_payload.ts b/src/background/service/scan_payload.ts new file mode 100644 index 0000000..f17c80e --- /dev/null +++ b/src/background/service/scan_payload.ts @@ -0,0 +1,214 @@ +/** + * 把“爬取的 raw 结果”组装成后端 ingest/scan 需要的 payload。 + * 中文备注:逻辑参考 `my-app/src/utils/extension/scan_payload.ts`,但这里运行在扩展侧,直接上传到后端。 + * + * 按你的要求:不定义一堆类型,参数/返回统一用 any。 + */ + +export function buildIngestScanBody(state: any, rawResult: any, auth: any, trigger: any): any { + const brandId = auth?.brandId; + const storeId = auth?.storeId; + + if (!brandId) { + throw new Error("缺少 brandId,请先在网页端完成品牌配置并重新配对扩展。"); + } + if (!storeId) { + throw new Error("缺少 storeId,请先在网页端完成店铺配置并重新配对扩展。"); + } + + const scannedAt = new Date().toISOString(); + + const databoard = readStepResult(rawResult, "databoard"); + const adscenter = readStepResult(rawResult, "adscenter"); + const reviews = readStepResult(rawResult, "message"); + const accountHealth = readStepResult(rawResult, "accounthealth"); + const business = readObject(databoard["商业分析"]); + + return { + brandId, + storeId, + scannedAt, + extractorStatus: getExtractorStatus(state), + extractorErrors: getExtractorErrors(state), + trigger: trigger || "manual", + platformAccountId: auth?.platformAccountId ?? null, + fieldMeta: {}, + payload: { + store_id: storeId, + scanned_at: scannedAt, + today: buildTodayPayload(business), + recent_3h: [], + skus: [], + ads: buildAdsPayload(adscenter), + reviews: buildReviewsPayload(reviews, scannedAt), + competitors: [], + review_summary: null, + product_aggregates: null, + traffic_sources: [], + shop_health: buildShopHealthPayload(accountHealth), + }, + }; +} + +function buildTodayPayload(business: any) { + const sales = readMetric(business["销售"]); + const visitors = readMetric(business["访客数"]); + const productClicks = readMetric(business["Product Clicks"]); + const orders = readMetric(business["订单"]); + const conversionRate = readMetric(business["Order Conversion Rate"]); + + return { + orders: parseInteger(orders.value), + gmv_cents: parseMoneyCents(sales.value), + cancel_rate: 0, + return_rate: 0, + gmv_delta_yesterday_pct: parsePercent(sales.change), + gmv_net_cents: null, + conversion_rate: parsePercent(conversionRate.value), + visitors: parseNullableInteger(visitors.value), + product_clicks: parseNullableInteger(productClicks.value), + aov_cents: null, + orders_delta_yesterday_pct: parsePercent(orders.change), + conversion_rate_delta_yesterday_pct: parsePercent(conversionRate.change), + visitors_delta_yesterday_pct: parsePercent(visitors.change), + product_clicks_delta_yesterday_pct: parsePercent(productClicks.change), + aov_delta_yesterday_pct: null, + }; +} + +function buildAdsPayload(adscenter: any) { + const rows = readArray(adscenter["进行中广告列表"]); + + return rows.map((item: any, index: number) => { + const row = readObject(item); + const info = readObject(row["广告信息"]); + const name = stringify(info["广告名称"]) || `campaign-${index + 1}`; + + return { + campaign_id: name, + campaign_name: name, + type: stringify(info["广告类型"]) || "unknown", + state: "ongoing", + spend_cents: parseMoneyCents(row["花费"]), + clicks: 0, + orders: 0, + revenue_cents: parseMoneyCents(row["销售额"]), + impressions: 0, + roas: parseNumber(row["广告支出回报率"]) ?? 0, + target_roas: parseNumber(row["目标ROAS"]), + daily_budget_cents: parseMoneyCents(row["每日预算"]), + keywords: [], + }; + }); +} + +function buildReviewsPayload(reviews: any, scannedAt: string) { + const rows = readArray(reviews["低星评论"]); + + return rows.map((item: any, index: number) => { + const row = readObject(item); + const orderId = stringify(row["订单编号"]); + + return { + review_id: orderId || `review-${index + 1}`, + sku_id: null, + rating: 1, + text: stringify(row["评价内容"]) || stringify(row["商品名称"]) || "", + replied: false, + created_at: scannedAt, + }; + }); +} + +function buildShopHealthPayload(accountHealth: any) { + const healthRows = readArray(accountHealth["健康状态"]); + if (healthRows.length === 0) return null; + + return { + rating_overall: null, + penalty_points: null, + metrics: {}, + }; +} + +function getExtractorStatus(state: any): any { + if (!state || state.status === "failed" || state.status === "canceled") { + return "failed"; + } + + const hasFailedStep = Array.isArray(state.steps) && state.steps.some((step: any) => step.status === "failed"); + const hasSuccessStep = Array.isArray(state.steps) && state.steps.some((step: any) => step.status === "success"); + + if (hasFailedStep && hasSuccessStep) return "partial"; + if (hasFailedStep) return "failed"; + return "ok"; +} + +function getExtractorErrors(state: any) { + if (!state) return ["missing extension crawl state"]; + + const list = Array.isArray(state.steps) ? state.steps : []; + return list + .filter((step: any) => step.status === "failed" || step.message) + .map((step: any) => `${step.name}: ${step.message || step.status}`) + .slice(0, 50); +} + +function readStepResult(result: any, key: string) { + return readObject(readObject(result?.[key])["result"]); +} + +function readMetric(value: any) { + const item = readObject(value); + return {value: item.value, change: item.change}; +} + +function readObject(value: any) { + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; +} + +function readArray(value: any) { + return Array.isArray(value) ? value : []; +} + +function stringify(value: any) { + return typeof value === "string" ? value.trim() : value == null ? "" : String(value).trim(); +} + +function parseInteger(value: any) { + return Math.max(0, Math.round(parseNumber(value) ?? 0)); +} + +function parseNullableInteger(value: any) { + const parsed = parseNumber(value); + return parsed == null ? null : Math.max(0, Math.round(parsed)); +} + +function parseMoneyCents(value: any) { + const parsed = parseNumber(value); + return Math.max(0, Math.round((parsed ?? 0) * 100)); +} + +function parsePercent(value: any) { + const text = stringify(value); + if (!text) return null; + + const parsed = parseNumber(text); + if (parsed == null) return null; + + return text.includes("%") ? parsed / 100 : parsed; +} + +function parseNumber(value: any) { + const text = stringify(value) + .replace(/RM/gi, "") + .replace(/,/g, "") + .replace(/%/g, "") + .replace(/[^\d.-]/g, ""); + + if (!text || text === "-" || text === "." || text === "-.") return null; + + const parsed = Number(text); + return Number.isFinite(parsed) ? parsed : null; +} + diff --git a/src/background/task/crawlTask.ts b/src/background/task/crawlTask.ts index 97c4def..200ed95 100644 --- a/src/background/task/crawlTask.ts +++ b/src/background/task/crawlTask.ts @@ -1,25 +1,53 @@ import {getPlatformById} from "@/config/platforms"; -import {CrawlTaskState, PlatformStepConfig} from "@/types"; +import type {CrawlTaskState, PlatformStepConfig} from "@/types"; import {openSingleTabWindow, scrapeStepInContent, sleep, waitForTabLoaded} from "@/background/task/helper"; import {clearCrawlTaskState, getCrawlTaskState, setCrawlTaskState, updateCrawlTaskState} from "./taskState"; import {sendTabMessage} from "@/shared/tab"; - +import {getAuthState} from "@/shared/store"; +import {ingestScanApi} from "@/api/scan"; +import {buildIngestScanBody} from "@/background/service/scan_payload"; +import {getScanQuotaApi} from "@/api/me"; const activeCrawlControllers = new Map(); /** - * 创建新的爬取任务,打开目标平台窗口,并把初始时间轴状态写入 storage。 - * @param platformId 平台id + * 创建新的爬取任务:打开目标平台窗口,并把初始状态写入 storage + * @param platformId 平台 id + * @param trigger 触发来源:manual/scheduled(按 API 文档) */ -export async function startCrawl(platformId: string): Promise { - const platform = getPlatformById(platformId); - if (!platform) { - return {error: '平台配置不存在'}; +export async function startCrawl(platformId: string, trigger: any = "manual"): Promise { + // 中文备注:没有 extension token 就不允许开始爬取,否则最后上传会失败(也不符合产品预期) + const auth = getAuthState(); + if (!auth?.token) { + return {error: "not_authed"}; } - //打开窗口 - let windowInfo = await openSingleTabWindow(platform.steps[0].url) - //初始化数据 + // 中文备注:开始爬取前先向后端确认订阅/权限(扩展 token 可访问的接口:GET /api/me/scan-quota) + // - manual:必须 allowed=true 才允许开始 + // - scheduled:允许忽略“手动次数上限”等限制,但订阅过期(subscription_required)必须阻断 + try { + const quota: any = await getScanQuotaApi(); + const allowed = quota?.allowed === true; + const reason = String(quota?.reason || ""); + if (String(trigger) === "manual" && !allowed) { + return {error: reason || quota?.message || "scan_not_allowed"}; + } + if (String(trigger) === "scheduled" && reason === "subscription_required") { + return {error: "subscription_required"}; + } + } catch (e: any) { + // 中文备注:如果接口失败,为了避免“订阅已过期但仍可扫描”的漏洞,这里选择阻断启动 + return {error: e?.response?.data?.message || e?.message || "quota_check_failed"}; + } + + const platform = getPlatformById(platformId); + if (!platform) { + return {error: "platform_not_found"}; + } + + // 打开爬取窗口(取第一步 URL 作为入口) + const windowInfo = await openSingleTabWindow(platform.steps[0].url); + const startedAt = Date.now(); const nextState: CrawlTaskState = { id: `${platform.id}-${startedAt}`, @@ -28,37 +56,33 @@ export async function startCrawl(platformId: string): Promise { platformId: platform.id, platformName: platform.name, startedAt, - status: 'running', + status: "running", currentStepIndex: 0, - steps: platform.steps.map((item, index) => { - return { - name: item.name, - uniqueKey: item.uniqueKey, - status: index === 0 ? 'running' : 'pending', - } - }) + steps: platform.steps.map((item, index) => ({ + name: item.name, + uniqueKey: item.uniqueKey, + status: index === 0 ? "running" : "pending", + })), }; + // 中文备注:CrawlTaskState 没有 trigger 字段(保持类型最小改动),这里用 any 写入,后续上传时会读取。 + (nextState as any).trigger = trigger; + await setCrawlTaskState(nextState); - //写入任务,用于取消 + // 写入任务执行器 controller(用于取消/窗口关闭暂停) const controller = new AbortController(); activeCrawlControllers.set(nextState.id, controller); - //启动 + void runCrawlSteps(nextState.id, nextState.tabId!, platform.steps, controller.signal).finally(() => { activeCrawlControllers.delete(nextState.id); }); - //自动开始爬取 - return nextState + + return nextState; } /** - * 按平台 steps 顺序执行页面跳转、DOM 等待、字段抓取和进度更新。 - * @param steps 平台步骤配置 - * @param signal 中断信号 - */ -/** - * 执行器 + * 按平台 steps 顺序执行:页面跳转、DOM 等待、字段抓取、进度更新 */ async function runCrawlSteps(taskId: string, tabId: number, steps: PlatformStepConfig[], signal: AbortSignal, startIndex = 0) { // 中文备注:startIndex 用于“继续/恢复”场景,从上次没爬完的步骤开始跑。 @@ -66,49 +90,47 @@ async function runCrawlSteps(taskId: string, tabId: number, steps: PlatformStepC const step = steps[i]; let shouldRetryStep = true; - // 【修改 2】进入新步骤,立刻更新状态机里的索引和步骤状态 - await updateCrawlTaskState(taskId, s => ({ + // 进入新 step:更新 currentStepIndex + step 状态 + await updateCrawlTaskState(taskId, (s) => ({ ...s, currentStepIndex: i, steps: s.steps.map((stepItem, idx) => ({ ...stepItem, - status: idx === i ? 'running' : stepItem.status - })) + status: idx === i ? "running" : stepItem.status, + })), })); while (shouldRetryStep) { if (signal.aborted) return; - // 1. 等待网页加载 + // 1) 跳转到目标 URL 并等待加载完成 await chrome.tabs.update(tabId, {url: step.url, active: true}); const loaded = await waitForTabLoaded(tabId, signal); if (!loaded) return; - // 2. 检测撞盾/抓取 + // 2) 交给 content script 抓取 const res: any = await scrapeStepInContent(tabId, step, signal); if (signal.aborted) return; - // 3. 处理中断(验证码等) + // 3) 处理“需要人工介入”的中断(登录/验证码/页面不存在等) if (res.interrupt) { - await updateCrawlTaskState(taskId, s => ({...s, status: 'paused', pause: res.interrupt})); + await updateCrawlTaskState(taskId, (s) => ({...s, status: "paused", pause: res.interrupt})); - // 死等恢复 - while ((await getCrawlTaskState())?.status === 'paused') { + // 死等恢复:直到 UI 把状态从 paused 改回 running + while ((await getCrawlTaskState())?.status === "paused") { if (signal.aborted) return; if (!(await sleep(1000, signal))) return; } - continue; // 恢复后重新触发 while 循环(重刷页面) + continue; } - // 4. 处理结果 + // 4) 处理抓取结果 if (res.ok) { - await updateCrawlTaskState(taskId, s => ({ + await updateCrawlTaskState(taskId, (s) => ({ ...s, - steps: s.steps.map((item, idx) => - idx === i ? {...item, status: 'success', result: res.data} : item - ) + steps: s.steps.map((item, idx) => (idx === i ? {...item, status: "success", result: res.data} : item)), })); - shouldRetryStep = false; // 退出 while,准备进下一个 for 循环步骤 + shouldRetryStep = false; } else { // 抓取失败重试 if (!(await sleep(2000, signal))) return; @@ -116,52 +138,64 @@ async function runCrawlSteps(taskId: string, tabId: number, steps: PlatformStepC } } - // 【修改 3】全部步骤完成,标记任务结束 - await updateCrawlTaskState(taskId, s => ({...s, status: 'completed'})); - - // 中文备注:全部爬取完成后,需要把数据发送给网页,然后清空本次任务记录数据、关掉爬取窗口。 - // 这里由 background 统一做“完成后收尾”,避免 UI 侧各自处理导致状态不同步。 + // 全部步骤完成:标记 completed 并统一收尾(上传 -> 清理 -> 关窗) + await updateCrawlTaskState(taskId, (s) => ({...s, status: "completed"})); await finalizeCompletedTask(taskId, signal); } - /** - * 取消当前爬取任务,并尝试关闭正在爬取的平台窗口。 + * 取消当前爬取任务:停止执行器、清空记录、关闭爬取窗口 */ export async function cancelCrawl() { const state = await getCrawlTaskState(); + if (!state) return; - if (!state) return - - // 立即触发 Abort 信号,让脚本自动停止 const controller = activeCrawlControllers.get(state.id); if (controller) { controller.abort(); activeCrawlControllers.delete(state.id); } - //清楚缓存 await clearCrawlTaskState(); - //关闭窗口 if (state.windowId) { - chrome.windows.remove(state.windowId).catch(() => { - }); + chrome.windows.remove(state.windowId).catch(() => undefined); } - } /** - * 当爬取窗口被用户手动关闭时触发:把任务标记为暂停,并中止当前的执行器。 - * 中文备注:这里“暂停”不是取消,任务进度(steps/result/currentStepIndex)会保留,供后续“继续”恢复。 + * 关闭/忽略当前任务的 UI 提示:只清空状态并尝试关闭窗口(不额外走取消流程) + */ +export async function dismissCrawl(): Promise { + const state = await getCrawlTaskState(); + if (!state) { + await clearCrawlTaskState(); + return; + } + + // 中文备注:如果还有执行器在跑,dismiss 等同取消,避免后台继续执行。 + const controller = activeCrawlControllers.get(state.id); + if (controller) { + controller.abort(); + activeCrawlControllers.delete(state.id); + } + + await clearCrawlTaskState(); + + if (state.windowId) { + chrome.windows.remove(state.windowId).catch(() => undefined); + } +} + +/** + * 用户手动关闭爬取窗口:自动暂停任务,并中止当前执行器 */ export async function pauseCrawlOnWindowRemoved(windowId: number): Promise { const state = await getCrawlTaskState(); if (!state) return; - if (state.status !== 'running') return; + if (state.status !== "running") return; if (state.windowId !== windowId) return; - // 中文备注:窗口被关掉后继续跑会频繁报 tab 不存在;这里直接 abort 当前 controller,等待用户点击“继续”后重启。 const controller = activeCrawlControllers.get(state.id); if (controller) { controller.abort(); @@ -170,28 +204,26 @@ export async function pauseCrawlOnWindowRemoved(windowId: number): Promise await updateCrawlTaskState(state.id, (s) => ({ ...s, - status: 'paused', + status: "paused", pause: { - reason: 'window_closed', - message: '检测到爬取窗口被关闭。点击“继续”后将重新打开窗口,并从上次进度继续爬取。', + reason: "window_closed", + message: "检测到爬取窗口被关闭。点击“继续”后将重新打开窗口,并从上次进度继续爬取。", }, - // 中文备注:窗口/tab 已经不存在,置空避免 UI 侧再尝试聚焦旧窗口。 + // 中文备注:窗口/tab 已不存在,置空避免 UI 侧再尝试聚焦旧窗口。 windowId: undefined, tabId: undefined, })); } /** - * 当爬取 tab 被关闭时触发:同样按“窗口被关闭”处理。 - * 中文备注:有些情况下只会触发 tabs.onRemoved,这里单独兜底。 + * 爬取 tab 被关闭:同样按“窗口被关闭”暂停处理(兜底) */ export async function pauseCrawlOnTabRemoved(tabId: number): Promise { const state = await getCrawlTaskState(); if (!state) return; - if (state.status !== 'running') return; + if (state.status !== "running") return; if (state.tabId !== tabId) return; - // 直接复用 window 关闭的暂停逻辑(windowId 可能为空,但不影响暂停) const controller = activeCrawlControllers.get(state.id); if (controller) { controller.abort(); @@ -200,10 +232,10 @@ export async function pauseCrawlOnTabRemoved(tabId: number): Promise { await updateCrawlTaskState(state.id, (s) => ({ ...s, - status: 'paused', + status: "paused", pause: { - reason: 'window_closed', - message: '检测到爬取页面被关闭。点击“继续”后将重新打开窗口,并从上次进度继续爬取。', + reason: "window_closed", + message: "检测到爬取页面被关闭。点击“继续”后将重新打开窗口,并从上次进度继续爬取。", }, windowId: undefined, tabId: undefined, @@ -211,38 +243,32 @@ export async function pauseCrawlOnTabRemoved(tabId: number): Promise { } /** - * 继续/恢复暂停的任务。 - * 中文备注: - * - 如果是登录/验证码导致的暂停:只需要把状态从 paused 切回 running,让原来的执行器继续跑(不重启)。 - * - 如果是窗口被关闭导致的暂停:需要重新打开窗口,并从上次没完成的步骤开始重新跑。 + * 继续/恢复暂停任务 */ export async function resumeCrawl(): Promise { const state = await getCrawlTaskState(); if (!state) return null; - if (state.status !== 'paused') { + if (state.status !== "paused") { return state; } - // 1) 登录/验证码等中断:窗口仍存在时,直接恢复即可 - if (state.pause?.reason !== 'window_closed' && state.windowId && state.tabId) { - await updateCrawlTaskState(state.id, (s) => ({...s, status: 'running', pause: undefined})); + // 1) 登录/验证码等中断:窗口还在,直接恢复 running 即可 + if (state.pause?.reason !== "window_closed" && state.windowId && state.tabId) { + await updateCrawlTaskState(state.id, (s) => ({...s, status: "running", pause: undefined})); return await getCrawlTaskState(); } - // 2) 窗口关闭导致的暂停:重新打开窗口,并从上次进度继续 + // 2) 窗口被关闭导致的暂停:需要重开窗口并从未完成 step 继续 const platform = getPlatformById(state.platformId); - if (!platform) { - // 中文备注:平台配置找不到时只能保持暂停态 - return state; - } + if (!platform) return state; const resumeIndex = Math.max(0, Math.min(state.currentStepIndex ?? 0, platform.steps.length - 1)); - // 中文备注:如果 currentStepIndex 对应 step 已经 success,说明暂停发生在步骤切换间隙,往后找第一个未完成的步骤。 + // 中文备注:从 currentStepIndex 往后找第一个未 success 的 step let startIndex = resumeIndex; for (let i = resumeIndex; i < state.steps.length; i += 1) { - if (state.steps[i]?.status !== 'success') { + if (state.steps[i]?.status !== "success") { startIndex = i; break; } @@ -255,19 +281,18 @@ export async function resumeCrawl(): Promise { ...state, windowId: windowInfo.windowId, tabId: windowInfo.tabId, - status: 'running', + status: "running", pause: undefined, currentStepIndex: startIndex, steps: state.steps.map((step, idx) => ({ ...step, - // 中文备注:继续时把当前要执行的 step 标记为 running(success 不动,避免覆盖已完成步骤) - status: idx === startIndex && step.status !== 'success' ? 'running' : step.status, + // 中文备注:继续时把当前要执行的 step 标为 running,已 success 的不动 + status: idx === startIndex && step.status !== "success" ? "running" : step.status, })), }; await setCrawlTaskState(nextState); - // 中文备注:重启执行器,从 startIndex 开始继续跑 const controller = new AbortController(); activeCrawlControllers.set(nextState.id, controller); void runCrawlSteps(nextState.id, nextState.tabId!, platform.steps, controller.signal, startIndex).finally(() => { @@ -278,45 +303,41 @@ export async function resumeCrawl(): Promise { } /** - * 关闭/忽略当前任务的 UI 提示(只清空状态,不强制走取消逻辑)。 - * 中文备注:用于 UI 侧把卡片隐藏掉;如果窗口还存在也会顺手关闭,避免残留。 - */ -export async function dismissCrawl(): Promise { - const state = await getCrawlTaskState(); - if (!state) { - await clearCrawlTaskState(); - return; - } - - // 中文备注:如果仍有执行器在跑,dismiss 等同取消,避免后台继续执行。 - const controller = activeCrawlControllers.get(state.id); - if (controller) { - controller.abort(); - activeCrawlControllers.delete(state.id); - } - - await clearCrawlTaskState(); - - if (state.windowId) { - chrome.windows.remove(state.windowId).catch(() => { - }); - } -} - -/** - * 完成后的统一收尾:发送结果 -> 清空 storage -> 关闭爬取窗口 - * 中文备注: - * - “发送给网页”:外部网页(externally_connectable)会通过 storage 广播拿到 completed 状态和结果; - * - 同时也给爬取 tab 发一份 `CRAWL_COMPLETED`,方便页面内(content script)有需要时直接接收。 + * 完成后的统一收尾:扩展直传后端 -> 广播完成 -> 清空任务 -> 关窗 */ async function finalizeCompletedTask(taskId: string, signal: AbortSignal) { const state = await getCrawlTaskState(); if (!state || state.id !== taskId) return; - if (state.status !== 'completed') return; + if (state.status !== "completed") return; - // 1) 发送给爬取 tab(如果 tab 还存在且页面内有监听方) + // 中文备注:按新需求,爬取完成后由扩展直接提交到后端(不再交给网页提交) + try { + const auth = getAuthState(); + const rawResult = collectStepResults(state); + const trigger = (state as any).trigger || "manual"; + const body = buildIngestScanBody(state, rawResult, auth, trigger); + await ingestScanApi(body); + } catch (e: any) { + const errorText = + e?.response?.data?.message + || e?.response?.data?.data?.apiCode + || e?.message + || "上传失败"; + + await updateCrawlTaskState(taskId, (s) => ({ + ...s, + status: "failed", + // 中文备注:把上传失败原因挂到当前 step 的 message 上,popup/overlay 可以直接展示。 + steps: s.steps.map((step, idx) => (idx === s.currentStepIndex ? {...step, message: String(errorText)} : step)), + })); + + console.error("[crawl] ingest/scan failed:", e); + return; + } + + // 1) 给爬取 tab 发一个事件(如页面内有监听可直接接收) if (state.tabId) { - sendTabMessage(state.tabId, 'CRAWL_COMPLETED', { + sendTabMessage(state.tabId, "CRAWL_COMPLETED", { taskId: state.id, platformId: state.platformId, platformName: state.platformName, @@ -325,24 +346,18 @@ async function finalizeCompletedTask(taskId: string, signal: AbortSignal) { }); } - // 2) 留一点时间给 storage.onChanged -> external ports 广播完成态(DIANSHAN_CRAWL_DONE) - // 中文备注:不宜太久,避免完成后窗口迟迟不关;这里 300ms 足够让消息出队。 + // 2) 留一点时间让 storage 广播完成态出队 await sleep(300, signal); - // 3) 清空任务记录(popup 会收到 storage 变化自动重置 UI) + // 3) 清空任务记录(popup/overlay 会收到 storage 变化自动重置 UI) await clearCrawlTaskState(); // 4) 关闭爬取窗口 if (state.windowId) { - chrome.windows.remove(state.windowId).catch(() => { - }); + chrome.windows.remove(state.windowId).catch(() => undefined); } } -/** - * 收集每个 step 的结果数据,统一输出为 { [uniqueKey]: { ... } } 结构。 - * 中文备注:该结构与 externalBridge.ts 里对外输出一致,方便网页侧消费。 - */ function collectStepResults(state: CrawlTaskState): Record { return Object.fromEntries( state.steps.map((step) => [ @@ -356,4 +371,3 @@ function collectStepResults(state: CrawlTaskState): Record { ]), ); } - diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..9ba5530 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,12 @@ +/** + * 扩展侧构建时配置。 + * 中文备注: + * - `APP_URL` 用于“登录按钮”打开的网站地址(也用于外部网页与扩展通信的可信来源)。 + * - 默认使用本地开发地址 `http://localhost:3000`。 + */ + +export const APP_URL: string = + (import.meta as any).env?.VITE_APP_URL ?? "http://localhost:3000"; + +export const APP_ORIGIN: string = new URL(APP_URL).origin; + diff --git a/src/popup/App.vue b/src/popup/App.vue index 458a0b0..4b62aa0 100644 --- a/src/popup/App.vue +++ b/src/popup/App.vue @@ -19,6 +19,10 @@ const { handleScan, handleCancelCrawl, handleResumeCrawl, + quotaLoading, + subscriptionExpired, + quotaDeniedMessage, + openBillingPage, } = useScan(); console.log(crawlState.value) @@ -98,9 +102,21 @@ function onLangChange(event: Event): void { - + + + diff --git a/src/popup/hook/use-i18n.ts b/src/popup/hook/use-i18n.ts index 4b6603b..4929bfa 100644 --- a/src/popup/hook/use-i18n.ts +++ b/src/popup/hook/use-i18n.ts @@ -19,6 +19,10 @@ const DICT: Record> = { sign_out: '退出登录', platform_select: '平台选择', scan_now: '立即扫描', + checking_sub: '正在检查订阅…', + subscription_expired: '订阅已到期,扫描功能已锁定。', + go_billing: '去订阅/续费', + scan_disabled: '暂时不允许扫描。', opening: '正在打开…', scanning: '扫描中', paused: '已暂停', @@ -41,6 +45,10 @@ const DICT: Record> = { sign_out: 'Sign out', platform_select: 'Platform', scan_now: 'Scan now', + checking_sub: 'Checking subscription…', + subscription_expired: 'Subscription expired. Scanning is locked.', + go_billing: 'Upgrade / Renew', + scan_disabled: 'Scan not allowed right now.', opening: 'Opening…', scanning: 'Scanning', paused: 'Paused', @@ -109,4 +117,3 @@ export function useI18n() { ], }; } - diff --git a/src/popup/hook/use-login.ts b/src/popup/hook/use-login.ts index 02cdb3d..a5dc98d 100644 --- a/src/popup/hook/use-login.ts +++ b/src/popup/hook/use-login.ts @@ -1,37 +1,52 @@ -import { computed, onMounted, ref } from 'vue'; -import { getToken, logout, setToken } from '@/shared/auth'; +import {computed, onMounted, ref} from "vue"; +import {APP_URL} from "@/config"; +import {clearAuthState, getAuthState, initAuthState} from "@/shared/store"; /** - * Popup 的登录状态与操作。 + * Popup 的登录状态与操作 + * + * 中文备注: + * - 扩展侧不直接拿 Web token;登录/配对在网页侧完成 + * - 网页侧拿到 extension token 后,会通过 `onMessageExternal` 回传并写入 `chrome.storage.local['auth_state']` */ export const useLogin = () => { const token = ref(null); - /** 当前是否已登录。 */ + // 当前是否已登录(是否已有 extension token) const isLoggedIn = computed(() => token.value !== null); /** - * 登录并保存 token。 + * 登录:打开网页,让网页完成配对并回传 extension token */ const handleLogin = async () => { - const value = 'xxx'; - await setToken(value); - token.value = value; + const extId = chrome?.runtime?.id || ""; + const url = `${APP_URL.replace(/\/$/, "")}/onboarding/extension?from=extension&extId=${encodeURIComponent(extId)}`; + await chrome.tabs.create({url, active: true}); }; /** - * 退出登录并清理本地状态。 + * 退出:清空本地 token 与上下文 */ const handleLogout = async () => { - await logout(); + await clearAuthState(); token.value = null; }; - /** - * 组件挂载时,从存储恢复 token。 - */ onMounted(async () => { - token.value = await getToken(); + // 中文备注:先恢复内存态 authState,再读 token 判断是否已配对 + await initAuthState(); + token.value = getAuthState()?.token ?? null; + + // 中文备注:监听 storage 变化,网页回传 token 后,popup 自动刷新“已登录”状态 + if (chrome?.storage?.onChanged) { + const handler = (changes: Record, areaName: string) => { + if (areaName !== "local") return; + const change = changes["auth_state"]; + if (!change) return; + token.value = (change.newValue as any)?.token ?? null; + }; + chrome.storage.onChanged.addListener(handler); + } }); return { diff --git a/src/popup/hook/use-scan.ts b/src/popup/hook/use-scan.ts index 907a5a1..9d42fc8 100644 --- a/src/popup/hook/use-scan.ts +++ b/src/popup/hook/use-scan.ts @@ -1,34 +1,40 @@ -import {computed, onMounted, onUnmounted, ref} from 'vue'; -import {platformConfigs} from '@/config/platforms'; -import type {CrawlTaskState} from '@/types'; -import {sendBackgroundMessage} from '@/shared/message'; - +import {computed, onMounted, onUnmounted, ref} from "vue"; +import {platformConfigs} from "@/config/platforms"; +import type {CrawlTaskState} from "@/types"; +import {sendBackgroundMessage} from "@/shared/message"; +import {getScanQuotaApi} from "@/api/me"; +import {APP_URL} from "@/config"; /** - * Popup 内的爬取状态与操作集合。 + * Popup 内的爬取状态与操作集合 + * + * 中文备注: + * - 订阅存在“隐藏逻辑”:订阅到期时不能让用户绕过网页,直接在扩展 popup 点“立即扫描” + * - 这里会在 popup 打开时调用一次 `/api/me/scan-quota`,如果订阅过期则隐藏“立即扫描”按钮,改为引导去订阅 */ export const useScan = () => { - /** 当前选中的平台 id。 */ - const selectedPlatformId = ref(platformConfigs[0]?.id ?? ''); - /** 防止重复点击“Scan now”(打开扫描窗口期间置为 true)。 */ + /** 当前选中的平台 id */ + const selectedPlatformId = ref(platformConfigs[0]?.id ?? ""); + /** 防止重复点击 “Scan now” */ const isScanning = ref(false); - /** - * 当前爬取任务状态(从 background 同步)。 - */ + /** 当前爬取任务状态(从 background 同步) */ const crawlState = ref(null); const taskStatus = computed(() => crawlState.value?.status); - /** - * 从任务开始到现在的秒数。 - */ - const elapsedSeconds = ref(0); + /** 任务耗时(秒) */ + const elapsedSeconds = ref(0); let timer: number | undefined; + // 中文备注:订阅/权限检查(控制“立即扫描”按钮是否可用) + const quotaLoading = ref(false); + const quotaDeniedReason = ref(null); + const quotaDeniedMessage = ref(null); + const subscriptionExpired = computed(() => quotaDeniedReason.value === "subscription_required"); + /** - * 停止计时器,并把显示的耗时归零。 - * 中文备注:当 crawlState 被清空(例如取消/完成后清理)时,如果不清理定时器, - * 定时器回调会继续访问 crawlState!.startedAt,导致 popup 直接崩溃,看起来像“闪一下就没反应”。 + * 停止计时器并清零 + * 中文备注:crawlState 被清空时必须停表,否则 timer 回调会访问空对象导致 popup “闪一下” */ function stopElapsedTimer() { if (timer) { @@ -38,134 +44,159 @@ export const useScan = () => { elapsedSeconds.value = 0; } + function startElapsedTimer() { + if (crawlState.value === null || timer) return; + timer = window.setInterval(() => { + elapsedSeconds.value = Math.max(0, Math.floor((Date.now() - crawlState.value!.startedAt) / 1000)); + }, 1000); + } + + function syncCrawlState(state: CrawlTaskState | null) { + crawlState.value = state; + if (state === null) { + stopElapsedTimer(); + return; + } + startElapsedTimer(); + } + + async function refreshCrawlState() { + const response = await sendBackgroundMessage({action: "GET_CRAWL_STATE"}); + if (response.ok) { + syncCrawlState(response.data ?? null); + } + } + /** - * 动新的爬取任务 + * 打开订阅页面(网页侧) */ - const handleScan = async () => { - if (isScanning.value) { + async function openBillingPage() { + const url = `${APP_URL.replace(/\/$/, "")}/dashboard/billing`; + await chrome.tabs.create({url, active: true}); + } + + /** + * 刷新订阅/权限信息(扩展 token 鉴权) + */ + async function refreshQuota() { + if (quotaLoading.value) return; + + // 中文备注:未登录(无 auth_state)时不请求;交给 UI 显示“请登录”即可 + const stored: any = await chrome.storage.local.get("auth_state"); + if (!stored?.auth_state?.token) { + quotaDeniedReason.value = null; + quotaDeniedMessage.value = null; return; } + quotaLoading.value = true; + try { + const quota: any = await getScanQuotaApi(); + if (quota?.allowed === true) { + quotaDeniedReason.value = null; + quotaDeniedMessage.value = null; + } else { + quotaDeniedReason.value = String(quota?.reason || "scan_not_allowed"); + quotaDeniedMessage.value = String(quota?.message || ""); + } + } catch (e: any) { + // 中文备注:接口失败时不在 UI 里误判为“订阅到期”,只记录为不可扫描 + quotaDeniedReason.value = "quota_check_failed"; + quotaDeniedMessage.value = e?.response?.data?.message || e?.message || ""; + } finally { + quotaLoading.value = false; + } + } + + /** + * 触发扫描(开始前会先做订阅校验) + */ + const handleScan = async () => { + if (isScanning.value) return; isScanning.value = true; try { + // 中文备注:点击“立即扫描”前先刷新一次订阅/权限,避免订阅刚到期但按钮仍可点 + await refreshQuota(); + if (subscriptionExpired.value) { + await openBillingPage(); + return; + } + if (quotaDeniedReason.value) { + console.error("[crawl] scan blocked:", quotaDeniedReason.value, quotaDeniedMessage.value); + return; + } + const response = await sendBackgroundMessage({ - action: 'START_CRAWL', + action: "START_CRAWL", payload: {platformId: selectedPlatformId.value}, }); if (response.ok) { syncCrawlState(response.data ?? null); } else { - console.error('[crawl] start failed', response.error); + console.error("[crawl] start failed", response.error); } } finally { isScanning.value = false; } }; - /** 通知 background 取消当前任务。 */ const handleCancelCrawl = async () => { - const response = await sendBackgroundMessage({action: 'CANCEL_CRAWL'}); - + const response = await sendBackgroundMessage({action: "CANCEL_CRAWL"}); if (response.ok) { syncCrawlState(response.data ?? null); return; } - - console.error('[crawl] cancel failed', response.error); + console.error("[crawl] cancel failed", response.error); await refreshCrawlState(); }; - /** 通知 background 恢复被暂停的任务。 */ const handleResumeCrawl = async () => { - const response = await sendBackgroundMessage({action: 'RESUME_CRAWL'}); - + const response = await sendBackgroundMessage({action: "RESUME_CRAWL"}); if (response.ok) { syncCrawlState(response.data ?? null); return; } - - console.error('[crawl] resume failed', response.error); + console.error("[crawl] resume failed", response.error); await refreshCrawlState(); }; - /** 关闭任务卡片并通知 background 清理任务状态。 */ const handleDismissCrawl = async () => { - const response = await sendBackgroundMessage({action: 'DISMISS_CRAWL'}); - + const response = await sendBackgroundMessage({action: "DISMISS_CRAWL"}); if (response.ok) { syncCrawlState(response.data ?? null); return; } - - console.error('[crawl] dismiss failed', response.error); + console.error("[crawl] dismiss failed", response.error); await refreshCrawlState(); }; /** - * 设置状态值,并设置时间 + * 监听 storage 变化: + * - crawlTaskState:同步任务进度 + * - auth_state:登录/退出/重新配对后刷新订阅状态 */ - function syncCrawlState(state: CrawlTaskState | null) { - crawlState.value = state; - - // 中文备注:任务被清空时,必须停止计时器,避免空引用导致 popup 闪退。 - if (state === null) { - stopElapsedTimer(); - return; - } - - startElapsedTimer(); - } - - /** - * 启动定时器 - */ - function startElapsedTimer() { - if (crawlState.value === null || timer) { - return; - } - timer = window.setInterval(() => { - elapsedSeconds.value = Math.max(0, Math.floor((Date.now() - crawlState.value!.startedAt) / 1000)); - }, 1000); - } - - - /** 从 background 拉取最新任务状态。 */ - async function refreshCrawlState() { - const response = await sendBackgroundMessage({action: 'GET_CRAWL_STATE'}); - - if (response.ok) { - syncCrawlState(response.data ?? null); - } - } - - /** 监听 `chrome.storage` 的变化,用于跨上下文同步任务状态。 */ function handleStorageChanged(changes: Record, areaName: string) { - if (areaName !== 'local') { - return; + if (areaName !== "local") return; + + if (changes["auth_state"]) { + void refreshQuota(); } const change = changes["crawlTaskState"]; - - if (!change) { - return; - } - + if (!change) return; syncCrawlState(isCrawlTaskState(change.newValue) ? change.newValue : null); } onMounted(async () => { - /** 首次加载 + 订阅 storage 事件。 */ await refreshCrawlState(); - + await refreshQuota(); chrome.storage.onChanged.addListener(handleStorageChanged); }); onUnmounted(() => { - /** 清理计时器 + 取消订阅 storage 事件。 */ stopElapsedTimer(); - chrome.storage.onChanged.removeListener(handleStorageChanged); }); @@ -174,15 +205,19 @@ export const useScan = () => { isScanning, crawlState, taskStatus, + elapsedSeconds, handleScan, handleCancelCrawl, handleResumeCrawl, handleDismissCrawl, - elapsedSeconds, + // 订阅/权限 + quotaLoading, + subscriptionExpired, + quotaDeniedMessage, + openBillingPage, }; }; -/** storage 数据的运行时类型保护(防御不可信数据)。 */ function isCrawlTaskState(value: unknown): value is CrawlTaskState { - return typeof value === 'object' && value !== null && 'id' in value && 'steps' in value; + return typeof value === "object" && value !== null && "id" in value && "steps" in value; } diff --git a/src/shared/request.ts b/src/shared/request.ts index e69de29..ac395d4 100644 --- a/src/shared/request.ts +++ b/src/shared/request.ts @@ -0,0 +1,98 @@ +import axios, {AxiosError, AxiosRequestConfig, AxiosResponse} from "axios"; + +import {getAuthState} from "@/shared/store"; + +/** + * axios 封装(扩展侧):要求与 `my-app/src/utils/reqeust.ts` 保持一致的行为。 + * + * 中文备注: + * - 统一 baseURL(来自配对后下发的 apiBaseUrl) + * - 自动带上 Authorization: Bearer + * - 统一处理后端 `{ code, message, data }`:成功返回 data,失败 reject 整个响应体 + */ + +const service = axios.create({ + // 中文备注:baseURL 会在请求拦截器里按需动态设置,这里先给空值 + baseURL: "", + timeout: 30000, +}); + +// 请求拦截器 +service.interceptors.request.use((config: any) => { + // 中文备注:当数据为 FormData 时,自动修改请求头(与网站端一致) + if (config.data instanceof FormData) { + (config as any).headers = {"Content-Type": "multipart/form-data"}; + } + + const auth = getAuthState(); + if (auth?.apiBaseUrl) { + config.baseURL = normaliseApiBaseUrl(auth.apiBaseUrl); + } + + if (auth?.token) { + (config.headers as any).Authorization = `Bearer ${auth.token}`; + } + + // 中文备注:扩展版本门禁(API 文档要求 X-Ext-Version,用于扫描相关接口) + try { + const version = chrome.runtime.getManifest().version; + (config.headers as any)["X-Ext-Version"] = version; + } catch { + // ignore + } + + return config; +}, (error: AxiosError) => Promise.reject(error)); + +// 响应拦截器 +service.interceptors.response.use((response: AxiosResponse) => { + const {code, data} = response.data || {}; + + // 中文备注:与网站端一致,成功码统一返回 data + if ([1, "200"].includes(code)) { + return data; + } + + // 中文备注:当为文件流时直接返回(保留网站端兜底逻辑) + if ((response.headers as any)?.["content-types"] === "application/octet-stream") { + return response.data; + } + + return Promise.reject(response.data); +}, (error: any) => { + if (error?.message === "Network Error") { + // 中文备注:这里不弹 Toast,避免扩展侧打扰;需要的话在 UI 层处理 + } + return Promise.reject(error); +}); + +function normaliseApiBaseUrl(apiBaseUrl: string) { + const raw = String(apiBaseUrl || "").replace(/\/$/, ""); + if (!raw) return ""; + return raw.endsWith("/api") ? raw : `${raw}/api`; +} + +function requestPost(url: string, data: any = {}, config: AxiosRequestConfig = {}) { + return service.post(url, data, config); +} + +function requestPatch(url: string, data: any = {}, config: AxiosRequestConfig = {}) { + return service.patch(url, data, config); +} + +function requestGet(url: string, params: any = {}) { + return service.get(url, {params}); +} + +function requestDelete(url: string, data: any = {}) { + return service.delete(url, {data}); +} + +const request = { + get: requestGet, + post: requestPost, + delete: requestDelete, + patch: requestPatch, +}; + +export default request; diff --git a/src/shared/store.ts b/src/shared/store.ts new file mode 100644 index 0000000..fbdfcd2 --- /dev/null +++ b/src/shared/store.ts @@ -0,0 +1,57 @@ +/** + * 扩展侧的本地“状态仓库”(轻量版)。 + * 中文备注: + * - axios 拦截器需要同步读取 token/apiBaseUrl,所以这里维护一份内存态 authState。 + * - 真实持久化仍然落在 chrome.storage.local,确保扩展重启后可恢复。 + * - 按需求:不要定义一堆类型,这里统一用 any。 + */ + +const AUTH_STATE_KEY = "auth_state"; + +let authState: any = null; + +/** + * 同步读取内存态 authState(给 axios 拦截器使用)。 + */ +export function getAuthState() { + return authState; +} + +/** + * 从 chrome.storage.local 初始化 authState(建议在 background 启动时调用一次)。 + */ +export async function initAuthState() { + if (typeof chrome === "undefined" || !chrome.storage?.local) { + return null; + } + + const result = await chrome.storage.local.get(AUTH_STATE_KEY); + authState = result?.[AUTH_STATE_KEY] ?? null; + return authState; +} + +/** + * 写入 authState(内存 + 持久化)。 + * 中文备注:配对成功后会在这里保存 extension token、apiBaseUrl、brand/store 等上下文。 + */ +export async function setAuthState(next: any) { + authState = next ?? null; + + if (typeof chrome === "undefined" || !chrome.storage?.local) { + return; + } + + if (authState) { + await chrome.storage.local.set({[AUTH_STATE_KEY]: authState}); + } else { + await chrome.storage.local.remove(AUTH_STATE_KEY); + } +} + +/** + * 清空登录态(等价于退出登录)。 + */ +export async function clearAuthState() { + await setAuthState(null); +} + diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index 1c23dff..b4963fd 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./manifest.config.ts","./message.js","./vite.config.ts","./src/background/domscraper.ts","./src/background/index.ts","./src/background/types.ts","./src/background/service/externalbridge.ts","./src/background/task/crawltask.ts","./src/background/task/helper.ts","./src/background/task/taskstate.ts","./src/config/platforms.ts","./src/content/app.vue","./src/content/crawloverlay.ts","./src/content/main.ts","./src/content/pagerunner.ts","./src/options/app.vue","./src/options/main.ts","./src/popup/app.vue","./src/popup/main.ts","./src/popup/hook/use-i18n.ts","./src/popup/hook/use-login.ts","./src/popup/hook/use-scan.ts","./src/shared/auth.ts","./src/shared/message.ts","./src/shared/request.ts","./src/shared/tab.ts","./src/shared/time_format.ts","./src/types/crawl.ts","./src/types/index.ts","./src/types/platform.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./manifest.config.ts","./message.js","./vite.config.ts","./src/config.ts","./src/api/me.ts","./src/api/scan.ts","./src/background/domscraper.ts","./src/background/index.ts","./src/background/types.ts","./src/background/service/autoscanscheduler.ts","./src/background/service/externalbridge.ts","./src/background/service/scan_payload.ts","./src/background/task/crawltask.ts","./src/background/task/helper.ts","./src/background/task/taskstate.ts","./src/config/platforms.ts","./src/content/app.vue","./src/content/crawloverlay.ts","./src/content/main.ts","./src/content/pagerunner.ts","./src/options/app.vue","./src/options/main.ts","./src/popup/app.vue","./src/popup/main.ts","./src/popup/hook/use-i18n.ts","./src/popup/hook/use-login.ts","./src/popup/hook/use-scan.ts","./src/shared/auth.ts","./src/shared/message.ts","./src/shared/request.ts","./src/shared/store.ts","./src/shared/tab.ts","./src/shared/time_format.ts","./src/types/crawl.ts","./src/types/index.ts","./src/types/platform.ts"],"version":"5.9.3"} \ No newline at end of file