Compare commits

..

5 Commits

Author SHA1 Message Date
zhu
2d1397c277 1 2026-05-13 16:59:26 +08:00
zhu
cb5a13d352 1 2026-05-13 11:22:10 +08:00
zhu
302311b3af 1 2026-05-12 17:58:27 +08:00
zhu
c7cb977243 11 2026-05-12 15:26:17 +08:00
zhu
cf7ea741a6 优化了popup 2026-05-12 11:10:21 +08:00
34 changed files with 3049 additions and 2501 deletions

View File

@@ -22,7 +22,7 @@ export default defineManifest({
}, },
], ],
host_permissions: ['https://*/*', 'http://*/*'], host_permissions: ['https://*/*', 'http://*/*'],
permissions: ['storage', 'tabs', 'scripting', 'activeTab', 'windows'], permissions: ['storage', 'tabs', 'scripting', 'activeTab', 'windows', 'alarms'],
background: { background: {
service_worker: 'src/background/index.ts', service_worker: 'src/background/index.ts',
type: 'module', type: 'module',

View File

@@ -9,6 +9,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"axios": "^1.16.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"vue": "^3.5.32", "vue": "^3.5.32",
"vue-router": "^5.0.6" "vue-router": "^5.0.6"

211
pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ importers:
.: .:
dependencies: dependencies:
axios:
specifier: ^1.16.0
version: 1.16.0
pinia: pinia:
specifier: ^3.0.4 specifier: ^3.0.4
version: 3.0.4(typescript@5.9.3)(vue@3.5.33(typescript@5.9.3)) 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'} engines: {node: '>= 10.0.0'}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.5.6': '@parcel/watcher-linux-arm-musl@2.5.6':
resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.5.6': '@parcel/watcher-linux-arm64-glibc@2.5.6':
resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.5.6': '@parcel/watcher-linux-arm64-musl@2.5.6':
resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.5.6': '@parcel/watcher-linux-x64-glibc@2.5.6':
resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.5.6': '@parcel/watcher-linux-x64-musl@2.5.6':
resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@parcel/watcher-win32-arm64@2.5.6': '@parcel/watcher-win32-arm64@2.5.6':
resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==}
@@ -240,36 +249,42 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17':
resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17':
resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17':
resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17':
resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0-rc.17': '@rolldown/binding-linux-x64-musl@1.0.0-rc.17':
resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0-rc.17': '@rolldown/binding-openharmony-arm64@1.0.0-rc.17':
resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==}
@@ -342,24 +357,28 @@ packages:
engines: {node: '>= 20'} engines: {node: '>= 20'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.2.4': '@tailwindcss/oxide-linux-arm64-musl@4.2.4':
resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==} resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==}
engines: {node: '>= 20'} engines: {node: '>= 20'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.2.4': '@tailwindcss/oxide-linux-x64-gnu@4.2.4':
resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==} resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==}
engines: {node: '>= 20'} engines: {node: '>= 20'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.2.4': '@tailwindcss/oxide-linux-x64-musl@4.2.4':
resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==} resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==}
engines: {node: '>= 20'} engines: {node: '>= 20'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.2.4': '@tailwindcss/oxide-wasm32-wasi@4.2.4':
resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==} resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==}
@@ -510,6 +529,12 @@ packages:
resolution: {integrity: sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==} resolution: {integrity: sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==}
engines: {node: '>=20.19.0'} 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: birpc@2.9.0:
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
@@ -520,6 +545,10 @@ packages:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'} 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: chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'} engines: {node: '>= 14.16.0'}
@@ -528,6 +557,10 @@ packages:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'} engines: {node: '>= 20.19.0'}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
confbox@0.1.8: confbox@0.1.8:
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
@@ -560,6 +593,10 @@ packages:
supports-color: supports-color:
optional: true optional: true
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
detect-libc@2.1.2: detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -577,6 +614,10 @@ packages:
domutils@3.2.2: domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} 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: enhanced-resolve@5.21.0:
resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==} resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
@@ -589,9 +630,25 @@ packages:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'} 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: es-module-lexer@0.10.5:
resolution: {integrity: sha512-+7IwY/kiGAacQfY+YBhKMvEmyAJnw5grTUgjG85Pe7vcUI/6b7pZjZG8nQ7+48YhzEAEqrEgD2dCz/JIK+AYvw==} 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: estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
@@ -618,6 +675,19 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'} 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: fs-extra@10.1.0:
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -627,13 +697,40 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin] 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: glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
graceful-fs@4.2.11: graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} 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: he@1.2.0:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true hasBin: true
@@ -712,24 +809,28 @@ packages:
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0: lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0: lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0: lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0: lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -758,6 +859,10 @@ packages:
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} 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: merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -766,6 +871,14 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'} 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: mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
@@ -834,6 +947,10 @@ packages:
resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==} resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==}
engines: {node: ^10 || ^12 || >=14} 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: quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
@@ -1472,6 +1589,16 @@ snapshots:
'@babel/parser': 7.29.2 '@babel/parser': 7.29.2
ast-kit: 2.2.0 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: {} birpc@2.9.0: {}
boolbase@1.0.0: {} boolbase@1.0.0: {}
@@ -1480,6 +1607,11 @@ snapshots:
dependencies: dependencies:
fill-range: 7.1.1 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: chokidar@4.0.3:
dependencies: dependencies:
readdirp: 4.1.2 readdirp: 4.1.2
@@ -1488,6 +1620,10 @@ snapshots:
dependencies: dependencies:
readdirp: 5.0.0 readdirp: 5.0.0
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
confbox@0.1.8: {} confbox@0.1.8: {}
confbox@0.2.4: {} confbox@0.2.4: {}
@@ -1514,6 +1650,8 @@ snapshots:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
delayed-stream@1.0.0: {}
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
dom-serializer@2.0.0: dom-serializer@2.0.0:
@@ -1534,6 +1672,12 @@ snapshots:
domelementtype: 2.3.0 domelementtype: 2.3.0
domhandler: 5.0.3 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: enhanced-resolve@5.21.0:
dependencies: dependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
@@ -1543,8 +1687,23 @@ snapshots:
entities@7.0.1: {} entities@7.0.1: {}
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
es-module-lexer@0.10.5: {} 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: {} estree-walker@2.0.2: {}
exsolve@1.0.8: {} exsolve@1.0.8: {}
@@ -1569,6 +1728,16 @@ snapshots:
dependencies: dependencies:
to-regex-range: 5.0.1 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: fs-extra@10.1.0:
dependencies: dependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
@@ -1578,12 +1747,44 @@ snapshots:
fsevents@2.3.3: fsevents@2.3.3:
optional: true 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: glob-parent@5.1.2:
dependencies: dependencies:
is-glob: 4.0.3 is-glob: 4.0.3
gopd@1.2.0: {}
graceful-fs@4.2.11: {} 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: {} he@1.2.0: {}
hookable@5.5.3: {} hookable@5.5.3: {}
@@ -1675,6 +1876,8 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
math-intrinsics@1.1.0: {}
merge2@1.4.1: {} merge2@1.4.1: {}
micromatch@4.0.8: micromatch@4.0.8:
@@ -1682,6 +1885,12 @@ snapshots:
braces: 3.0.3 braces: 3.0.3
picomatch: 2.3.2 picomatch: 2.3.2
mime-db@1.52.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
mitt@3.0.1: {} mitt@3.0.1: {}
mlly@1.8.2: mlly@1.8.2:
@@ -1748,6 +1957,8 @@ snapshots:
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.1
proxy-from-env@2.1.0: {}
quansync@0.2.11: {} quansync@0.2.11: {}
queue-microtask@1.2.3: {} queue-microtask@1.2.3: {}

493
s.md
View File

@@ -1,188 +1,389 @@
# 网站接入店闪扩展说明 # dianshan 插件爬取任务开发文档(逻辑/通信/状态)
这个扩展已经提供网站侧调用接口。网站点击“开始”时,可以让扩展执行和 popup 手动点击“立即爬取”一样的流程:打开新浏览器窗口、进入平台后台、抓取数据。抓取完成后,扩展会通过长连接把结果推回网站 > 目的:把“点击爬取后发生了什么、会触发哪些方法、给谁通信、改变哪些状态”说清楚,方便后续二开与排查
>
> 范围:`dianshan/` 目录内的扩展popup + background + content/overlay + 外部网页桥接)。
>
> 注意:当前项目代码里没有 `startDianshanCrawl()` / `getDianshanCrawlState()` 这类方法名;
> 正确入口分别是 `startCrawl()``dianshan/src/background/task/crawlTask.ts:14`)与 `getCrawlTaskState()``dianshan/src/background/task/taskState.ts:10`),以及对应的消息 action`START_CRAWL` / `GET_CRAWL_STATE`)。
## 1. 先配置允许连接的网站域名 ---
扩展的 `manifest.config.ts` 里有: ## 1. 关键文件索引(按职责)
```ts ### 1.1 Popup扩展弹窗 UI
externally_connectable: { - `dianshan/src/popup/App.vue`
matches: [ - UI 展示:平台选择、开始/取消/继续按钮、步骤列表、底部语言切换/版本号
"http://localhost:3000/*", - `dianshan/src/popup/hook/use-scan.ts`
] - 与 background 通信(开始/取消/继续/读取状态)
} - 监听 `chrome.storage.onChanged` 同步任务状态
``` - 计时器:根据 `startedAt` 计算已用时
- `dianshan/src/popup/hook/use-i18n.ts`
- Popup 文案 i18n中文/英文),持久化到 `chrome.storage.local`
把你的网站域名加进去,例如: ### 1.2 Background后台调度 + 任务状态机)
- `dianshan/src/background/index.ts`
- 统一接收 popup/content/external 的 message`chrome.runtime.onMessage`
- 监听窗口/Tab 关闭(自动暂停任务)
- 监听 storage 变化并对外广播external bridge
- `dianshan/src/background/task/crawlTask.ts`
- 任务生命周期start/cancel/pause/resume/dismiss
- 步骤执行器:按 steps 顺序打开页面、等待、抓取、处理中断/重试
- 完成后收尾:发送结果 -> 清空记录 -> 关闭窗口
- `dianshan/src/background/task/taskState.ts`
- 读写 `chrome.storage.local``crawlTaskState`
- 写入时同时 `sendTabMessage(tabId,'CRAWL_STATE_UPDATE',state)` 推送给爬取窗口overlay 用)
- `dianshan/src/background/task/helper.ts`
- `openSingleTabWindow()` 创建 popup 类型新窗口并返回 `{windowId, tabId}`
- `waitForTabLoaded()` 等待 tab load complete
- `scrapeStepInContent()` 与 content script 通信执行抓取(`tabs.sendMessage`
- `dianshan/src/background/service/externalBridge.ts`
- 与外部网页(官网/业务系统)进行 external message / long-connect 通信
- 监听 storage 变化,广播 `DIANSHAN_CRAWL_STATE / DONE / ...` 等事件
```ts ### 1.3 Content Script爬取窗口页内逻辑
externally_connectable: { - `dianshan/src/content/pageRunner.ts`
matches: [ - 接收 background 指令:
"http://localhost:3000/*", - `CHECK_INTERRUPT`:判断登录/验证码/404/未就绪等“需要人工处理”的中断
"https://your-site.com/*", - `SCRAPE_STEP`:等待关键 selector 稳定后执行 DOM 抓取
] - `dianshan/src/content/crawlOverlay.ts`
} - 爬取窗口左下角悬浮 UI圆形计时菜单
``` - 点击圆形菜单展开后展示“与 popup 类似”的步骤进度/暂停提示/继续/取消
- 接收 background 推送的 `CRAWL_STATE_UPDATE` 状态
- 初始化时会拉取一次 `GET_CRAWL_STATE_FOR_TAB` 快照(只在爬取 tab 返回状态)
改完扩展后需要重新 `pnpm run build`,并在 Chrome 扩展管理页重新加载扩展。 ### 1.4 Shared跨模块协议
- `dianshan/src/shared/message.ts`
- popup/content -> background 的消息 action 类型(例如 `START_CRAWL`
- `dianshan/src/shared/tab.ts`
- background -> content爬取窗口 tab消息 action 类型(例如 `CRAWL_STATE_UPDATE`
- `dianshan/src/types/crawl.ts`
- `CrawlTaskState` / `CrawlProgressStep` / `CrawlPauseInfo` 等任务数据结构
## 2. 网站侧需要知道扩展 ID ### 1.5 关键方法定位(按真实代码)
> 下面列的都是当前项目里“确实存在”的方法/入口,后面章节会反复引用;每条都附带文件地址+行号,方便你 Ctrl+P 直达。
Chrome 扩展管理页打开“开发者模式”,复制这个扩展的 ID - popup 启动爬取:`handleScan()``dianshan/src/popup/hook/use-scan.ts:46`
- popup 取消爬取:`handleCancelCrawl()``dianshan/src/popup/hook/use-scan.ts:70`
- popup 继续爬取:`handleResumeCrawl()``dianshan/src/popup/hook/use-scan.ts:83`
- popup 同步状态:`handleStorageChanged()``dianshan/src/popup/hook/use-scan.ts:146`
```ts - background 消息总入口:`chrome.runtime.onMessage.addListener(...)``dianshan/src/background/index.ts:22`
const EXTENSION_ID = "这里换成你的扩展ID"; - background 路由:`case "START_CRAWL"` 等:`dianshan/src/background/index.ts:34`
``` - background 窗口/Tab 关闭监听:`windows.onRemoved`/`tabs.onRemoved``dianshan/src/background/index.ts:92`
开发环境如果每次扩展 ID 变化,建议给扩展配置固定 key或者每次复制新的 ID 到网站项目配置里。 - 任务启动:`startCrawl()``dianshan/src/background/task/crawlTask.ts:14`
- 任务执行器:`runCrawlSteps()``dianshan/src/background/task/crawlTask.ts:63`
- 完成收尾:`finalizeCompletedTask()``dianshan/src/background/task/crawlTask.ts:312`
- 取消任务:`cancelCrawl()``dianshan/src/background/task/crawlTask.ts:131`
- 窗口关闭自动暂停:`pauseCrawlOnWindowRemoved()``dianshan/src/background/task/crawlTask.ts:158`
- Tab 关闭自动暂停:`pauseCrawlOnTabRemoved()``dianshan/src/background/task/crawlTask.ts:188`
- 继续/恢复:`resumeCrawl()``dianshan/src/background/task/crawlTask.ts:219`
## 3. 推荐的网站侧接入代码 - 状态读写:`getCrawlTaskState()`/`setCrawlTaskState()``dianshan/src/background/task/taskState.ts:10` / `dianshan/src/background/task/taskState.ts:20`
网站页面加载后先建立长连接,用来接收扩展推送的进度和最终结果。 - 新开爬取窗口:`openSingleTabWindow()``dianshan/src/background/task/helper.ts:8`
- 等待页面加载:`waitForTabLoaded()``dianshan/src/background/task/helper.ts:45`
- 让 content 抓取:`scrapeStepInContent()``dianshan/src/background/task/helper.ts:90`
```ts - content 执行器入口:`setupPageRunner()``dianshan/src/content/pageRunner.ts:28`
const EXTENSION_ID = "这里换成你的扩展ID"; - content 抓取处理:`handlePageRunnerMessage()``dianshan/src/content/pageRunner.ts:38`
type DianshanMessage = { - 爬取窗口悬浮 UI`mountCrawlOverlay()``dianshan/src/content/crawlOverlay.ts:36`
ok: boolean; - 悬浮 UI 初始化拉取快照:`GET_CRAWL_STATE_FOR_TAB``dianshan/src/content/crawlOverlay.ts:67`
type?: string; - 悬浮 UI 实时状态推送:`CRAWL_STATE_UPDATE``dianshan/src/content/crawlOverlay.ts:54`
data?: {
state: any | null;
result: Record<string, unknown> | null;
};
error?: string;
};
let port: chrome.runtime.Port | null = null; ---
export function connectDianshanExtension() { ## 2. 核心数据结构CrawlTaskState状态机
port = chrome.runtime.connect(EXTENSION_ID, { name: "DIANSHAN_CRAWL" });
port.onMessage.addListener((message: DianshanMessage) => { 存储位置:`chrome.storage.local['crawlTaskState']`
console.log("[dianshan]", message);
if (message.type === "DIANSHAN_CRAWL_STATE") { 关键字段(简化说明):
// 可选:更新网站上的进度 UI - `id`:任务唯一 ID`platformId-startedAt`
return; - `platformId/platformName`:平台信息
} - `windowId/tabId`:当前爬取窗口与承载爬取的 taboverlay 只在这个 tab 渲染)
- `startedAt`:任务开始时间戳(用于 popup/overlay 计时)
- `status``running | paused | completed | failed | canceled`
- `pause`:当 `status='paused'` 时存在,包含
- `reason``reauth | shield | not_found | page_not_ready | window_closed`
- `message`:给用户看的暂停提示
- `currentStepIndex`:当前执行到的步骤索引
- `steps[]`:每个步骤的进度条记录
- `uniqueKey/name`
- `status``pending | running | success | failed`
- `result/message`:抓取结果或失败原因
if (message.type === "DIANSHAN_CRAWL_DONE") { 状态同步策略:
// 抓取完成,最终数据在 message.data.result - background 每次 `setCrawlTaskState()` / `updateCrawlTaskState()` 都会写入 storage
console.log("抓取结果", message.data?.result); - popup监听 `chrome.storage.onChanged`,永远以 storage 为准渲染 UI
return; - overlay通过 `sendTabMessage(tabId,'CRAWL_STATE_UPDATE',state)` 实时更新(并可在初始化时请求快照)
} - 外部网页:`chrome.storage.onChanged` -> `externalBridge.broadcastCrawlStorageChange()` 广播结果/状态
if (message.type === "DIANSHAN_CRAWL_FAILED") { ---
// 抓取失败,可展示 message.data.state.steps 里的失败原因
console.error("抓取失败", message.data?.state);
return;
}
if (message.type === "DIANSHAN_CRAWL_CANCELED" || message.type === "DIANSHAN_CRAWL_CLEARED") { ## 3. “点击爬取”后的完整时序(从 UI 到抓取)
// 用户取消或任务被清空
console.log("抓取已取消");
}
});
port.onDisconnect.addListener(() => { 以下用“触发方法 / 通信对象 / 状态变化”描述每一步。
port = null;
});
}
```
## 4. 网站点击“开始抓取 ### 3.1 Popup 点击 “Scan now
1) 触发方法
- popup`use-scan.ts -> handleScan()``dianshan/src/popup/hook/use-scan.ts:46`
按钮点击时调用: 2) 通信
- popup -> background`sendBackgroundMessage({ action:'START_CRAWL', payload:{ platformId } })`
```ts 3) background 入口
export async function startDianshanCrawl(platformId = "Shopee") { - `background/index.ts``chrome.runtime.onMessage` 收到 `START_CRAWL``dianshan/src/background/index.ts:22` / `dianshan/src/background/index.ts:34`
const response = await chrome.runtime.sendMessage(EXTENSION_ID, { - 调用:`crawlTask.startCrawl(platformId)``dianshan/src/background/task/crawlTask.ts:14`
type: "DIANSHAN_START_CRAWL",
payload: { platformId },
});
if (!response?.ok) { 4) 状态变化startCrawl
throw new Error(response?.error ?? "启动抓取失败"); - `openSingleTabWindow(steps[0].url)`创建爬取窗口popup 类型)得到 `{windowId, tabId}``dianshan/src/background/task/helper.ts:8`
} - 构建初始 `CrawlTaskState`
- `status='running'`
- `currentStepIndex=0`
- `steps[0].status='running'`,其余 `pending`
- `setCrawlTaskState(nextState)` 写入 storage`dianshan/src/background/task/taskState.ts:20`
- 这一步会触发:
- popup 的 storage 监听更新 UI
- background 同时 `sendTabMessage(tabId,'CRAWL_STATE_UPDATE',state)` 推送给 overlay`dianshan/src/background/task/taskState.ts:20`
- externalBridge 通过 storage.onChanged 对外广播(如果网页有连 port
return response.data; 5) 启动执行器
} - background创建 `AbortController`,并异步执行:
``` - `runCrawlSteps(taskId, tabId, platform.steps, signal, startIndex=0)``dianshan/src/background/task/crawlTask.ts:63`
效果等同于用户打开 popup 后手动点击“立即爬取”。如果当前已经有 running/paused 的任务,扩展会直接返回当前任务,不会重复打开多个抓取窗口。 ---
## 5. 查询当前状态 ## 4. “执行一个步骤”会发生什么runCrawlSteps 的循环体)
```ts 每个 step 的处理流程如下(对每个 i 从 startIndex 到 steps.length-1
export async function getDianshanCrawlState() {
return chrome.runtime.sendMessage(EXTENSION_ID, {
type: "DIANSHAN_GET_CRAWL_STATE",
});
}
```
## 6. 网站侧取消抓取 ### 4.1 进入新 step先更新状态机
1) 触发方法
- background`updateCrawlTaskState(taskId, updater)`
```ts 2) 状态变化
export async function cancelDianshanCrawl() { - `currentStepIndex = i`
return chrome.runtime.sendMessage(EXTENSION_ID, { - `steps[i].status = 'running'`(其它步骤保持原样)
type: "DIANSHAN_CANCEL_CRAWL",
});
}
```
取消后扩展会清空 `crawlTaskState`,并关闭扩展自动打开的浏览器窗口。 3) 通信影响
- 因为 `updateCrawlTaskState()` 内部会 `setCrawlTaskState()`
- storage 写入 -> popup UI 更新
- 同时 `sendTabMessage(tabId,'CRAWL_STATE_UPDATE',state)` -> overlay 更新
- storage.onChanged -> external bridge 广播(如果外部网页连着)
## 7. 返回数据结构 ### 4.2 跳转页面并等待加载完成
1) 触发方法
- background`chrome.tabs.update(tabId, { url: step.url, active: true })`
- background`waitForTabLoaded(tabId, signal)`
长连接收到 `DIANSHAN_CRAWL_DONE` 时,数据大致是: 2) 通信对象
- background -> Chrome tabs API无 content 参与)
```ts 3) 状态变化
{ - 不改变任务状态,只是准备让 content script 进入目标页面上下文
ok: true,
type: "DIANSHAN_CRAWL_DONE",
data: {
state: {
id: "Shopee-...",
platformId: "Shopee",
platformName: "Shopee 后台",
status: "completed",
steps: [
{
name: "数据看板",
uniqueKey: "databoard",
status: "success",
result: {}
}
]
},
result: {
databoard: {
name: "数据看板",
status: "success",
result: {}
},
adscenter: {
name: "广告中心",
status: "success",
result: {}
}
}
}
}
```
网站项目里一般用 `message.data.result` 入库或展示即可;如果要展示进度,用 `message.data.state.steps` ### 4.3 让 content script 执行抓取(核心通信)
1) 触发方法
- background`scrapeStepInContent(tabId, step, signal)`
## 8. 最小使用流程 2) 通信
- background -> content爬取窗口 tab`chrome.tabs.sendMessage(tabId, { action:'SCRAPE_STEP', payload:{ fields, checkSelector } })`
```ts 3) content 入口
connectDianshanExtension(); - `content/pageRunner.ts``chrome.runtime.onMessage` 收到 `SCRAPE_STEP`
- `detectPageInterrupt()`:先判断是否需要人工处理(登录/验证码/404/未就绪)
- `waitForStableSelector(checkSelector, timeout)`:等待关键 DOM 稳定出现
- `processFields(fields, document.body)`:按配置抓取 DOM 数据
document.querySelector("#start")?.addEventListener("click", async () => { 4) 返回值约定PageRunnerResponse
await startDianshanCrawl("Shopee"); - `ok: true, data: DomScrapeResult`:本步骤抓取成功
}); - `interrupt: CrawlPauseInfo`:需要人工处理(会进入 paused
``` - `ok: false, error`:未就绪/异常background 会重试
注意:网站必须运行在 `externally_connectable.matches` 配置过的域名下,否则 Chrome 会拒绝调用扩展。 ### 4.4 抓取成功:写入结果并标记 step success
1) 触发方法
- background`updateCrawlTaskState(taskId, updater)`
2) 状态变化
- `steps[i].status = 'success'`
- `steps[i].result = res.data`
3) 通信影响
- 同 4.1storage + overlay 推送 + external bridge 广播
### 4.5 抓取中断:进入 paused需要用户处理
中断来源:
- content 检测到 `shield/reauth/not_found/page_not_ready`
- 或者:爬取窗口被用户关掉(见第 6 章)
1) 触发方法(以 content 中断为例)
- background`updateCrawlTaskState(taskId, s => ({ ...s, status:'paused', pause: interrupt }))``dianshan/src/background/task/crawlTask.ts:93`
2) 状态变化
- `status='paused'`
- `pause={reason,message}`
3) 执行器行为
- background 在 `runCrawlSteps` 内部“死等恢复”:
- 循环检查 `getCrawlTaskState()?.status === 'paused'`
- 直到用户点击“继续”把状态改回 `running` 才继续
4) UI 行为
- popup / overlay 都会显示暂停提示 + “Continue”
---
## 5. “点击继续”会发生什么RESUME_CRAWL
触发来源:
- popup 的 Continue
- overlay 的 Continue
### 5.1 popup/overlay 发消息
- popup`use-scan.ts -> handleResumeCrawl() -> sendBackgroundMessage({action:'RESUME_CRAWL'})``dianshan/src/popup/hook/use-scan.ts:83`
- overlay`crawlOverlay.ts -> chrome.runtime.sendMessage({action:'RESUME_CRAWL'})``dianshan/src/content/crawlOverlay.ts:392`
### 5.2 background 处理 resume
入口:
- `background/index.ts` switch case `RESUME_CRAWL``dianshan/src/background/index.ts:66`
- 调用 `crawlTask.resumeCrawl()``dianshan/src/background/task/crawlTask.ts:219`
resumeCrawl 分两类:
#### A) 登录/验证码等中断(窗口仍存在)
条件:
- `state.status === 'paused'`
- `state.pause.reason !== 'window_closed'`
- `state.windowId && state.tabId` 还在
行为:
- 仅把状态改回 `running`(清掉 pause
- 原来的 `runCrawlSteps()` 仍在“死等恢复”,会自动继续跑下一轮
#### B) 窗口被关闭导致中断(需要重开窗口)
条件:
- `state.pause.reason === 'window_closed'``windowId/tabId` 不存在
行为:
- 根据 `currentStepIndex` 找到“第一个未 success 的步骤”作为 `startIndex`
- `openSingleTabWindow(url)` 重新打开爬取窗口(`dianshan/src/background/task/helper.ts:8`
- `setCrawlTaskState()` 写入新的 `windowId/tabId/status='running'/currentStepIndex=startIndex``dianshan/src/background/task/taskState.ts:20`
- 新建 `AbortController`,重新启动执行器:
- `runCrawlSteps(taskId, newTabId, platform.steps, signal, startIndex)``dianshan/src/background/task/crawlTask.ts:63`
---
## 6. “爬取过程中窗口被关掉”会发生什么(自动暂停)
触发点:
- `background/index.ts` 监听:
- `chrome.windows.onRemoved``dianshan/src/background/index.ts:92`
- `chrome.tabs.onRemoved`(兜底)(`dianshan/src/background/index.ts:97`
处理逻辑:
- 调用 `crawlTask.pauseCrawlOnWindowRemoved(windowId)``dianshan/src/background/task/crawlTask.ts:158`)或 `pauseCrawlOnTabRemoved(tabId)``dianshan/src/background/task/crawlTask.ts:188`
行为:
1) 中止执行器
- 找到当前任务对应的 `AbortController``abort()`
- 目的:避免后台继续对不存在的 tab 进行 `tabs.update/sendMessage` 导致刷屏错误
2) 状态改为 paused
- `status='paused'`
- `pause.reason='window_closed'`
- `pause.message` 提示用户点击继续可恢复
- 清空 `windowId/tabId`(避免 UI 侧再尝试 focus 老窗口)
用户点击继续后:
- 走第 5.2 的 B 分支:重开窗口 + 从未完成步骤继续
---
## 7. “全部步骤完成”会发生什么(发送结果 + 清理 + 关窗)
触发点:
- `runCrawlSteps()` 完成 for 循环后:
1) `updateCrawlTaskState(...status='completed')`
2) 调用 `finalizeCompletedTask(taskId)`
finalizeCompletedTask 做三件事(顺序很重要):
1) 发送结果给页面
- background -> content爬取 tab`sendTabMessage(tabId,'CRAWL_COMPLETED',{ result: ... })`
- 同时 storage 完成态会触发 `externalBridge` 对外广播 `DIANSHAN_CRAWL_DONE`
2) 清空本次任务记录
- `clearCrawlTaskState()`:移除 `chrome.storage.local['crawlTaskState']`
- popup 会收到 storage 改变并自动把 UI 恢复到“未开始”状态
3) 关闭爬取窗口
- `chrome.windows.remove(windowId)`
---
## 8. Popup/Overlay 如何拿到状态(同步机制总结)
### 8.1 Popup只看 storage强一致
- 初次加载:发 `GET_CRAWL_STATE`
- 后续更新:监听 `chrome.storage.onChanged`,只要 `crawlTaskState` 变化就刷新 UI
### 8.2 Overlay推送为主拉取为辅
- 推送background 写入 state 时会 `sendTabMessage(tabId,'CRAWL_STATE_UPDATE',state)`
- overlay 监听 `chrome.runtime.onMessage`,收到 `action==='CRAWL_STATE_UPDATE'` 立刻渲染
- 拉取overlay 初始化时发 `GET_CRAWL_STATE_FOR_TAB`
- background 只在 `sender.tab.id === state.tabId` 时返回 state否则返回 null
---
## 9. 外部网页(官网/业务系统如何接收结果externalBridge
两种方式:
### 9.1 onMessageExternal短消息
- 网页发 `DIANSHAN_START_CRAWL / DIANSHAN_GET_CRAWL_STATE / ...`
- background `externalBridge.handleExternalMessage()` 返回当前状态/平台列表等
### 9.2 onConnectExternal长连接 Port
- 网页建立 Portname=`DIANSHAN_CRAWL`
- background 把当前 state 发一次 `DIANSHAN_CRAWL_STATE`
- 后续只要 storage 变化,就广播:
- `DIANSHAN_CRAWL_STATE`(进行中)
- `DIANSHAN_CRAWL_DONE`(完成且带 result
- `DIANSHAN_CRAWL_CLEARED / FAILED / CANCELED`(清理/失败/取消)
结果结构:
- `externalBridge.buildCrawlWebPayload(state)` 会给出:
- `state`:完整状态机
- `result`:仅当 `state.status==='completed'` 时非空,为按 step.uniqueKey 聚合的结果对象
---
## 10. 常见问题排查(建议)
1) overlay 不显示 / 不更新
- 先确认该 tab 是否为 `crawlTaskState.tabId`
- overlay 初始化会请求 `GET_CRAWL_STATE_FOR_TAB`,只有爬取 tab 才返回 state
- 确认 background 写入 state 时是否调用了 `setCrawlTaskState()`(它会推送 `CRAWL_STATE_UPDATE`
2) 点击取消 popup “闪一下”
- 通常是计时器回调解引用了空状态popup 侧必须在 state 清空时 stop timer
3) 窗口被关后任务还在后台跑
- 应当由 `windows.onRemoved/tabs.onRemoved` 触发 `pauseCrawlOnWindowRemoved/TabRemoved`
- 检查 background/index.ts 是否注册了监听
---
## 11. 约定:消息 action 一览(当前)
popup/content -> background`dianshan/src/shared/message.ts`
- `START_CRAWL`
- `GET_CRAWL_STATE`
- `GET_CRAWL_STATE_FOR_TAB`(仅爬取 tab 返回 state
- `CANCEL_CRAWL`
- `RESUME_CRAWL`
- `DISMISS_CRAWL`
- `CANCEL_AUTOCLOSE`(兼容旧 overlay 按钮,不建议新逻辑依赖)
background -> content`dianshan/src/shared/tab.ts`
- `CRAWL_STATE_UPDATE`overlay/pagerunner 可用)
- `CRAWL_COMPLETED`(完成时额外发送结果)

24
src/api/me.ts Normal file
View File

@@ -0,0 +1,24 @@
import request from "@/shared/request";
/**
* 获取扩展侧可用的配额/配置(扩展 token 鉴权)
*
* 中文备注:
* - API 文档GET `/api/me/scan-quota`
* - 按你的要求:返回值/参数全部用 any不定义复杂类型
*/
export async function getScanQuotaApi(): Promise<any> {
return await request.get("/me/scan-quota");
}
/**
* 扩展心跳(用于后端记录扩展活跃/跳过原因)
*
* 中文备注:
* - API 文档POST `/api/extension/heartbeat`
* - 这里不强依赖心跳成功,失败不应影响爬取主流程
*/
export async function extensionHeartbeatApi(body: any): Promise<any> {
return await request.post("/extension/heartbeat", body);
}

12
src/api/scan.ts Normal file
View File

@@ -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<any> {
return await request.post("/ingest/scan", body);
}

View File

@@ -1,69 +1,138 @@
import { handleBackgroundCommand, handleInstalled, handleStartup, handleWindowRemoved } from './service'; import {broadcastCrawlStorageChange, handleExternalConnect, handleExternalMessage} from "./service/externalBridge";
import { broadcastCrawlStorageChange, handleExternalConnect, handleExternalMessage } from './service/externalBridge'; import type {MessageAction} from "@/shared/message";
import type { BackgroundCommand } from './types'; import {
import { cancelStaleCrawlWhenWindowMissing } from './service/crawlTask'; cancelCrawl,
import { getCrawlTaskState } from './service/taskState'; dismissCrawl,
pauseCrawlOnTabRemoved,
pauseCrawlOnWindowRemoved,
resumeCrawl,
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(() => { chrome.runtime.onInstalled.addListener(() => {
void handleInstalled(); // 中文备注:安装/更新后尝试重建一次定时任务(如果已配对过,会从 storage 取到配置)。
void rescheduleAutoScanAlarms("onInstalled");
}); });
chrome.runtime.onStartup.addListener(() => { chrome.runtime.onStartup.addListener(() => {
void handleStartup(); // 中文备注:浏览器启动时重建定时任务,避免 service worker 回收导致 alarms 丢失。
}); void rescheduleAutoScanAlarms("onStartup");
chrome.runtime.onMessage.addListener((message: BackgroundCommand | { action?: string }, sender, sendResponse) => {
if (message && typeof message === 'object' && message.action === 'GET_CRAWL_STATE_FOR_TAB') {
void (async () => {
await cancelStaleCrawlWhenWindowMissing();
const state = await getCrawlTaskState();
const tabId = sender.tab?.id;
if (state && typeof tabId === 'number' && state.tabId === tabId) {
sendResponse({ ok: true, data: state });
return;
}
sendResponse({ ok: true, data: null });
})();
return true;
}
void handleBackgroundMessage(message as BackgroundCommand, sendResponse);
return true;
});
chrome.windows.onRemoved.addListener((windowId) => {
void handleWindowRemoved(windowId);
});
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),
});
});
return true;
});
chrome.runtime.onConnectExternal.addListener(handleExternalConnect);
chrome.storage.onChanged.addListener((changes, areaName) => {
broadcastCrawlStorageChange(changes, areaName);
}); });
/** /**
* Wrap background command handling so async errors can still be returned to callers. * 接收 popup/content 的消息
*/ */
async function handleBackgroundMessage( chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
message: BackgroundCommand, const action = message.action as MessageAction;
sendResponse: (response?: unknown) => void, const payload = message.payload;
) {
try { (async () => {
const result = await handleBackgroundCommand(message); try {
sendResponse(result); let resultData: any = null;
} catch (error: unknown) {
const messageText = error instanceof Error ? error.message : 'Unknown error'; switch (action) {
sendResponse({ ok: false, data: null, error: messageText }); case "START_CRAWL":
// 中文备注:手动触发
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”拿到状态避免其它页面误显示 overlay。
const state = await getCrawlTaskState();
const senderTabId = sender.tab?.id;
resultData = state && senderTabId && state.tabId === senderTabId ? state : null;
break;
}
case "CANCEL_CRAWL":
await cancelCrawl();
break;
case "CANCEL_AUTOCLOSE": {
// 中文备注:兼容旧悬浮面板按钮;当前版本默认完成后会清理并关闭窗口。
const state = await getCrawlTaskState();
if (state) {
await updateCrawlTaskState(state.id, (s) => ({...s, autocloseAt: null}));
resultData = await getCrawlTaskState();
} else {
resultData = null;
}
break;
}
case "RESUME_CRAWL":
resultData = await resumeCrawl();
break;
case "DISMISS_CRAWL":
await dismissCrawl();
break;
default:
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"});
}
})();
return true;
});
/**
* 监听窗口/Tab 关闭:
* - 用户手动关掉爬取窗口时,不直接取消任务,而是自动暂停,等待用户点击继续。
*/
chrome.windows.onRemoved.addListener((windowId) => {
void pauseCrawlOnWindowRemoved(windowId);
});
chrome.tabs.onRemoved.addListener((tabId) => {
void pauseCrawlOnTabRemoved(tabId);
});
/**
* 接收外部网页消息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),
});
});
return true; // 中文备注:保持异步响应通道开启
});
/**
* 外部网页长连接onConnectExternal
*/
chrome.runtime.onConnectExternal.addListener(handleExternalConnect);
/**
* 监听 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");
} }
} });

View File

@@ -1 +0,0 @@
export { handleBackgroundCommand, handleInstalled, handleStartup, handleWindowRemoved } from './service/lifecycle';

View File

@@ -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);
}

View File

@@ -1,671 +0,0 @@
import { getPlatformById } from '@/config/platforms';
import type { CrawlPauseInfo, CrawlProgressStep, CrawlTaskState, PlatformConfig, PlatformStepConfig } from '@/types';
import type { DomScrapeResult } from '../domScraper';
import type { CrawlStateResponse } from '../types';
import { clearCrawlTaskState, getCrawlTaskState, setCrawlTaskState, updateCrawlTaskState } from './taskState';
interface PageRunnerResponse {
ok: boolean;
data?: DomScrapeResult | null;
interrupt?: CrawlPauseInfo;
error?: string;
}
const activeCrawlControllers = new Map<string, AbortController>();
const autoCloseTimers = new Map<string, number>();
const DEFAULT_AUTOCLOSE_DELAY_MS = 10_000;
/**
* 创建新的爬取任务,打开目标平台窗口,并把初始时间轴状态写入 storage。
*/
export async function startCrawl(platformId: string): Promise<CrawlStateResponse> {
const platform = getPlatformById(platformId);
const currentState = await getCrawlTaskState();
if (currentState && ['running', 'paused'].includes(currentState.status)) {
return { ok: true, data: currentState };
}
if (!platform) {
return { ok: false, error: '平台配置不存在' };
}
const firstStep = platform.steps[0];
if (!firstStep) {
return { ok: false, error: '平台未配置爬取步骤' };
}
const startedAt = Date.now();
const nextState: CrawlTaskState = {
id: `${platform.id}-${startedAt}`,
platformId: platform.id,
platformName: platform.name,
startedAt,
status: 'running',
currentStepIndex: 0,
steps: platform.steps.map<CrawlProgressStep>((step, index) => ({
name: step.name,
uniqueKey: step.uniqueKey,
status: index === 0 ? 'running' : 'pending',
})),
};
await setCrawlTaskState(nextState);
try {
const windowInfo = await createCrawlWindow(firstStep.url);
let tabId: number | undefined;
try {
if (windowInfo.id) {
tabId = await getWindowActiveTabId(windowInfo.id);
}
} catch {
tabId = undefined;
}
const stateWithWindow = { ...nextState, windowId: windowInfo.id, tabId };
const controller = new AbortController();
await setCrawlTaskState(stateWithWindow);
activeCrawlControllers.set(stateWithWindow.id, controller);
void runCrawlSteps(platform, stateWithWindow, controller.signal).finally(() => {
activeCrawlControllers.delete(stateWithWindow.id);
});
return { ok: true, data: stateWithWindow };
} catch (error: unknown) {
const failedState: CrawlTaskState = {
...nextState,
status: 'failed',
steps: nextState.steps.map((step, index) =>
index === 0 ? { ...step, status: 'failed', message: '打开平台窗口失败' } : step,
),
};
await setCrawlTaskState(failedState);
return { ok: false, data: failedState, error: error instanceof Error ? error.message : '打开平台窗口失败' };
}
}
/**
* 取消当前爬取任务,并尝试关闭正在爬取的平台窗口。
*/
export async function cancelCrawl(): Promise<CrawlStateResponse> {
const state = await getCrawlTaskState();
if (!state) {
return { ok: true, data: null };
}
abortActiveCrawl(state.id);
clearAutoCloseTimer(state.id);
const canceledState: CrawlTaskState = {
...state,
status: 'canceled',
autocloseAt: state.windowId ? Date.now() + DEFAULT_AUTOCLOSE_DELAY_MS : null,
steps: state.steps.map((step, index) =>
index === state.currentStepIndex && step.status === 'running'
? { ...step, status: 'failed', message: '用户取消爬取任务' }
: step,
),
};
await setCrawlTaskState(canceledState);
if (canceledState.windowId) {
scheduleAutoCloseWindow(canceledState.id, canceledState.windowId, canceledState.autocloseAt);
}
return { ok: true, data: canceledState };
}
/**
* 用户处理完登录、验证码或风控后,恢复当前暂停中的爬取任务。
*/
export async function resumeCrawl(): Promise<CrawlStateResponse> {
const state = await getCrawlTaskState();
if (!state || state.status !== 'paused') {
return { ok: true, data: state };
}
const resumedState: CrawlTaskState = {
...state,
status: 'running',
pause: undefined,
steps: state.steps.map((step, index) =>
index === state.currentStepIndex ? { ...step, status: 'running', message: undefined } : step,
),
};
await setCrawlTaskState(resumedState);
return { ok: true, data: resumedState };
}
/**
* 窗口关闭后,如果关闭的是爬取窗口,就把当前任务标记为取消。
*/
export async function cancelCrawlWhenWindowRemoved(windowId: number): Promise<void> {
const state = await getCrawlTaskState();
if (state?.windowId !== windowId || !['running', 'paused'].includes(state.status)) {
return;
}
abortActiveCrawl(state.id);
clearAutoCloseTimer(state.id);
await setCrawlTaskState({
...state,
status: 'canceled',
autocloseAt: null,
steps: state.steps.map((step, index) =>
index === state.currentStepIndex ? { ...step, status: 'failed', message: '爬取窗口已关闭' } : step,
),
});
}
export async function cancelStaleCrawlWhenWindowMissing(): Promise<void> {
const state = await getCrawlTaskState();
if (!state || !['running', 'paused'].includes(state.status)) {
return;
}
const isWindowAlive = state.windowId ? await hasWindow(state.windowId) : false;
if (isWindowAlive) {
return;
}
abortActiveCrawl(state.id);
clearAutoCloseTimer(state.id);
await setCrawlTaskState({
...state,
status: 'canceled',
autocloseAt: null,
steps: state.steps.map((step, index) =>
index === state.currentStepIndex ? { ...step, status: 'failed', message: '爬取窗口已关闭,任务已取消' } : step,
),
});
}
function abortActiveCrawl(taskId: string): void {
activeCrawlControllers.get(taskId)?.abort();
}
/**
* 取消终态自动关窗overlay“保持打开”
*/
export async function cancelAutoclose(): Promise<CrawlStateResponse> {
const state = await getCrawlTaskState();
if (!state) {
return { ok: true, data: null };
}
clearAutoCloseTimer(state.id);
const nextState: CrawlTaskState = {
...state,
autocloseAt: null,
};
await setCrawlTaskState(nextState);
return { ok: true, data: nextState };
}
/**
* 清理当前任务快照popup 的 Close/Dismiss。不强制关窗只影响 UI。
*/
export async function dismissCrawl(): Promise<CrawlStateResponse> {
const state = await getCrawlTaskState();
if (!state) {
return { ok: true, data: null };
}
clearAutoCloseTimer(state.id);
await clearCrawlTaskState();
return { ok: true, data: null };
}
function scheduleAutoCloseWindow(taskId: string, windowId: number, autocloseAt?: number | null): void {
if (!autocloseAt) {
return;
}
clearAutoCloseTimer(taskId);
const delayMs = Math.max(0, autocloseAt - Date.now());
const timer = setTimeout(() => {
autoCloseTimers.delete(taskId);
chrome.windows.remove(windowId).catch(() => undefined);
}, delayMs) as unknown as number;
autoCloseTimers.set(taskId, timer);
}
function clearAutoCloseTimer(taskId: string): void {
const timer = autoCloseTimers.get(taskId);
if (timer === undefined) {
return;
}
clearTimeout(timer);
autoCloseTimers.delete(taskId);
}
/**
* 按平台 steps 顺序执行页面跳转、DOM 等待、字段抓取和进度更新。
*/
async function runCrawlSteps(platform: PlatformConfig, initialState: CrawlTaskState, signal: AbortSignal): Promise<void> {
if (!initialState.windowId) {
return;
}
try {
for (let stepIndex = 0; stepIndex < platform.steps.length; stepIndex += 1) {
const step = platform.steps[stepIndex];
let shouldRetryStep = true;
while (shouldRetryStep) {
const currentState = await getCrawlTaskState();
if (signal.aborted || currentState?.id !== initialState.id || currentState.status === 'canceled') {
return;
}
if (currentState.status === 'paused') {
const resumed = await waitUntilResumed(initialState.id, signal);
if (!resumed) {
return;
}
}
await updateCrawlTaskState(initialState.id, (state) => ({
...state,
currentStepIndex: stepIndex,
status: 'running',
pause: undefined,
steps: state.steps.map((item, index) => ({
...item,
status: index === stepIndex ? 'running' : item.status,
message: index === stepIndex ? undefined : item.message,
})),
}));
const tabId = await getWindowActiveTabId(initialState.windowId);
await chrome.tabs.update(tabId, { url: step.url, active: true });
const tabLoaded = await waitForTabLoaded(tabId, signal);
if (!tabLoaded || signal.aborted) {
return;
}
const response = await scrapeStepInContent(tabId, step, signal);
if (signal.aborted) {
return;
}
if (response.interrupt) {
await pauseForInterrupt(initialState.id, stepIndex, response.interrupt);
const resumed = await waitUntilResumed(initialState.id, signal);
if (!resumed) {
return;
}
continue;
}
if (!response.ok) {
const message = response.error ?? '页面抓取失败';
await updateCrawlTaskState(initialState.id, (state) => ({
...state,
status: 'failed',
currentStepIndex: stepIndex,
steps: state.steps.map((item, index) =>
index === stepIndex ? { ...item, status: 'failed', message } : item,
),
}));
return;
}
console.log(`[crawl] ${platform.name} - ${step.name} 提取成功`, response.data);
await updateCrawlTaskState(initialState.id, (state) => ({
...state,
steps: state.steps.map((item, index) =>
index === stepIndex
? { ...item, status: 'success', message: undefined, result: response.data }
: item,
),
}));
shouldRetryStep = false;
}
}
const autocloseAt = initialState.windowId ? Date.now() + DEFAULT_AUTOCLOSE_DELAY_MS : null;
await updateCrawlTaskState(initialState.id, (state) => ({
...state,
status: 'completed',
autocloseAt,
steps: state.steps.map((step) => (step.status === 'running' ? { ...step, status: 'success' } : step)),
}));
if (initialState.windowId) {
scheduleAutoCloseWindow(initialState.id, initialState.windowId, autocloseAt);
}
} catch (error: unknown) {
console.error('[crawl] 执行失败', error);
const autocloseAt = initialState.windowId ? Date.now() + DEFAULT_AUTOCLOSE_DELAY_MS : null;
await updateCrawlTaskState(initialState.id, (state) => ({
...state,
status: 'failed',
autocloseAt,
steps: state.steps.map((step, index) =>
index === state.currentStepIndex && step.status === 'running'
? { ...step, status: 'failed', message: error instanceof Error ? error.message : '爬取执行失败' }
: step,
),
}));
if (initialState.windowId) {
scheduleAutoCloseWindow(initialState.id, initialState.windowId, autocloseAt);
}
}
}
/**
* 获取指定窗口中的活动 tab ID。
*/
async function getWindowActiveTabId(windowId: number): Promise<number> {
const tabs = await chrome.tabs.query({ windowId, active: true });
const tab = tabs[0];
if (!tab?.id) {
throw new Error('未找到爬取窗口中的标签页');
}
return tab.id;
}
/**
* 让 content script 直接在目标页面执行检查和抓取。
*/
async function scrapeStepInContent(
tabId: number,
step: PlatformStepConfig,
signal: AbortSignal,
): Promise<PageRunnerResponse> {
const startedAt = Date.now();
while (Date.now() - startedAt < 20000) {
if (signal.aborted) {
return { ok: false, error: 'canceled' };
}
const response = await sendPageRunnerMessage(tabId, {
action: 'SCRAPE_STEP',
payload: {
fields: step.fields,
checkSelector: step.checkSelector,
},
}, signal);
if (response.ok || response.interrupt || !isPageRunnerNotReadyError(response.error)) {
return response;
}
if (!(await sleep(500, signal))) {
return { ok: false, error: 'canceled' };
}
}
return { ok: false, error: '页面脚本未响应,请刷新扩展后重试' };
}
/**
* 给目标页的 content script 发送页面执行消息。
*/
async function sendPageRunnerMessage(tabId: number, message: unknown, signal: AbortSignal): Promise<PageRunnerResponse> {
if (signal.aborted) {
return { ok: false, error: 'canceled' };
}
return raceWithAbort(sendPageRunnerMessageOnce(tabId, message), signal);
}
async function sendPageRunnerMessageOnce(tabId: number, message: unknown): Promise<PageRunnerResponse> {
try {
const response = await chrome.tabs.sendMessage(tabId, message);
if (response && typeof response === 'object') {
return response as PageRunnerResponse;
}
return { ok: false, error: '页面脚本返回为空' };
} catch (error: unknown) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
}
/**
* 判断错误是否只是 content script 尚未注入完成。
*/
function isPageRunnerNotReadyError(error?: string): boolean {
if (!error) {
return false;
}
return /receiving end does not exist|could not establish connection|no receiving end/i.test(error);
}
/**
* 因登录、验证码或页面异常暂停当前任务。
*/
async function pauseForInterrupt(taskId: string, stepIndex: number, interrupt: CrawlPauseInfo): Promise<void> {
await updateCrawlTaskState(taskId, (state) => ({
...state,
status: 'paused',
pause: interrupt,
currentStepIndex: stepIndex,
steps: state.steps.map((step, index) =>
index === stepIndex ? { ...step, status: 'running', message: interrupt.message } : step,
),
}));
}
/**
* 暂停后等待用户点继续或取消。
*/
async function waitUntilResumed(taskId: string, signal: AbortSignal): Promise<boolean> {
while (true) {
if (signal.aborted) {
return false;
}
const state = await getCrawlTaskState();
if (!state || state.id !== taskId || state.status === 'canceled' || state.status === 'failed') {
return false;
}
if (state.status === 'running') {
return true;
}
if (!(await sleep(1000, signal))) {
return false;
}
}
}
/**
* 打开一个普通浏览器窗口承载目标平台页面。
*/
function createCrawlWindow(url: string): Promise<chrome.windows.Window> {
return new Promise((resolve, reject) => {
chrome.windows.create(
{
url,
type: 'popup',
focused: false,
state: 'normal',
width: 1280,
height: 900,
},
(windowInfo) => {
const runtimeError = chrome.runtime.lastError;
if (runtimeError) {
reject(new Error(runtimeError.message));
return;
}
if (!windowInfo?.id) {
reject(new Error('窗口创建失败'));
return;
}
void chrome.windows.update(windowInfo.id, { drawAttention: true }).catch(() => undefined);
resolve(windowInfo);
},
);
});
}
/**
* 等待 tab 完成页面加载。
*/
function waitForTabLoaded(tabId: number, signal: AbortSignal): Promise<boolean> {
return new Promise((resolve) => {
if (signal.aborted) {
resolve(false);
return;
}
const timeout = globalThis.setTimeout(() => {
cleanup();
resolve(true);
}, 15000);
function cleanup() {
globalThis.clearTimeout(timeout);
chrome.tabs.onUpdated.removeListener(handleUpdated);
signal.removeEventListener('abort', handleAbort);
}
function handleAbort() {
cleanup();
resolve(false);
}
function handleUpdated(updatedTabId: number, changeInfo: { status?: string }) {
if (updatedTabId === tabId && changeInfo.status === 'complete') {
cleanup();
resolve(true);
}
}
chrome.tabs.onUpdated.addListener(handleUpdated);
signal.addEventListener('abort', handleAbort, { once: true });
});
}
/**
* 简单等待工具。
*/
async function hasWindow(windowId: number): Promise<boolean> {
try {
await chrome.windows.get(windowId);
return true;
} catch {
return false;
}
}
function raceWithAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
return new Promise((resolve, reject) => {
if (signal.aborted) {
resolve({ ok: false, error: 'canceled' } as T);
return;
}
let isSettled = false;
function cleanup() {
signal.removeEventListener('abort', handleAbort);
}
function handleAbort() {
if (isSettled) {
return;
}
isSettled = true;
cleanup();
resolve({ ok: false, error: 'canceled' } as T);
}
signal.addEventListener('abort', handleAbort, { once: true });
promise.then(
(value) => {
if (isSettled) {
return;
}
isSettled = true;
cleanup();
resolve(value);
},
(error) => {
if (isSettled) {
return;
}
isSettled = true;
cleanup();
reject(error);
},
);
});
}
function sleep(ms: number, signal?: AbortSignal): Promise<boolean> {
return new Promise((resolve) => {
if (signal?.aborted) {
resolve(false);
return;
}
const timeout = globalThis.setTimeout(() => {
cleanup();
resolve(true);
}, ms);
function cleanup() {
globalThis.clearTimeout(timeout);
signal?.removeEventListener('abort', handleAbort);
}
function handleAbort() {
cleanup();
resolve(false);
}
signal?.addEventListener('abort', handleAbort, { once: true });
});
}

View File

@@ -1,26 +1,38 @@
import { platformConfigs } from '@/config/platforms'; import {platformConfigs} from '@/config/platforms';
import type { CrawlTaskState } from '@/types'; import type {CrawlTaskState} from '@/types';
import { cancelCrawl, startCrawl } from './crawlTask'; import {getCrawlTaskState} from "@/background/task/taskState";
import { getCrawlTaskState } from './taskState'; import {cancelCrawl, startCrawl} from "@/background/task/crawlTask";
import {setAuthState} from "@/shared/store";
/** 存储任务状态的 Key需与存储层保持一致 */
const CRAWL_TASK_STORAGE_KEY = 'crawlTaskState'; const CRAWL_TASK_STORAGE_KEY = 'crawlTaskState';
/** 外部通信的长连接端口名称 */
const EXTERNAL_PORT_NAME = 'DIANSHAN_CRAWL'; const EXTERNAL_PORT_NAME = 'DIANSHAN_CRAWL';
/** 定义外部(网页侧)可以发起的动作类型 */
type ExternalAction = type ExternalAction =
| 'DIANSHAN_PING' | 'DIANSHAN_PING' // 探测插件是否安装/活跃
| 'DIANSHAN_START_CRAWL' | 'DIANSHAN_AUTH_CHECK' // 网页侧检查扩展是否已配对
| 'DIANSHAN_GET_CRAWL_STATE' | 'DIANSHAN_SSO_HANDOFF' // 网页侧回传 extension token配对结果
| 'DIANSHAN_CANCEL_CRAWL' | 'DIANSHAN_START_CRAWL' // 从网页发起爬取
| 'STORE_AI_PING'; | 'DIANSHAN_GET_CRAWL_STATE' // 获取当前进度
| 'DIANSHAN_CANCEL_CRAWL' // 取消爬取
| 'STORE_AI_PING'; // 兼容性探测
/** 外部消息结构 */
interface ExternalMessage { interface ExternalMessage {
type?: ExternalAction; type?: ExternalAction;
action?: ExternalAction; action?: ExternalAction;
payload?: { payload?: {
platformId?: string; platformId?: string;
// 中文备注:网页侧配对后回传的登录信息(全部用 any按要求不定义复杂类型
authState?: any;
// 中文备注:兼容网页侧老参数(例如 ingestContext扩展侧目前不使用但保留以免网页传多字段报错。
[key: string]: any;
}; };
} }
/** 返回给网页的统一响应格式 */
interface ExternalResponse<T = unknown> { interface ExternalResponse<T = unknown> {
ok: boolean; ok: boolean;
success?: boolean; success?: boolean;
@@ -29,17 +41,23 @@ interface ExternalResponse<T = unknown> {
error?: string; error?: string;
} }
/** 网页侧接收到的复合载荷:包含任务状态和(完成后的)抓取结果 */
interface CrawlWebPayload { interface CrawlWebPayload {
state: CrawlTaskState | null; state: CrawlTaskState | null;
result: Record<string, unknown> | null; result: Record<string, unknown> | null;
} }
/** 维护当前所有已连接的网页端口(用于实时广播进度) */
const externalPorts = new Set<chrome.runtime.Port>(); const externalPorts = new Set<chrome.runtime.Port>();
/**
* 处理外部网页发送的单次指令(一问一答模式)
*/
export async function handleExternalMessage(message: ExternalMessage): Promise<ExternalResponse> { export async function handleExternalMessage(message: ExternalMessage): Promise<ExternalResponse> {
const action = message.type ?? message.action; const action = message.type ?? message.action;
switch (action) { switch (action) {
// 插件存活探测:返回版本号及支持的平台列表
case 'STORE_AI_PING': case 'STORE_AI_PING':
case 'DIANSHAN_PING': case 'DIANSHAN_PING':
return { return {
@@ -53,25 +71,68 @@ export async function handleExternalMessage(message: ExternalMessage): Promise<E
})), })),
}, },
}; };
// 网页发起爬取指令
case 'DIANSHAN_START_CRAWL': case 'DIANSHAN_START_CRAWL':
return startCrawlForWebsite(message.payload?.platformId); return startCrawlForWebsite(message.payload?.platformId);
// 网页侧检查扩展是否已配对(是否已存 extension token
case 'DIANSHAN_AUTH_CHECK': {
const res = await chrome.storage.local.get("auth_state");
const auth: any = res?.["auth_state"];
return {
ok: true,
success: true,
type: "DIANSHAN_AUTH_STATE",
data: {
authed: !!auth?.token,
userEmail: auth?.userEmail ?? null,
},
};
}
// 网页侧把配对得到的 extension token 回传给扩展
case 'DIANSHAN_SSO_HANDOFF': {
const authState = message.payload?.authState;
if (!authState?.token) {
return {ok: false, error: 'missing_extension_token'};
}
await setAuthState(authState);
return {
ok: true,
success: true,
type: "DIANSHAN_SSO_OK",
data: {saved: true},
};
}
// 主动查询当前进度
case 'DIANSHAN_GET_CRAWL_STATE': case 'DIANSHAN_GET_CRAWL_STATE':
return { return {
ok: true, ok: true,
data: buildCrawlWebPayload(await getCrawlTaskState()), data: buildCrawlWebPayload(await getCrawlTaskState()),
}; };
// 网页强制取消任务
case 'DIANSHAN_CANCEL_CRAWL': case 'DIANSHAN_CANCEL_CRAWL':
await cancelCrawl(); await cancelCrawl();
return { return {
ok: true, ok: true,
data: buildCrawlWebPayload(null), data: buildCrawlWebPayload(null),
}; };
default: default:
return { ok: false, error: 'unknown_external_action' }; return {ok: false, error: 'unknown_external_action'};
} }
} }
/**
* 处理外部网页的长连接请求(用于实时推送进度)
*/
export function handleExternalConnect(port: chrome.runtime.Port): void { export function handleExternalConnect(port: chrome.runtime.Port): void {
// 只接受指定名称的端口连接
if (port.name !== EXTERNAL_PORT_NAME) { if (port.name !== EXTERNAL_PORT_NAME) {
port.disconnect(); port.disconnect();
return; return;
@@ -79,6 +140,7 @@ export function handleExternalConnect(port: chrome.runtime.Port): void {
externalPorts.add(port); externalPorts.add(port);
// 连接建立时,立即推送一次当前状态
getCrawlTaskState() getCrawlTaskState()
.then((state) => { .then((state) => {
postToExternalPort(port, { postToExternalPort(port, {
@@ -95,6 +157,7 @@ export function handleExternalConnect(port: chrome.runtime.Port): void {
}); });
}); });
// 监听网页通过长连接发送的消息
port.onMessage.addListener((message: ExternalMessage) => { port.onMessage.addListener((message: ExternalMessage) => {
void handleExternalMessage(message) void handleExternalMessage(message)
.then((response) => { .then((response) => {
@@ -109,24 +172,26 @@ export function handleExternalConnect(port: chrome.runtime.Port): void {
}); });
}); });
// 端口断开(网页关闭)时,从集合中移除
port.onDisconnect.addListener(() => { port.onDisconnect.addListener(() => {
externalPorts.delete(port); externalPorts.delete(port);
}); });
} }
/**
* 监听 Storage 变化并广播给所有网页端口
* 这是实现网页端进度条“丝滑跳动”的核心逻辑
*/
export function broadcastCrawlStorageChange(changes: Record<string, chrome.storage.StorageChange>, areaName: string): void { export function broadcastCrawlStorageChange(changes: Record<string, chrome.storage.StorageChange>, areaName: string): void {
if (areaName !== 'local') { if (areaName !== 'local') return;
return;
}
const change = changes[CRAWL_TASK_STORAGE_KEY]; const change = changes[CRAWL_TASK_STORAGE_KEY];
if (!change) return;
if (!change) {
return;
}
const nextState = isCrawlTaskState(change.newValue) ? change.newValue : null; const nextState = isCrawlTaskState(change.newValue) ? change.newValue : null;
const oldState = isCrawlTaskState(change.oldValue) ? change.oldValue : null; const oldState = isCrawlTaskState(change.oldValue) ? change.oldValue : null;
// 根据状态变化确定通知类型(如:已完成、已取消、普通更新)
const type = getBroadcastType(nextState, oldState); const type = getBroadcastType(nextState, oldState);
broadcastToExternalPorts({ broadcastToExternalPorts({
@@ -136,17 +201,30 @@ export function broadcastCrawlStorageChange(changes: Record<string, chrome.stora
}); });
} }
/**
* 封装网页调起的爬取逻辑:做一层错误兼容处理
*/
async function startCrawlForWebsite(platformId?: string): Promise<ExternalResponse<CrawlWebPayload>> { async function startCrawlForWebsite(platformId?: string): Promise<ExternalResponse<CrawlWebPayload>> {
const response = 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);
const state = !isError ? (response as CrawlTaskState) : null;
return { return {
ok: response.ok, ok: !isError,
type: 'DIANSHAN_CRAWL_STARTED', type: 'DIANSHAN_CRAWL_STARTED',
data: buildCrawlWebPayload(response.data ?? null), data: buildCrawlWebPayload(state),
error: response.error, error: isError ? String(response.error ?? 'start_failed') : undefined,
}; };
} }
/**
* 构建网页端专用的数据载荷
* 如果任务已完成,则顺便把所有抓取到的结果打包带走
*/
function buildCrawlWebPayload(state: CrawlTaskState | null): CrawlWebPayload { function buildCrawlWebPayload(state: CrawlTaskState | null): CrawlWebPayload {
return { return {
state, state,
@@ -154,6 +232,9 @@ function buildCrawlWebPayload(state: CrawlTaskState | null): CrawlWebPayload {
}; };
} }
/**
* 汇总任务中所有步骤的抓取结果
*/
function collectStepResults(state: CrawlTaskState): Record<string, unknown> { function collectStepResults(state: CrawlTaskState): Record<string, unknown> {
return Object.fromEntries( return Object.fromEntries(
state.steps.map((step) => [ state.steps.map((step) => [
@@ -168,40 +249,40 @@ function collectStepResults(state: CrawlTaskState): Record<string, unknown> {
); );
} }
/**
* 根据状态机的变化,转换成对应的外部事件名称
*/
function getBroadcastType(nextState: CrawlTaskState | null, oldState: CrawlTaskState | null): string { function getBroadcastType(nextState: CrawlTaskState | null, oldState: CrawlTaskState | null): string {
if (!nextState) { if (!nextState) {
return oldState ? 'DIANSHAN_CRAWL_CLEARED' : 'DIANSHAN_CRAWL_STATE'; return oldState ? 'DIANSHAN_CRAWL_CLEARED' : 'DIANSHAN_CRAWL_STATE';
} }
if (nextState.status === 'completed') { switch (nextState.status) {
return 'DIANSHAN_CRAWL_DONE'; case 'completed': return 'DIANSHAN_CRAWL_DONE';
case 'failed': return 'DIANSHAN_CRAWL_FAILED';
case 'canceled': return 'DIANSHAN_CRAWL_CANCELED';
default: return 'DIANSHAN_CRAWL_STATE';
} }
if (nextState.status === 'failed') {
return 'DIANSHAN_CRAWL_FAILED';
}
if (nextState.status === 'canceled') {
return 'DIANSHAN_CRAWL_CANCELED';
}
return 'DIANSHAN_CRAWL_STATE';
} }
/** 向所有已连接的网页广播消息 */
function broadcastToExternalPorts(message: ExternalResponse<CrawlWebPayload>): void { function broadcastToExternalPorts(message: ExternalResponse<CrawlWebPayload>): void {
for (const port of externalPorts) { for (const port of externalPorts) {
postToExternalPort(port, message); postToExternalPort(port, message);
} }
} }
/** 向单个端口发送消息,并处理连接失效的情况 */
function postToExternalPort(port: chrome.runtime.Port, message: ExternalResponse): void { function postToExternalPort(port: chrome.runtime.Port, message: ExternalResponse): void {
try { try {
port.postMessage(message); port.postMessage(message);
} catch { } catch {
// 如果发送失败(通常因为网页已关闭),则强制清理
externalPorts.delete(port); externalPorts.delete(port);
} }
} }
/** 类型守卫:判断对象是否为有效的任务状态 */
function isCrawlTaskState(value: unknown): value is CrawlTaskState { 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;
} }

View File

@@ -1,59 +0,0 @@
import type { BackgroundCommand, BackgroundResponse, CrawlStateResponse } from '../types';
import {
cancelAutoclose,
cancelCrawl,
cancelCrawlWhenWindowRemoved,
cancelStaleCrawlWhenWindowMissing,
dismissCrawl,
resumeCrawl,
startCrawl,
} from './crawlTask';
import { getCrawlTaskState } from './taskState';
/**
* 扩展安装完成时的初始化入口,当前仅保留日志方便调试生命周期。
*/
export async function handleInstalled(): Promise<void> {
console.log('[background] installed');
}
/**
* 浏览器启动并加载扩展时的初始化入口,当前仅保留日志方便调试生命周期。
*/
export async function handleStartup(): Promise<void> {
console.log('[background] startup');
await cancelStaleCrawlWhenWindowMissing();
}
/**
* 监听窗口关闭事件;如果关闭的是爬取窗口,就把当前任务标记为取消。
*/
export async function handleWindowRemoved(windowId: number): Promise<void> {
console.log('[background] window removed', windowId);
await cancelCrawlWhenWindowRemoved(windowId);
}
/**
* 根据 popup/content 发来的 action 分发到对应的后台处理函数。
*/
export async function handleBackgroundCommand(
message: BackgroundCommand,
): Promise<BackgroundResponse | CrawlStateResponse> {
switch (message.action) {
case 'START_CRAWL':
return startCrawl(message.payload.platformId);
case 'GET_CRAWL_STATE':
await cancelStaleCrawlWhenWindowMissing();
return { ok: true, data: await getCrawlTaskState() };
case 'CANCEL_CRAWL':
return cancelCrawl();
case 'RESUME_CRAWL':
return resumeCrawl();
case 'CANCEL_AUTOCLOSE':
return cancelAutoclose();
case 'DISMISS_CRAWL':
return dismissCrawl();
default:
return { ok: false, error: '未知的后台指令' };
}
}

View File

@@ -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;
}

View File

@@ -1,47 +0,0 @@
import type { CrawlTaskState } from '@/types';
const CRAWL_TASK_STORAGE_KEY = 'crawlTaskState';
export async function getCrawlTaskState(): Promise<CrawlTaskState | null> {
const result = await chrome.storage.local.get(CRAWL_TASK_STORAGE_KEY);
const state = result[CRAWL_TASK_STORAGE_KEY];
return isCrawlTaskState(state) ? state : null;
}
export async function setCrawlTaskState(state: CrawlTaskState): Promise<void> {
await chrome.storage.local.set({ [CRAWL_TASK_STORAGE_KEY]: state });
broadcastToCrawlTab(state);
}
export async function clearCrawlTaskState(): Promise<void> {
await chrome.storage.local.remove(CRAWL_TASK_STORAGE_KEY);
}
export async function updateCrawlTaskState(
taskId: string,
updater: (state: CrawlTaskState) => CrawlTaskState,
): Promise<void> {
const state = await getCrawlTaskState();
if (!state || state.id !== taskId || state.status === 'canceled') {
return;
}
await setCrawlTaskState(updater(state));
}
function broadcastToCrawlTab(state: CrawlTaskState): void {
if (!state.tabId) {
return;
}
try {
void chrome.tabs.sendMessage(state.tabId, { type: 'crawl_state_update', state }).catch(() => undefined);
} catch {
// ignore
}
}
function isCrawlTaskState(value: unknown): value is CrawlTaskState {
return typeof value === 'object' && value !== null && 'id' in value && 'steps' in value;
}

View File

@@ -0,0 +1,373 @@
import {getPlatformById} from "@/config/platforms";
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<string, AbortController>();
/**
* 创建新的爬取任务:打开目标平台窗口,并把初始状态写入 storage
* @param platformId 平台 id
* @param trigger 触发来源manual/scheduled按 API 文档)
*/
export async function startCrawl(platformId: string, trigger: any = "manual"): Promise<any> {
// 中文备注:没有 extension token 就不允许开始爬取,否则最后上传会失败(也不符合产品预期)
const auth = getAuthState();
if (!auth?.token) {
return {error: "not_authed"};
}
// 中文备注:开始爬取前先向后端确认订阅/权限(扩展 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}`,
windowId: windowInfo.windowId,
tabId: windowInfo.tabId,
platformId: platform.id,
platformName: platform.name,
startedAt,
status: "running",
currentStepIndex: 0,
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;
}
/**
* 按平台 steps 顺序执行页面跳转、DOM 等待、字段抓取、进度更新
*/
async function runCrawlSteps(taskId: string, tabId: number, steps: PlatformStepConfig[], signal: AbortSignal, startIndex = 0) {
// 中文备注startIndex 用于“继续/恢复”场景,从上次没爬完的步骤开始跑。
for (let i = startIndex; i < steps.length; i += 1) {
const step = steps[i];
let shouldRetryStep = true;
// 进入新 step更新 currentStepIndex + step 状态
await updateCrawlTaskState(taskId, (s) => ({
...s,
currentStepIndex: i,
steps: s.steps.map((stepItem, idx) => ({
...stepItem,
status: idx === i ? "running" : stepItem.status,
})),
}));
while (shouldRetryStep) {
if (signal.aborted) return;
// 1) 跳转到目标 URL 并等待加载完成
await chrome.tabs.update(tabId, {url: step.url, active: true});
const loaded = await waitForTabLoaded(tabId, signal);
if (!loaded) return;
// 2) 交给 content script 抓取
const res: any = await scrapeStepInContent(tabId, step, signal);
if (signal.aborted) return;
// 3) 处理“需要人工介入”的中断(登录/验证码/页面不存在等)
if (res.interrupt) {
await updateCrawlTaskState(taskId, (s) => ({...s, status: "paused", pause: res.interrupt}));
// 死等恢复:直到 UI 把状态从 paused 改回 running
while ((await getCrawlTaskState())?.status === "paused") {
if (signal.aborted) return;
if (!(await sleep(1000, signal))) return;
}
continue;
}
// 4) 处理抓取结果
if (res.ok) {
await updateCrawlTaskState(taskId, (s) => ({
...s,
steps: s.steps.map((item, idx) => (idx === i ? {...item, status: "success", result: res.data} : item)),
}));
shouldRetryStep = false;
} else {
// 抓取失败重试
if (!(await sleep(2000, signal))) return;
}
}
}
// 全部步骤完成:标记 completed 并统一收尾(上传 -> 清理 -> 关窗)
await updateCrawlTaskState(taskId, (s) => ({...s, status: "completed"}));
await finalizeCompletedTask(taskId, signal);
}
/**
* 取消当前爬取任务:停止执行器、清空记录、关闭爬取窗口
*/
export async function cancelCrawl() {
const state = await getCrawlTaskState();
if (!state) return;
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);
}
}
/**
* 关闭/忽略当前任务的 UI 提示:只清空状态并尝试关闭窗口(不额外走取消流程)
*/
export async function dismissCrawl(): Promise<void> {
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<void> {
const state = await getCrawlTaskState();
if (!state) return;
if (state.status !== "running") return;
if (state.windowId !== windowId) return;
const controller = activeCrawlControllers.get(state.id);
if (controller) {
controller.abort();
activeCrawlControllers.delete(state.id);
}
await updateCrawlTaskState(state.id, (s) => ({
...s,
status: "paused",
pause: {
reason: "window_closed",
message: "检测到爬取窗口被关闭。点击“继续”后将重新打开窗口,并从上次进度继续爬取。",
},
// 中文备注:窗口/tab 已不存在,置空避免 UI 侧再尝试聚焦旧窗口。
windowId: undefined,
tabId: undefined,
}));
}
/**
* 爬取 tab 被关闭:同样按“窗口被关闭”暂停处理(兜底)
*/
export async function pauseCrawlOnTabRemoved(tabId: number): Promise<void> {
const state = await getCrawlTaskState();
if (!state) return;
if (state.status !== "running") return;
if (state.tabId !== tabId) return;
const controller = activeCrawlControllers.get(state.id);
if (controller) {
controller.abort();
activeCrawlControllers.delete(state.id);
}
await updateCrawlTaskState(state.id, (s) => ({
...s,
status: "paused",
pause: {
reason: "window_closed",
message: "检测到爬取页面被关闭。点击“继续”后将重新打开窗口,并从上次进度继续爬取。",
},
windowId: undefined,
tabId: undefined,
}));
}
/**
* 继续/恢复暂停任务
*/
export async function resumeCrawl(): Promise<CrawlTaskState | null> {
const state = await getCrawlTaskState();
if (!state) return null;
if (state.status !== "paused") {
return state;
}
// 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) 窗口被关闭导致的暂停:需要重开窗口并从未完成 step 继续
const platform = getPlatformById(state.platformId);
if (!platform) return state;
const resumeIndex = Math.max(0, Math.min(state.currentStepIndex ?? 0, platform.steps.length - 1));
// 中文备注:从 currentStepIndex 往后找第一个未 success 的 step
let startIndex = resumeIndex;
for (let i = resumeIndex; i < state.steps.length; i += 1) {
if (state.steps[i]?.status !== "success") {
startIndex = i;
break;
}
}
const openUrl = platform.steps[startIndex]?.url ?? platform.steps[resumeIndex]?.url ?? platform.steps[0].url;
const windowInfo = await openSingleTabWindow(openUrl);
const nextState: CrawlTaskState = {
...state,
windowId: windowInfo.windowId,
tabId: windowInfo.tabId,
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,
})),
};
await setCrawlTaskState(nextState);
const controller = new AbortController();
activeCrawlControllers.set(nextState.id, controller);
void runCrawlSteps(nextState.id, nextState.tabId!, platform.steps, controller.signal, startIndex).finally(() => {
activeCrawlControllers.delete(nextState.id);
});
return nextState;
}
/**
* 完成后的统一收尾:扩展直传后端 -> 广播完成 -> 清空任务 -> 关窗
*/
async function finalizeCompletedTask(taskId: string, signal: AbortSignal) {
const state = await getCrawlTaskState();
if (!state || state.id !== taskId) return;
if (state.status !== "completed") return;
// 中文备注:按新需求,爬取完成后由扩展直接提交到后端(不再交给网页提交)
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", {
taskId: state.id,
platformId: state.platformId,
platformName: state.platformName,
startedAt: state.startedAt,
result: collectStepResults(state),
});
}
// 2) 留一点时间让 storage 广播完成态出队
await sleep(300, signal);
// 3) 清空任务记录popup/overlay 会收到 storage 变化自动重置 UI
await clearCrawlTaskState();
// 4) 关闭爬取窗口
if (state.windowId) {
chrome.windows.remove(state.windowId).catch(() => undefined);
}
}
function collectStepResults(state: CrawlTaskState): Record<string, unknown> {
return Object.fromEntries(
state.steps.map((step) => [
step.uniqueKey,
{
name: step.name,
status: step.status,
result: step.result ?? null,
message: step.message ?? null,
},
]),
);
}

View File

@@ -0,0 +1,171 @@
import {CrawlPauseInfo, PlatformStepConfig} from "@/types";
import {DomScrapeResult} from "@/background/domScraper";
/**
* 打开一个纯净的单标签窗口,并提醒用户注意
* @param url 目标网址
*/
export async function openSingleTabWindow(url: string) {
return new Promise<{ windowId: number; tabId: number }>((resolve, reject) => {
chrome.windows.create({
url,
type: 'popup',
width: 1260,
height: 900,
focused: true // 初始设为聚焦,方便窗口弹出
}, (win) => {
// 1. 检查创建是否报错
if (chrome.runtime.lastError) {
return reject(new Error(chrome.runtime.lastError.message));
}
if (win?.id && win.tabs?.[0]?.id) {
// 2. 让窗口在任务栏“闪烁”,提醒用户(比如处理登录或验证码)
// 使用 void 表示不等待结果catch 防止窗口意外关闭导致崩溃
void chrome.windows.update(win.id, { drawAttention: true }).catch(() => {});
// 3. 返回双 ID 供后续爬取逻辑使用
resolve({
windowId: win.id,
tabId: win.tabs[0].id
});
} else {
reject(new Error('窗口初始化失败'));
}
});
});
}
/**
* 等待指定的标签页加载完成
* @param tabId 标签页ID
* @param signal 中断信号
*/
export function waitForTabLoaded(tabId: number, signal: AbortSignal): Promise<boolean> {
return new Promise((resolve) => {
if (signal.aborted) {
resolve(false);
return;
}
const timeout = globalThis.setTimeout(() => {
cleanup();
resolve(true);
}, 15000);
function cleanup() {
globalThis.clearTimeout(timeout);
chrome.tabs.onUpdated.removeListener(handleUpdated);
signal.removeEventListener('abort', handleAbort);
}
function handleAbort() {
cleanup();
resolve(false);
}
function handleUpdated(updatedTabId: number, changeInfo: { status?: string }) {
if (updatedTabId === tabId && changeInfo.status === 'complete') {
cleanup();
resolve(true);
}
}
chrome.tabs.onUpdated.addListener(handleUpdated);
signal.addEventListener('abort', handleAbort, {once: true});
});
}
/**
* 让 content script 在目标页面执行抓取或探测
*/
interface PageRunnerResponse {
ok: boolean;
data?: DomScrapeResult | null;
interrupt?: CrawlPauseInfo;
error?: string;
}
export async function scrapeStepInContent(tabId: number, step: PlatformStepConfig, signal: AbortSignal): Promise<PageRunnerResponse> {
const startTime = Date.now();
const TIMEOUT = 20000; // 最多等 20 秒
while (Date.now() - startTime < TIMEOUT) {
if (signal.aborted) return {ok: false, error: 'canceled'};
try {
// 给 Content Script 发消息
const res: any = await chrome.tabs.sendMessage(tabId, {
action: 'SCRAPE_STEP',
payload: {
fields: step.fields, // 要抓哪些字段
checkSelector: step.checkSelector // 用来检测是否“撞盾”的特征选择器
}
});
// 情况 1撞盾了比如检测到了登录框、验证码
// Content Script 发现特征后会返回 interrupt 对象
if (res.interrupt) {
return res;
}
// 情况 2抓取成功
if (res.ok) {
return res;
}
// 情况 3如果 res.ok 是 false 且没有 interrupt说明页面还没渲染出来
// 继续循环重试
} catch (err: any) {
// 特殊处理:如果报错是“接收端不存在”,说明 Content Script 还没加载完
// 这属于正常情况,忽略它,等下一轮循环重试
if (!err.message.includes('receiving end does not exist')) {
console.warn('通信异常:', err.message);
}
}
// 等 500ms 再问下一次
const canContinue = await sleep(500, signal);
if (!canContinue) break;
}
return {ok: false, error: '页面响应超时,可能需要刷新'};
}
/**
* 延迟指定毫秒数,并支持随时中断
* @param ms 延迟毫秒数
* @param signal 中断信号
* @returns {Promise<boolean>} 返回 true 表示等完了,返回 false 表示被中断了
*/
export function sleep(ms: number, signal?: AbortSignal): Promise<boolean> {
return new Promise((resolve) => {
// 1. 如果信号已经中断了,直接返回 false
if (signal?.aborted) {
return resolve(false);
}
// 2. 正常设置定时器
const timer = setTimeout(() => {
cleanup();
resolve(true);
}, ms);
// 3. 定义清理逻辑
const cleanup = () => {
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
};
// 4. 监听中断事件
const onAbort = () => {
cleanup();
resolve(false); // 一旦中断,立刻返回 false
};
// 5. 注册监听(只监听一次)
signal?.addEventListener('abort', onAbort, {once: true});
});
}

View File

@@ -0,0 +1,54 @@
import type {CrawlTaskState} from '@/types';
import {sendTabMessage} from "@/shared/tab";
const CRAWL_TASK_STORAGE_KEY = 'crawlTaskState';
/**
* 从本地存储中获取当前的爬取任务状态
* @returns {Promise<CrawlTaskState | null>} 返回任务状态对象,如果不存在或数据非法则返回 null
*/
export async function getCrawlTaskState(): Promise<CrawlTaskState | null> {
const result = await chrome.storage.local.get(CRAWL_TASK_STORAGE_KEY);
const state = result[CRAWL_TASK_STORAGE_KEY];
return (state as CrawlTaskState) || null;
}
/**
* 设置并保存爬取任务状态,并同步广播给对应的标签页
* @param {CrawlTaskState} state - 需要保存的新状态对象
*/
export async function setCrawlTaskState(state: CrawlTaskState): Promise<void> {
// 持久化到本地存储
await chrome.storage.local.set({[CRAWL_TASK_STORAGE_KEY]: state});
// 将更新后的状态发送给正在执行任务的标签页内容脚本
if (!state.tabId) return
sendTabMessage(state.tabId, 'CRAWL_STATE_UPDATE', state)
}
/**
* 从本地存储中清除当前的爬取任务状态(通常用于任务结束或彻底重置)
*/
export async function clearCrawlTaskState(): Promise<void> {
await chrome.storage.local.remove(CRAWL_TASK_STORAGE_KEY);
}
/**
* 局部更新爬取任务状态
* 只有当任务 ID 匹配且任务未被取消时,才会执行更新逻辑
* @param {string} taskId - 任务的唯一标识符
* @param {(state: CrawlTaskState) => CrawlTaskState} updater - 接收旧状态并返回新状态的回调函数
*/
export async function updateCrawlTaskState(
taskId: string,
updater: (state: CrawlTaskState) => CrawlTaskState,
): Promise<void> {
const state = await getCrawlTaskState();
// 检查任务是否存在、ID 是否一致、以及任务是否已被标记为取消
if (!state || state.id !== taskId || state.status === 'canceled') {
return;
}
// 执行更新并保存
await setCrawlTaskState(updater(state));
}

View File

@@ -1,54 +1,5 @@
import type { CrawlTaskState } from '@/types'; import type { CrawlTaskState } from '@/types';
// 启动爬取任务的后台消息。
export interface StartCrawlCommand {
// 消息动作类型:请求 background 创建爬取窗口并初始化任务状态。
action: 'START_CRAWL';
// 启动爬取所需参数。
payload: {
// 当前要爬取的平台 ID对应 config/platforms.ts 中的平台配置。
platformId: string;
};
}
// 获取当前爬取任务状态的后台消息。
export interface GetCrawlStateCommand {
// 消息动作类型:请求 background 返回当前任务快照。
action: 'GET_CRAWL_STATE';
}
// 取消当前爬取任务的后台消息。
export interface CancelCrawlCommand {
// 消息动作类型:请求 background 标记任务取消并关闭爬取窗口。
action: 'CANCEL_CRAWL';
}
// 继续当前暂停中的爬取任务。
export interface ResumeCrawlCommand {
// 消息动作类型:用户已处理登录/验证码,允许 background 继续重试当前步骤。
action: 'RESUME_CRAWL';
}
// 取消终态自动关窗(保持窗口打开)的后台消息。
export interface CancelAutocloseCommand {
// 消息动作类型:用户在 overlay 中点“保持打开”,阻止 background 自动关闭爬取窗口。
action: 'CANCEL_AUTOCLOSE';
}
// 清理当前爬取任务快照(用于 popup 的 Dismiss/Close
export interface DismissCrawlCommand {
// 消息动作类型:清空 crawlTaskState让 popup 回到 idle。
action: 'DISMISS_CRAWL';
}
// popup/content script 能发送给 background 的全部消息类型。
export type BackgroundCommand =
| StartCrawlCommand
| GetCrawlStateCommand
| CancelCrawlCommand
| ResumeCrawlCommand
| CancelAutocloseCommand
| DismissCrawlCommand;
// background 统一响应结构。 // background 统一响应结构。
export interface BackgroundResponse<T = unknown> { export interface BackgroundResponse<T = unknown> {

12
src/config.ts Normal file
View File

@@ -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;

View File

@@ -47,11 +47,12 @@ export function mountCrawlOverlay(): void {
refs.host.style.display = 'none'; refs.host.style.display = 'none';
maskHost = buildMaskHost(); maskHost = buildMaskHost();
// State broadcasts are targeted to the crawl tab only (background knows tabId). // 中文备注:接收 background 通过 sendTabMessage(tabId, 'CRAWL_STATE_UPDATE', state) 推送的任务状态。
// 旧逻辑使用 type='crawl_state_update',这里改为适配当前的 action/payload 协议。
chrome.runtime.onMessage.addListener((raw) => { chrome.runtime.onMessage.addListener((raw) => {
const msg = raw as { type?: string; state?: unknown } | undefined; const msg = raw as { action?: string; payload?: unknown } | undefined;
if (msg?.type === 'crawl_state_update') { if (msg?.action === 'CRAWL_STATE_UPDATE') {
applyState(isCrawlTaskState(msg.state) ? (msg.state as CrawlTaskState) : null); applyState(isCrawlTaskState(msg.payload) ? (msg.payload as CrawlTaskState) : null);
} }
return false; return false;
}); });
@@ -114,14 +115,14 @@ function applyState(next: CrawlTaskState | null): void {
// Mask blocks page interaction while running. Paused lifts mask so user can solve captcha/login. // Mask blocks page interaction while running. Paused lifts mask so user can solve captcha/login.
setMaskActive(next.status === 'running'); setMaskActive(next.status === 'running');
// Auto-collapse once per task, only while actively running. // 中文备注:默认折叠为左下角“圆形计时菜单”,用户点击后再展开查看进度。
if (!hasExpandedOnceForThisTask && next.status === 'running') { if (!hasExpandedOnceForThisTask && next.status === 'running') {
hasExpandedOnceForThisTask = true; hasExpandedOnceForThisTask = true;
setCollapsed(false); setCollapsed(true);
if (autoCollapseTimer) window.clearTimeout(autoCollapseTimer); if (autoCollapseTimer) {
autoCollapseTimer = window.setTimeout(() => { window.clearTimeout(autoCollapseTimer);
if (currentState?.status === 'running') setCollapsed(true); autoCollapseTimer = null;
}, 3000); }
} }
if (next.status === 'paused') { if (next.status === 'paused') {
@@ -230,10 +231,16 @@ function render(state: CrawlTaskState): void {
refs.autocloseBanner.style.display = 'none'; refs.autocloseBanner.style.display = 'none';
} }
// Capsule text // Capsule text
const capsuleText = refs.capsule.querySelector('.capsule-text') as HTMLSpanElement | null; // 中文备注圆形菜单上只显示计时mm:ss
if (capsuleText) { const capsuleTime = refs.capsule.querySelector('.capsule-time') as HTMLSpanElement | null;
capsuleText.textContent = if (capsuleTime) {
capsuleTime.textContent = time;
}
const capsuleText = refs.capsule.querySelector('.capsule-text') as HTMLSpanElement | null;
if (capsuleText) {
capsuleText.textContent =
phase === 'paused' phase === 'paused'
? '已暂停' ? '已暂停'
: isTerminal(state.status) : isTerminal(state.status)
@@ -281,7 +288,8 @@ function buildDom(): OverlayRefs {
host.id = OVERLAY_HOST_ID; host.id = OVERLAY_HOST_ID;
host.style.all = 'initial'; host.style.all = 'initial';
host.style.position = 'fixed'; host.style.position = 'fixed';
host.style.right = '24px'; // 中文备注:爬取窗口左下角放圆形菜单(计时 + 入口)
host.style.left = '24px';
host.style.bottom = '24px'; host.style.bottom = '24px';
host.style.zIndex = '2147483647'; host.style.zIndex = '2147483647';
@@ -335,7 +343,7 @@ function buildDom(): OverlayRefs {
<button class="capsule" type="button" aria-label="展开面板"> <button class="capsule" type="button" aria-label="展开面板">
<div class="radar-mini"><div class="sweep-mini"></div></div> <div class="radar-mini"><div class="sweep-mini"></div></div>
<span class="capsule-text">爬取中</span> <span class="capsule-time">爬取中</span>
</button> </button>
`; `;
@@ -597,10 +605,11 @@ function styleTag(): string {
.cancel-btn { align-self: flex-start; padding: 5px 10px; background: transparent; color: #8b949e; border: 1px solid #30363d; border-radius: 6px; font-size: 11px; cursor: pointer; transition: all 120ms ease; } .cancel-btn { align-self: flex-start; padding: 5px 10px; background: transparent; color: #8b949e; border: 1px solid #30363d; border-radius: 6px; font-size: 11px; cursor: pointer; transition: all 120ms ease; }
.cancel-btn:hover { color: #f85149; border-color: #f85149; } .cancel-btn:hover { color: #f85149; border-color: #f85149; }
.capsule { display: inline-flex; align-items: center; gap: 8px; padding: 7px 12px 7px 8px; background: #0d1117; color: #e6edf3; border: 1px solid #30363d; border-radius: 999px; box-shadow: 0 4px 14px rgba(0,0,0,0.4); cursor: pointer; font-size: 12px; transition: all 120ms ease; } /* 中文备注:圆形菜单(左下角)只展示计时;点击展开查看详细进度 */
.capsule:hover { transform: translateY(-1px); } .capsule { width: 56px; height: 56px; padding: 0; display: inline-flex; align-items: center; justify-content: center; background: #0d1117; color: #e6edf3; border: 1px solid #30363d; border-radius: 50%; box-shadow: 0 4px 14px rgba(0,0,0,0.4); cursor: pointer; font-size: 12px; transition: all 120ms ease; position: relative; }
.radar-mini { width: 18px; height: 18px; border-radius: 50%; position: relative; overflow: hidden; background: radial-gradient(circle at center, rgba(46,160,67,0.2), rgba(46,160,67,0.02) 70%, transparent 80%); border: 1px solid rgba(46,160,67,0.4); } .capsule:hover { transform: translateY(-1px); }
.sweep-mini { position: absolute; inset: 0; background: conic-gradient(from 0deg, rgba(46,160,67,0) 0deg, rgba(46,160,67,0.8) 50deg, rgba(46,160,67,0) 60deg); animation: sweep 2s linear infinite; } .radar-mini { position: absolute; inset: 6px; border-radius: 50%; overflow: hidden; background: radial-gradient(circle at center, rgba(46,160,67,0.20), rgba(46,160,67,0.03) 70%, transparent 82%); border: 1px solid rgba(46,160,67,0.40); }
.capsule-text { white-space: nowrap; } .sweep-mini { position: absolute; inset: 0; background: conic-gradient(from 0deg, rgba(46,160,67,0) 0deg, rgba(46,160,67,0.8) 50deg, rgba(46,160,67,0) 60deg); animation: sweep 2s linear infinite; }
</style>`; .capsule-time { position: relative; z-index: 1; font-variant-numeric: tabular-nums; font-weight: 700; letter-spacing: 0.2px; }
} </style>`;
}

View File

@@ -1,273 +1,220 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount } from 'vue'; import {platformConfigs} from '@/config/platforms';
import { platformConfigs } from '@/config/platforms'; import {formatSeconds} from '@/shared/time_format';
import { formatSeconds } from '@/shared/time_format'; import {useLogin} from './hook/use-login';
import { useLogin } from './hook/use-login'; import {useScan} from './hook/use-scan';
import { useScan } from './hook/use-scan'; import {useI18n, type PopupUiLang} from './hook/use-i18n';
const { isLoggedIn, handleLogin, handleLogout } = useLogin(); const {isLoggedIn, handleLogin, handleLogout} = useLogin();
// 中文备注Popup 内多语言(只影响文案显示)
const {uiLang, setUiLang, t, langOptions} = useI18n();
const { const {
selectedPlatformId, selectedPlatformId,
isScanning, isScanning,
crawlState, crawlState,
elapsedSeconds, elapsedSeconds,
handleScan, taskStatus,
handleCancelCrawl, handleScan,
handleResumeCrawl, handleCancelCrawl,
handleDismissCrawl, handleResumeCrawl,
quotaLoading,
subscriptionExpired,
quotaDeniedMessage,
openBillingPage,
} = useScan(); } = useScan();
console.log(crawlState.value)
/** 从扩展 manifest 读取版本号(兜底 `0.0.0`)。 */
const manifestVersion = (() => { const manifestVersion = (() => {
try { try {
return chrome.runtime.getManifest().version; return chrome.runtime.getManifest().version;
} catch { } catch {
return '0.0.0'; return '0.0.0';
} }
})(); })();
type PopupCard = 'not_authed' | 'idle' | 'running' | 'paused' | 'done' | 'failed' | 'cancelled';
const card = computed<PopupCard>(() => {
if (!isLoggedIn.value) return 'not_authed';
if (!crawlState.value) return 'idle';
if (crawlState.value.status === 'paused') return 'paused';
if (crawlState.value.status === 'completed') return 'done';
if (crawlState.value.status === 'failed') return 'failed';
if (crawlState.value.status === 'canceled') return 'cancelled';
return 'running';
});
const badgeClass = computed(() => {
const c = card.value;
if (c === 'running') return 'badge badge-scanning';
if (c === 'paused') return 'badge badge-paused';
if (c === 'done') return 'badge badge-done';
if (c === 'failed') return 'badge badge-failed';
if (c === 'cancelled') return 'badge badge-cancelled';
if (c === 'idle') return 'badge badge-ok';
return 'badge';
});
const badgeText = computed(() => {
const c = card.value;
if (c === 'running') return 'SCANNING';
if (c === 'paused') return 'PAUSED';
if (c === 'done') return 'DONE';
if (c === 'failed') return 'FAILED';
if (c === 'cancelled') return 'CANCELLED';
if (c === 'idle') return 'READY';
return 'SIGN IN';
});
const radarCardClass = computed(() => {
const c = card.value;
if (c === 'paused') return 'radar-card paused';
if (c === 'done') return 'radar-card done';
if (c === 'failed') return 'radar-card failed';
if (c === 'cancelled') return 'radar-card cancelled';
return 'radar-card';
});
function dotFor(status: string): string {
if (status === 'success') return '✓';
if (status === 'failed') return '×';
if (status === 'running') return '•';
return '·';
}
function stepStatusText(status: string): string {
const map: Record<string, string> = {
pending: '等待中',
running: '爬取中',
success: '已完成',
failed: '爬取失败',
};
return map[status] ?? status;
}
function statusLine(): string {
const c = card.value;
if (c === 'not_authed') return '请先登录后再开始爬取';
if (c === 'idle') return '选择平台后开始爬取,会打开一个专用扫描窗口';
if (!crawlState.value) return '';
if (c === 'paused') return crawlState.value.pause?.message ?? '任务已暂停,请处理后继续';
if (c === 'done') return '爬取完成';
if (c === 'failed') return '爬取失败,可重试';
if (c === 'cancelled') return '任务已取消';
return `已运行 ${formatSeconds(elapsedSeconds.value)}`;
}
/** 若爬取窗口存在,则将其置顶并吸引注意力。 */
async function focusCrawlWindow(): Promise<void> { async function focusCrawlWindow(): Promise<void> {
if (!crawlState.value?.windowId) return; if (!crawlState.value?.windowId) return;
try { try {
await chrome.windows.update(crawlState.value.windowId, { focused: true, drawAttention: true }); await chrome.windows.update(crawlState.value.windowId, {focused: true, drawAttention: true});
} catch { } catch {
// ignore // ignore
} }
} }
let cancelConfirmTimer: number | null = null;
/**
* 取消
*/
function requestCancel(): void { function requestCancel(): void {
const btn = document.getElementById('popup-cancel-btn') as HTMLButtonElement | null; // 中文备注:不要在这里手动把 crawlState 置空。
if (!btn) { // 任务状态以 storage 同步为准;手动置空会让 use-scan 的计时器回调访问空对象,导致 popup 闪退(表现为“闪一下”)。
void handleCancelCrawl(); void handleCancelCrawl();
return;
}
if (btn.dataset.confirming === '1') {
btn.dataset.confirming = '0';
btn.textContent = 'Cancel';
if (cancelConfirmTimer) window.clearTimeout(cancelConfirmTimer);
cancelConfirmTimer = null;
void handleCancelCrawl();
return;
}
btn.dataset.confirming = '1';
btn.textContent = 'Cancel?';
cancelConfirmTimer = window.setTimeout(() => {
btn.dataset.confirming = '0';
btn.textContent = 'Cancel';
cancelConfirmTimer = null;
}, 3000);
} }
onBeforeUnmount(() => { /**
if (cancelConfirmTimer) window.clearTimeout(cancelConfirmTimer); * 语言切换
cancelConfirmTimer = null; * 中文备注:用事件回调承接,避免在 template 里写复杂类型断言影响可读性。
}); */
function onLangChange(event: Event): void {
const value = (event.target as HTMLSelectElement).value as PopupUiLang;
void setUiLang(value);
}
</script> </script>
<template> <template>
<div class="container"> <div class="container">
<header> <header>
<div class="logo"> <div class="logo">
<span class="logo-mark">SA</span> <span class="logo-mark">SA</span>
<span>StoreAI</span> <span>StoreAI</span>
</div> </div>
<span :class="badgeClass">{{ badgeText }}</span> </header>
</header>
<div class="status">{{ statusLine() }}</div> <!-- 未登录-->
<template v-if="!isLoggedIn">
<div class="status">{{ t('please_login') }}</div>
<button style="margin-top: 20px" type="button" @click="handleLogin">
{{ t('sign_in') }}
</button>
</template>
<div v-if="isLoggedIn" class="account"> <template v-else>
平台{{ platformConfigs.find((p) => p.id === selectedPlatformId)?.name ?? selectedPlatformId }} <!-- 未开始-->
<template v-if="crawlState == null">
<label class="platform-select">
<span class="account">{{ t('platform_select') }}</span>
<select v-model="selectedPlatformId"
class="platform-select__control">
<option v-for="platform in platformConfigs"
:key="platform.id"
:value="platform.id">
{{ platform.name }}
</option>
</select>
</label>
<!-- 中文备注订阅到期时不允许用户在扩展里直接点立即扫描绕过订阅限制 -->
<template v-if="subscriptionExpired">
<div class="status">{{ t('subscription_expired') }}</div>
<button type="button" @click="openBillingPage">
{{ t('go_billing') }}
</button>
</template>
<template v-else>
<button type="button" :disabled="isScanning || quotaLoading" @click="handleScan">
{{ quotaLoading ? t('checking_sub') : (isScanning ? t('opening') : t('scan_now')) }}
</button>
<div v-if="quotaDeniedMessage" class="status" style="margin-top: 8px;">
{{ quotaDeniedMessage || t('scan_disabled') }}
</div>
</template>
</template>
<!-- 进行中-->
<template v-else>
<div :class="['radar-card', taskStatus]">
<div class="radar-row">
<div class="radar">
<div class="sweep"></div>
<div class="ping"></div>
</div>
<div class="radar-titles">
<div class="radar-title">{{ crawlState.platformName }}</div>
<div class="radar-sub">
<template v-if="taskStatus == 'paused'">{{ t('paused') }}</template>
<template v-else-if="taskStatus == 'completed'">{{ t('done') }}</template>
<template v-else-if="taskStatus == 'failed'">{{ t('failed') }}</template>
<template v-else>{{ t('scanning') }}</template>
· {{ formatSeconds(elapsedSeconds) }}
</div>
</div>
</div>
<div class="steps">
<div v-for="(step, index) in crawlState.steps" :key="step.uniqueKey" class="step">
<div class="step-left">
<div class="step-dot">
<span v-if="step.status =='success'"></span>
<span v-else-if="step.status =='failed'">×</span>
<span v-else-if="step.status =='running'"></span>
</div>
<div class="step-label">{{ index + 1 }}. {{ step.name }}</div>
</div>
<div class="step-status">
<span v-if="step.status == 'success'">{{ t('step_done') }}</span>
<span v-else-if="step.status == 'failed'">{{ t('step_failed') }}</span>
<span v-else-if="step.status == 'running'">{{ t('step_running') }}</span>
<span v-else-if="step.status == 'pending'">{{ t('step_pending') }}</span>
</div>
</div>
</div>
<div class="actions">
<!-- 运行中-->
<template v-if="taskStatus == 'running'">
<button v-if="taskStatus == 'running'"
type="button"
class="secondary"
@click="focusCrawlWindow">
{{ t('show_tab') }}
</button>
</template>
<!-- 暂停中-->
<template v-else-if="taskStatus == 'paused'">
<button type="button" @click="handleResumeCrawl">
{{ t('continue_now') }}
</button>
</template>
<button id="popup-cancel-btn"
type="button"
class="secondary"
@click="requestCancel">
{{ t('cancel') }}
</button>
</div>
</div>
</template>
</template>
<footer>
<button
v-if="isLoggedIn"
type="button"
class="secondary footer-btn"
@click="handleLogout">
{{ t('sign_out') }}
</button>
<div style="display:flex; align-items:center; gap:8px;">
<label style="display:flex; align-items:center; gap:6px;">
<span style="font-size: 12px; opacity: 0.75;">{{ t('language') }}</span>
<select
:value="uiLang"
style="font-size: 12px; padding: 2px 6px; border-radius: 6px; border: 1px solid rgba(0,0,0,0.12);"
@change="onLangChange"
>
<option v-for="opt in langOptions" :key="opt.value" :value="opt.value">
{{ t(opt.labelKey) }}
</option>
</select>
</label>
<span class="version">v{{ manifestVersion }}</span>
</div>
</footer>
</div> </div>
<template v-if="card === 'not_authed'">
<button type="button" @click="handleLogin">Sign in</button>
</template>
<template v-else-if="card === 'idle'">
<label style="display: flex; flex-direction: column; gap: 6px">
<span class="account">平台选择</span>
<select
v-model="selectedPlatformId"
style="
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: #fff;
font-size: 13px;
"
>
<option v-for="platform in platformConfigs" :key="platform.id" :value="platform.id">
{{ platform.name }}
</option>
</select>
</label>
<button type="button" :disabled="isScanning" @click="handleScan">
{{ isScanning ? 'Opening' : 'Scan now' }}
</button>
</template>
<template v-else-if="crawlState">
<div :class="radarCardClass">
<div class="radar-row">
<div class="radar">
<div class="sweep"></div>
<div class="ping"></div>
</div>
<div class="radar-titles">
<div class="radar-title">{{ crawlState.platformName }}</div>
<div class="radar-sub">
{{
card === 'paused'
? 'Paused'
: card === 'done'
? 'Done'
: card === 'failed'
? 'Failed'
: card === 'cancelled'
? 'Cancelled'
: 'Scanning'
}}
· {{ formatSeconds(elapsedSeconds) }}
</div>
</div>
</div>
<div class="steps">
<div v-for="(step, index) in crawlState.steps" :key="step.uniqueKey" class="step">
<div class="step-left">
<div class="step-dot">{{ dotFor(step.status) }}</div>
<div class="step-label">{{ index + 1 }}. {{ step.name }}</div>
</div>
<div class="step-status">{{ stepStatusText(step.status) }}</div>
</div>
</div>
<div v-if="card === 'paused' && crawlState.pause" class="pause-banner">
<p>{{ crawlState.pause.message }}</p>
</div>
<div class="actions">
<button v-if="card === 'running'" type="button" class="secondary" @click="focusCrawlWindow">
Show tab
</button>
<button v-if="card === 'paused'" type="button" @click="handleResumeCrawl">Continue now</button>
<button
v-if="card === 'done' || card === 'failed' || card === 'cancelled'"
type="button"
@click="handleScan"
>
Scan again
</button>
<button
v-if="card === 'done' || card === 'failed' || card === 'cancelled'"
type="button"
class="secondary"
@click="handleDismissCrawl"
>
Close
</button>
<button v-if="card === 'running'" id="popup-cancel-btn" type="button" class="secondary" @click="requestCancel">
Cancel
</button>
</div>
</div>
</template>
<footer>
<button
v-if="isLoggedIn"
type="button"
class="secondary"
style="width: auto; padding: 4px 10px"
@click="handleLogout"
>
Sign out
</button>
<span v-else></span>
<span class="version">v{{ manifestVersion }}</span>
</footer>
</div>
</template> </template>
<style> <style>
select:focus { @import "tailwindcss";
outline: none;
}
</style> </style>

119
src/popup/hook/use-i18n.ts Normal file
View File

@@ -0,0 +1,119 @@
import {computed, onMounted, ref} from 'vue';
/**
* Popup 多语言(仅影响 Popup 文案,不改平台配置/爬取数据)。
* 中文备注:用户要求把切换入口放在 Popup 底部版本号附近,因此这里提供一个轻量的本地 i18n。
*/
/** chrome.storage.local 中保存语言的 key */
const POPUP_UI_LANG_KEY = 'popupUiLang';
/** 目前仅提供中英两种,后续可在这里继续扩展 */
export type PopupUiLang = 'zh-CN' | 'en';
/** 文案字典key 统一用英文标识,方便维护 */
const DICT: Record<PopupUiLang, Record<string, string>> = {
'zh-CN': {
please_login: '请先登录后再开始爬取',
sign_in: '登录',
sign_out: '退出登录',
platform_select: '平台选择',
scan_now: '立即扫描',
checking_sub: '正在检查订阅…',
subscription_expired: '订阅已到期,扫描功能已锁定。',
go_billing: '去订阅/续费',
scan_disabled: '暂时不允许扫描。',
opening: '正在打开…',
scanning: '扫描中',
paused: '已暂停',
done: '已完成',
failed: '失败',
show_tab: '显示页面',
continue_now: '继续',
cancel: '取消',
step_done: '已完成',
step_failed: '失败',
step_running: '进行中',
step_pending: '等待中',
language: '语言',
lang_zh: '中文',
lang_en: 'English',
},
en: {
please_login: 'Please sign in before scanning.',
sign_in: 'Sign in',
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',
done: 'Done',
failed: 'Failed',
show_tab: 'Show tab',
continue_now: 'Continue now',
cancel: 'Cancel',
step_done: 'Completed',
step_failed: 'Failed',
step_running: 'Running',
step_pending: 'Pending',
language: 'Language',
lang_zh: '中文',
lang_en: 'English',
},
};
/**
* Popup 内使用的 i18n composable
*/
export function useI18n() {
const uiLang = ref<PopupUiLang>('zh-CN');
// 中文备注:从本地存储恢复用户上次选择
onMounted(async () => {
try {
if (typeof chrome === 'undefined' || !chrome.storage?.local) return;
const res = await chrome.storage.local.get(POPUP_UI_LANG_KEY);
const stored = res?.[POPUP_UI_LANG_KEY];
if (stored === 'zh-CN' || stored === 'en') {
uiLang.value = stored;
}
} catch {
// ignore
}
});
const t = computed(() => {
return (key: string) => {
return DICT[uiLang.value]?.[key] ?? DICT.en[key] ?? key;
};
});
/**
* 设置语言并持久化
*/
async function setUiLang(lang: PopupUiLang) {
uiLang.value = lang;
try {
if (typeof chrome === 'undefined' || !chrome.storage?.local) return;
await chrome.storage.local.set({[POPUP_UI_LANG_KEY]: lang});
} catch {
// ignore
}
}
return {
uiLang,
t: t.value,
setUiLang,
// 中文备注:给 template 直接使用的语言选项
langOptions: [
{value: 'zh-CN' as const, labelKey: 'lang_zh'},
{value: 'en' as const, labelKey: 'lang_en'},
],
};
}

View File

@@ -1,35 +1,57 @@
import {computed, onMounted, ref} from "vue"; import {computed, onMounted, ref} from "vue";
import {getToken, logout, setToken} from "@/shared/auth"; import {APP_URL} from "@/config";
import {clearAuthState, getAuthState, initAuthState} from "@/shared/store";
/**
* Popup 的登录状态与操作
*
* 中文备注:
* - 扩展侧不直接拿 Web token登录/配对在网页侧完成
* - 网页侧拿到 extension token 后,会通过 `onMessageExternal` 回传并写入 `chrome.storage.local['auth_state']`
*/
export const useLogin = () => { export const useLogin = () => {
const token = ref<string | null>(null); const token = ref<string | null>(null);
// 当前是否已登录(是否已有 extension token
const isLoggedIn = computed(() => token.value !== null); const isLoggedIn = computed(() => token.value !== null);
/** /**
* 登录 * 登录:打开网页,让网页完成配对并回传 extension token
*/ */
const handleLogin = async () => { const handleLogin = async () => {
let value = "xxx" const extId = chrome?.runtime?.id || "";
await setToken(value) const url = `${APP_URL.replace(/\/$/, "")}/onboarding/extension?from=extension&extId=${encodeURIComponent(extId)}`;
token.value = value await chrome.tabs.create({url, active: true});
} };
/** /**
* 退出登录 * 退出:清空本地 token 与上下文
*/ */
const handleLogout = async () => { const handleLogout = async () => {
await logout() await clearAuthState();
token.value = null token.value = null;
} };
onMounted(async () => { 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<string, chrome.storage.StorageChange>, 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 { return {
isLoggedIn, isLoggedIn,
handleLogin, handleLogin,
handleLogout, handleLogout,
} };
} };

View File

@@ -1,38 +1,141 @@
import { onMounted, onUnmounted, ref } from 'vue'; import {computed, onMounted, onUnmounted, ref} from "vue";
import { platformConfigs } from '@/config/platforms'; import {platformConfigs} from "@/config/platforms";
import type { CrawlTaskState } from '@/types'; import type {CrawlTaskState} from "@/types";
import { sendBackgroundMessage } from '@/shared/message'; import {sendBackgroundMessage} from "@/shared/message";
import {getScanQuotaApi} from "@/api/me";
const CRAWL_TASK_STORAGE_KEY = 'crawlTaskState'; import {APP_URL} from "@/config";
const ACTIVE_STATUSES = new Set(['running', 'paused']);
/**
* Popup 内的爬取状态与操作集合
*
* 中文备注:
* - 订阅存在“隐藏逻辑”:订阅到期时不能让用户绕过网页,直接在扩展 popup 点“立即扫描”
* - 这里会在 popup 打开时调用一次 `/api/me/scan-quota`,如果订阅过期则隐藏“立即扫描”按钮,改为引导去订阅
*/
export const useScan = () => { export const useScan = () => {
const selectedPlatformId = ref(platformConfigs[0]?.id ?? ''); /** 当前选中的平台 id */
const selectedPlatformId = ref(platformConfigs[0]?.id ?? "");
/** 防止重复点击 “Scan now” */
const isScanning = ref<boolean>(false); const isScanning = ref<boolean>(false);
/** 当前爬取任务状态(从 background 同步) */
const crawlState = ref<CrawlTaskState | null>(null); const crawlState = ref<CrawlTaskState | null>(null);
const elapsedSeconds = ref<number>(0);
const taskStatus = computed(() => crawlState.value?.status);
/** 任务耗时(秒) */
const elapsedSeconds = ref<number>(0);
let timer: number | undefined; let timer: number | undefined;
const handleScan = async () => { // 中文备注:订阅/权限检查(控制“立即扫描”按钮是否可用)
if (isScanning.value) { const quotaLoading = ref<boolean>(false);
const quotaDeniedReason = ref<string | null>(null);
const quotaDeniedMessage = ref<string | null>(null);
const subscriptionExpired = computed(() => quotaDeniedReason.value === "subscription_required");
/**
* 停止计时器并清零
* 中文备注crawlState 被清空时必须停表,否则 timer 回调会访问空对象导致 popup “闪一下”
*/
function stopElapsedTimer() {
if (timer) {
window.clearInterval(timer);
timer = undefined;
}
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<CrawlTaskState | null>({action: "GET_CRAWL_STATE"});
if (response.ok) {
syncCrawlState(response.data ?? null);
}
}
/**
* 打开订阅页面(网页侧)
*/
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; 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; isScanning.value = true;
try { try {
ensureElapsedTimer(); // 中文备注:点击“立即扫描”前先刷新一次订阅/权限,避免订阅刚到期但按钮仍可点
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<CrawlTaskState>({ const response = await sendBackgroundMessage<CrawlTaskState>({
action: 'START_CRAWL', action: "START_CRAWL",
payload: { platformId: selectedPlatformId.value }, payload: {platformId: selectedPlatformId.value},
}); });
if (response.ok) { if (response.ok) {
syncCrawlState(response.data ?? null); syncCrawlState(response.data ?? null);
} else { } else {
console.error('[crawl] start failed', response.error); console.error("[crawl] start failed", response.error);
} }
} finally { } finally {
isScanning.value = false; isScanning.value = false;
@@ -40,131 +143,81 @@ export const useScan = () => {
}; };
const handleCancelCrawl = async () => { const handleCancelCrawl = async () => {
const response = await sendBackgroundMessage<CrawlTaskState | null>({ action: 'CANCEL_CRAWL' }); const response = await sendBackgroundMessage<CrawlTaskState | null>({action: "CANCEL_CRAWL"});
if (response.ok) { if (response.ok) {
syncCrawlState(response.data ?? null); syncCrawlState(response.data ?? null);
return; return;
} }
console.error("[crawl] cancel failed", response.error);
console.error('[crawl] cancel failed', response.error);
await refreshCrawlState(); await refreshCrawlState();
}; };
const handleResumeCrawl = async () => { const handleResumeCrawl = async () => {
const response = await sendBackgroundMessage<CrawlTaskState | null>({ action: 'RESUME_CRAWL' }); const response = await sendBackgroundMessage<CrawlTaskState | null>({action: "RESUME_CRAWL"});
if (response.ok) { if (response.ok) {
syncCrawlState(response.data ?? null); syncCrawlState(response.data ?? null);
return; return;
} }
console.error("[crawl] resume failed", response.error);
console.error('[crawl] resume failed', response.error);
await refreshCrawlState(); await refreshCrawlState();
}; };
const handleDismissCrawl = async () => { const handleDismissCrawl = async () => {
const response = await sendBackgroundMessage<CrawlTaskState | null>({ action: 'DISMISS_CRAWL' }); const response = await sendBackgroundMessage<CrawlTaskState | null>({action: "DISMISS_CRAWL"});
if (response.ok) { if (response.ok) {
syncCrawlState(response.data ?? null); syncCrawlState(response.data ?? null);
return; return;
} }
console.error("[crawl] dismiss failed", response.error);
console.error('[crawl] dismiss failed', response.error);
await refreshCrawlState(); await refreshCrawlState();
}; };
function syncCrawlState(state: CrawlTaskState | null) { /**
crawlState.value = state; * 监听 storage 变化:
updateSeconds(); * - crawlTaskState同步任务进度
* - auth_state登录/退出/重新配对后刷新订阅状态
if (state && ACTIVE_STATUSES.has(state.status)) { */
ensureElapsedTimer();
return;
}
clearElapsedTimer();
}
function ensureElapsedTimer() {
if (timer !== undefined) {
return;
}
timer = window.setInterval(() => {
updateSeconds();
}, 1000);
}
function clearElapsedTimer() {
if (timer === undefined) {
return;
}
window.clearInterval(timer);
timer = undefined;
}
function updateSeconds() {
if (!crawlState.value) {
elapsedSeconds.value = 0;
return;
}
elapsedSeconds.value = Math.max(0, Math.floor((Date.now() - crawlState.value.startedAt) / 1000));
}
async function refreshCrawlState() {
const response = await sendBackgroundMessage<CrawlTaskState | null>({ action: 'GET_CRAWL_STATE' });
if (response.ok) {
syncCrawlState(response.data ?? null);
}
}
function handleStorageChanged(changes: Record<string, chrome.storage.StorageChange>, areaName: string) { function handleStorageChanged(changes: Record<string, chrome.storage.StorageChange>, areaName: string) {
if (areaName !== 'local') { if (areaName !== "local") return;
return;
} if (changes["auth_state"]) {
void refreshQuota();
const change = changes[CRAWL_TASK_STORAGE_KEY];
if (!change) {
return;
} }
const change = changes["crawlTaskState"];
if (!change) return;
syncCrawlState(isCrawlTaskState(change.newValue) ? change.newValue : null); syncCrawlState(isCrawlTaskState(change.newValue) ? change.newValue : null);
} }
onMounted(async () => { onMounted(async () => {
await refreshCrawlState(); await refreshCrawlState();
await refreshQuota();
if (typeof chrome !== 'undefined' && chrome.storage?.onChanged) { chrome.storage.onChanged.addListener(handleStorageChanged);
chrome.storage.onChanged.addListener(handleStorageChanged);
}
}); });
onUnmounted(() => { onUnmounted(() => {
clearElapsedTimer(); stopElapsedTimer();
chrome.storage.onChanged.removeListener(handleStorageChanged);
if (typeof chrome !== 'undefined' && chrome.storage?.onChanged) {
chrome.storage.onChanged.removeListener(handleStorageChanged);
}
}); });
return { return {
selectedPlatformId, selectedPlatformId,
isScanning, isScanning,
crawlState, crawlState,
taskStatus,
elapsedSeconds,
handleScan, handleScan,
handleCancelCrawl, handleCancelCrawl,
handleResumeCrawl, handleResumeCrawl,
handleDismissCrawl, handleDismissCrawl,
elapsedSeconds, // 订阅/权限
quotaLoading,
subscriptionExpired,
quotaDeniedMessage,
openBillingPage,
}; };
}; };
function isCrawlTaskState(value: unknown): value is CrawlTaskState { 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;
} }

View File

@@ -1,5 +1,5 @@
import { createApp } from 'vue'; import { createApp } from 'vue';
import App from './App.vue'; import App from './App.vue';
import './popup.css'; import './popup.scss';
createApp(App).mount('#app'); createApp(App).mount('#app');

View File

@@ -1,392 +0,0 @@
:root {
--bg: #ffffff;
--fg: #0f172a;
--muted: #64748b;
--border: #e2e8f0;
--primary: #0f172a;
--primary-fg: #ffffff;
--accent: #f1f5f9;
--success: #22c55e;
--warning: #eab308;
--danger: #ef4444;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
width: 360px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: var(--bg);
color: var(--fg);
}
.container {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
}
.logo {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 600;
letter-spacing: -0.01em;
}
.logo-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
background: var(--primary);
color: var(--primary-fg);
border-radius: 5px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0;
}
.badge {
font-size: 10px;
font-weight: 600;
padding: 2px 8px;
border-radius: 999px;
background: var(--accent);
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.badge-ok {
background: rgba(34, 197, 94, 0.12);
color: var(--success);
}
.status {
font-size: 13px;
color: var(--muted);
line-height: 1.5;
}
.account {
font-size: 12px;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.last-scan {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
padding: 8px 12px;
background: var(--accent);
border-radius: 6px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--muted);
}
.dot-green {
background: var(--success);
}
.dot-yellow {
background: var(--warning);
}
.dot-red {
background: var(--danger);
}
.progress {
font-size: 12px;
color: var(--muted);
padding: 6px 8px;
background: var(--accent);
border-radius: 4px;
font-style: italic;
}
button {
width: 100%;
padding: 9px 12px;
border: none;
border-radius: 6px;
background: var(--primary);
color: var(--primary-fg);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: opacity 150ms ease;
}
button:hover:not(:disabled) {
opacity: 0.9;
}
button.secondary {
background: transparent;
color: var(--muted);
border: 1px solid var(--border);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
footer {
border-top: 1px solid var(--border);
padding-top: 8px;
font-size: 11px;
color: var(--muted);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
footer .version {
flex: 0 0 auto;
}
/* ===============================================================
Scanning state - radar card + step list + pause banner
=============================================================== */
.badge-scanning,
.badge-starting,
.badge-drilling,
.badge-competitors,
.badge-uploading {
background: rgba(14, 165, 233, 0.12);
color: #0ea5e9;
}
.badge-paused {
background: rgba(234, 179, 8, 0.15);
color: #ca8a04;
}
.badge-done {
background: rgba(34, 197, 94, 0.15);
color: #16a34a;
}
.badge-cancelled {
background: rgba(100, 116, 139, 0.15);
color: #64748b;
}
.badge-failed {
background: rgba(239, 68, 68, 0.15);
color: #dc2626;
}
.radar-card {
background: linear-gradient(180deg, #0f172a 0%, #1e293b 100%);
color: #e2e8f0;
border-radius: 10px;
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.radar-card.paused {
background: linear-gradient(180deg, #2b2008 0%, #3d2b0f 100%);
}
.radar-card.done {
background: linear-gradient(180deg, #0a2e1a 0%, #134028 100%);
}
.radar-card.failed {
background: linear-gradient(180deg, #2a0f0f 0%, #3b1718 100%);
}
.radar-card.cancelled {
background: linear-gradient(180deg, #1e293b 0%, #263345 100%);
}
.radar-row {
display: flex;
align-items: center;
gap: 12px;
}
.radar {
flex: 0 0 40px;
width: 40px;
height: 40px;
position: relative;
overflow: hidden;
border-radius: 50%;
background: radial-gradient(
circle at center,
rgba(46, 160, 67, 0.14),
rgba(46, 160, 67, 0.02) 70%,
transparent 80%
);
border: 1px solid rgba(46, 160, 67, 0.35);
}
.radar .sweep {
position: absolute;
inset: 0;
background: conic-gradient(
from 0deg,
rgba(46, 160, 67, 0) 0deg,
rgba(46, 160, 67, 0.75) 50deg,
rgba(46, 160, 67, 0) 60deg
);
animation: pop-sweep 2s linear infinite;
}
.radar .ping {
position: absolute;
left: 50%;
top: 50%;
width: 7px;
height: 7px;
background: #2ea043;
border-radius: 50%;
transform: translate(-50%, -50%);
box-shadow: 0 0 0 0 rgba(46, 160, 67, 0.7);
animation: pop-ping 2s ease-out infinite;
}
@keyframes pop-sweep {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes pop-ping {
0% {
box-shadow: 0 0 0 0 rgba(46, 160, 67, 0.7);
}
100% {
box-shadow: 0 0 0 14px rgba(46, 160, 67, 0);
}
}
.radar-card.paused .sweep,
.radar-card.done .sweep,
.radar-card.cancelled .sweep,
.radar-card.failed .sweep {
animation: none;
opacity: 0.3;
}
.radar-card.paused .ping {
background: #eab308;
animation: none;
}
.radar-card.done .ping {
background: #22c55e;
animation: none;
}
.radar-card.failed .ping {
background: #ef4444;
animation: none;
}
.radar-card.cancelled .ping {
background: #94a3b8;
animation: none;
}
.radar-titles {
flex: 1 1 auto;
min-width: 0;
}
.radar-title {
font-size: 13px;
font-weight: 600;
line-height: 1.2;
color: #f8fafc;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.radar-sub {
font-size: 11.5px;
color: rgba(226, 232, 240, 0.75);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.steps {
display: flex;
flex-direction: column;
gap: 6px;
}
.step {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
font-size: 12px;
color: rgba(226, 232, 240, 0.8);
}
.step-left {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1 1 auto;
}
.step-dot {
width: 16px;
height: 16px;
border-radius: 50%;
background: rgba(226, 232, 240, 0.18);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
}
.step-label {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.step-status {
flex: 0 0 auto;
font-size: 11px;
opacity: 0.85;
}
.pause-banner {
border-radius: 8px;
border: 1px solid rgba(234, 179, 8, 0.35);
background: rgba(234, 179, 8, 0.08);
padding: 10px 12px;
font-size: 12px;
line-height: 1.45;
}
.pause-banner p {
margin: 0;
color: #fef3c7;
}
.actions {
display: flex;
gap: 8px;
}
.actions button {
width: auto;
flex: 1 1 0;
}
.actions button.secondary {
color: rgba(226, 232, 240, 0.85);
border-color: rgba(226, 232, 240, 0.2);
}

410
src/popup/popup.scss Normal file
View File

@@ -0,0 +1,410 @@
:root {
--bg: #ffffff;
--fg: #0f172a;
--muted: #64748b;
--border: #e2e8f0;
--primary: #0f172a;
--primary-fg: #ffffff;
--accent: #f1f5f9;
--success: #22c55e;
--warning: #eab308;
--danger: #ef4444;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
width: 360px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: var(--bg);
color: var(--fg);
}
.container {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
}
.logo {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 600;
letter-spacing: -0.01em;
&-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
background: var(--primary);
color: var(--primary-fg);
border-radius: 5px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0;
}
}
.status {
font-size: 13px;
color: var(--muted);
line-height: 1.5;
}
.account {
font-size: 12px;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.platform-select {
display: flex;
flex-direction: column;
gap: 6px;
&__control {
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: #fff;
font-size: 13px;
&:focus {
outline: none;
}
}
}
.last-scan {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
padding: 8px 12px;
background: var(--accent);
border-radius: 6px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--muted);
&-green {
background: var(--success);
}
&-yellow {
background: var(--warning);
}
&-red {
background: var(--danger);
}
}
.progress {
font-size: 12px;
color: var(--muted);
padding: 6px 8px;
background: var(--accent);
border-radius: 4px;
font-style: italic;
}
button {
width: 100%;
padding: 9px 12px;
border: none;
border-radius: 6px;
background: var(--primary);
color: var(--primary-fg);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: opacity 150ms ease;
&:hover:not(:disabled) {
opacity: 0.9;
}
&.secondary {
background: transparent;
color: var(--muted);
border: 1px solid var(--border);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
footer {
border-top: 1px solid var(--border);
padding-top: 8px;
font-size: 11px;
color: var(--muted);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
.footer-btn {
width: auto;
padding: 4px 10px;
}
.version {
flex: 0 0 auto;
}
}
.radar-card {
background: linear-gradient(180deg, #0f172a 0%, #1e293b 100%);
color: #e2e8f0;
border-radius: 10px;
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
&.paused {
background: linear-gradient(180deg, #2b2008 0%, #3d2b0f 100%);
}
&.done {
background: linear-gradient(180deg, #0a2e1a 0%, #134028 100%);
}
&.failed {
background: linear-gradient(180deg, #2a0f0f 0%, #3b1718 100%);
}
&.canceled {
background: linear-gradient(180deg, #1e293b 0%, #263345 100%);
}
&.paused,
&.done,
&.canceled,
&.failed {
.sweep {
animation: none;
opacity: 0.3;
}
}
&.paused {
.ping {
background: #eab308;
animation: none;
}
}
&.done {
.ping {
background: #22c55e;
animation: none;
}
}
&.failed {
.ping {
background: #ef4444;
animation: none;
}
}
&.canceled {
.ping {
background: #94a3b8;
animation: none;
}
}
}
.radar-row {
display: flex;
align-items: center;
gap: 12px;
}
.radar {
flex: 0 0 40px;
width: 40px;
height: 40px;
position: relative;
overflow: hidden;
border-radius: 50%;
background: radial-gradient(
circle at center,
rgba(46, 160, 67, 0.14),
rgba(46, 160, 67, 0.02) 70%,
transparent 80%
);
border: 1px solid rgba(46, 160, 67, 0.35);
.sweep {
position: absolute;
inset: 0;
background: conic-gradient(
from 0deg,
rgba(46, 160, 67, 0) 0deg,
rgba(46, 160, 67, 0.75) 50deg,
rgba(46, 160, 67, 0) 60deg
);
animation: pop-sweep 2s linear infinite;
}
.ping {
position: absolute;
left: 50%;
top: 50%;
width: 7px;
height: 7px;
background: #2ea043;
border-radius: 50%;
transform: translate(-50%, -50%);
box-shadow: 0 0 0 0 rgba(46, 160, 67, 0.7);
animation: pop-ping 2s ease-out infinite;
}
}
@keyframes pop-sweep {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes pop-ping {
0% {
box-shadow: 0 0 0 0 rgba(46, 160, 67, 0.7);
}
100% {
box-shadow: 0 0 0 14px rgba(46, 160, 67, 0);
}
}
.radar-titles {
flex: 1 1 auto;
min-width: 0;
}
.radar-title {
font-size: 13px;
font-weight: 600;
line-height: 1.2;
color: #f8fafc;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.radar-sub {
font-size: 11.5px;
color: rgba(226, 232, 240, 0.75);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.steps {
display: flex;
flex-direction: column;
gap: 6px;
}
.step {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
font-size: 12px;
color: rgba(226, 232, 240, 0.8);
&-left {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1 1 auto;
}
&-dot {
width: 16px;
height: 16px;
border-radius: 50%;
background: rgba(226, 232, 240, 0.18);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
}
&-label {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&-status {
flex: 0 0 auto;
font-size: 11px;
opacity: 0.85;
}
}
.pause-banner {
border-radius: 8px;
border: 1px solid rgba(234, 179, 8, 0.35);
background: rgba(234, 179, 8, 0.08);
padding: 10px 12px;
font-size: 12px;
line-height: 1.45;
p {
margin: 0;
color: #fef3c7;
}
}
.actions {
display: flex;
gap: 8px;
button {
width: auto;
flex: 1 1 0;
&.secondary {
color: rgba(226, 232, 240, 0.85);
border-color: rgba(226, 232, 240, 0.2);
}
}
}

View File

@@ -1,9 +1,31 @@
export type MessageAction = export type MessageAction =
/** 获取当前爬取任务的状态*/
| 'GET_CRAWL_STATE' | 'GET_CRAWL_STATE'
/**
* 仅供 content script爬取窗口页内悬浮面板拉取状态
* - 只在“当前 tab == 爬取 tab”时返回状态其它 tab 返回 null。
* 中文备注:用于悬浮面板初始化时拿到任务快照。
*/
| 'GET_CRAWL_STATE_FOR_TAB'
/** 启动一个新的爬取任务 */
| 'START_CRAWL' | 'START_CRAWL'
/** 彻底取消并停止当前的爬取任务 */
| 'CANCEL_CRAWL' | 'CANCEL_CRAWL'
/** 恢复之前被暂停或因中断而停止的爬取任务 */
| 'RESUME_CRAWL' | 'RESUME_CRAWL'
/**
* 取消自动关闭(旧悬浮面板按钮会发这个指令)。
* 中文备注:当前版本默认完成后会清理并关闭窗口;这里保留用于兼容,避免后台报“未知指令”。
*/
| 'CANCEL_AUTOCLOSE' | 'CANCEL_AUTOCLOSE'
/** 忽略/关闭当前爬取任务的 UI 提示或通知(通常指任务结束后清理界面) */
| 'DISMISS_CRAWL'; | 'DISMISS_CRAWL';
interface BackgroundMessage<T = unknown> { interface BackgroundMessage<T = unknown> {
@@ -18,12 +40,15 @@ interface BackgroundResponse<T = unknown> {
} }
/** /**
* Send a command to the background service worker. * 发送消息给服务
*/ */
export function sendBackgroundMessage<T>(data: BackgroundMessage): Promise<BackgroundResponse<T>> { export function sendBackgroundMessage<T>(data: BackgroundMessage): Promise<BackgroundResponse<T>> {
if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) { if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) {
return Promise.resolve({ ok: true, data: null }); return Promise.resolve({ok: true, data: null});
} }
return chrome.runtime.sendMessage(data); return chrome.runtime.sendMessage(data);
} }
//接受

98
src/shared/request.ts Normal file
View File

@@ -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 <extension_token>
* - 统一处理后端 `{ 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;

57
src/shared/store.ts Normal file
View File

@@ -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);
}

34
src/shared/tab.ts Normal file
View File

@@ -0,0 +1,34 @@
/**
* 后台发给网页Tab的消息行为
*/
export type TabAction =
/** 任务状态更新(进度、状态改变等) */
| 'CRAWL_STATE_UPDATE'
/** 任务发生错误 */
| 'CRAWL_ERROR'
/** 任务完成 */
| 'CRAWL_COMPLETED';
/**
* 后台发给网页的消息格式
*/
interface TabMessage<T = unknown> {
action: TabAction;
payload?: T;
}
/**
* 发送消息给特定的标签页(由后台调用)
*/
export function sendTabMessage<T>(tabId: number, action: TabAction, payload?: T): void {
if (typeof chrome === 'undefined' || !chrome.tabs?.sendMessage) {
return;
}
const message: TabMessage<T> = {action, payload};
chrome.tabs.sendMessage(tabId, message).catch((err) => {
// 这里的错误通常是因为 Tab 被关闭了或者页面刷新了,属于正常现象
console.warn(`[Message] Failed to send ${action} to tab ${tabId}:`, err);
});
}

View File

@@ -21,7 +21,7 @@ export interface CrawlProgressStep {
// 爬取暂停原因,通常由登录、验证码或页面不存在触发。 // 爬取暂停原因,通常由登录、验证码或页面不存在触发。
export interface CrawlPauseInfo { export interface CrawlPauseInfo {
// 暂停原因编码。 // 暂停原因编码。
reason: 'reauth' | 'shield' | 'not_found' | 'page_not_ready'; reason: 'reauth' | 'shield' | 'not_found' | 'page_not_ready' | 'window_closed';
// 展示给用户看的处理提示。 // 展示给用户看的处理提示。
message: string; message: string;
} }

View File

@@ -1 +1 @@
{"root":["./manifest.config.ts","./message.js","./vite.config.ts","./src/background/domscraper.ts","./src/background/index.ts","./src/background/service.ts","./src/background/types.ts","./src/background/service/crawltask.ts","./src/background/service/externalbridge.ts","./src/background/service/lifecycle.ts","./src/background/service/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-login.ts","./src/popup/hook/use-scan.ts","./src/shared/auth.ts","./src/shared/message.ts","./src/shared/time_format.ts","./src/types/crawl.ts","./src/types/index.ts","./src/types/platform.ts"],"version":"5.9.3"} {"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"}

661
we.md
View File

@@ -1,661 +0,0 @@
# 店闪扩展执行链路说明
这份文档按当前项目代码整理,目的是方便顺着代码阅读整个爬取流程。
## 1. 扩展入口关系
扩展入口由 `manifest.config.ts` 配置:
- `action.default_popup` 指向 `src/popup/index.html`,点击浏览器插件图标后打开 popup。
- `background.service_worker` 指向 `src/background/index.ts`,负责接收消息、创建爬取窗口、执行爬取任务。
- `content_scripts` 指向 `src/content/main.ts`,会注入到所有 `http/https` 页面,用于右下角进度浮窗和页面 DOM 抓取。
- `permissions` 中的 `storage``tabs``windows` 支撑状态保存、页面跳转和窗口管理。
## 2. 点击 popup 后发生什么
### 2.1 popup 初始化
文件:`src/popup/App.vue`
触发方法:
- `onMounted`
- `getToken`
- `refreshCrawlState`
- `updateElapsedSeconds`
执行过程:
1. 用户点击扩展图标Chrome 打开 `src/popup/index.html`
2. Vue 加载 `src/popup/App.vue`
3. `onMounted` 执行:
- 调用 `getToken()` 读取登录状态。
- 调用 `refreshCrawlState()` 获取当前爬取任务。
- 启动 `setInterval`,每秒刷新爬取状态和计时。
4. `getToken()` 来自 `src/shared/auth.ts`
- 优先从 `chrome.storage.local` 读取 `token`
- 非扩展环境下从 `localStorage` 读取。
### 2.2 点击“立即爬取”
文件:`src/popup/App.vue`
触发方法:
- `handleScan`
- `sendBackgroundMessage`
执行过程:
1. 用户点击“立即爬取”按钮。
2. `handleScan()` 检查当前是否选择了平台。
3. 通过 `sendBackgroundMessage()` 发送消息给 background
```ts
{
action: 'START_CRAWL',
payload: { platformId: selectedPlatform.value.id },
}
```
4. `sendBackgroundMessage()` 实际调用:
```ts
chrome.runtime.sendMessage(message)
```
5. popup 收到 background 返回的 `CrawlTaskState` 后,更新本地的 `crawlState`,页面开始显示进度。
## 3. background 如何接住 popup 消息
### 3.1 background 消息入口
文件:`src/background/index.ts`
触发方法:
- `chrome.runtime.onMessage.addListener`
- `handleBackgroundMessage`
执行过程:
1. background service worker 启动后,注册 `chrome.runtime.onMessage`
2. popup 发来的 `START_CRAWL` 会进入 `handleBackgroundMessage()`
3. `handleBackgroundMessage()` 调用:
```ts
handleBackgroundCommand(message)
```
4. 如果执行成功,调用 `sendResponse(result)` 把结果回给 popup。
5. 如果执行失败,统一返回:
```ts
{ ok: false, error: messageText }
```
### 3.2 background 指令分发
文件:`src/background/service.ts`
作用:
```ts
export {
handleBackgroundCommand,
handleInstalled,
handleStartup,
handleWindowRemoved,
} from './service/lifecycle';
```
这个文件现在只是 re-export真正逻辑在 `src/background/service/lifecycle.ts`
文件:`src/background/service/lifecycle.ts`
触发方法:
- `handleBackgroundCommand`
分发关系:
- `START_CRAWL` -> `startCrawl`
- `GET_CRAWL_STATE` -> `getCrawlTaskState`
- `CANCEL_CRAWL` -> `cancelCrawl`
- `RESUME_CRAWL` -> `resumeCrawl`
这些消息类型定义在 `src/background/types.ts`
## 4. 创建爬取任务和新窗口
文件:`src/background/service/crawlTask.ts`
触发方法:
- `startCrawl`
- `createCrawlWindow`
- `runCrawlSteps`
执行过程:
1. `startCrawl(platformId)` 先调用 `getPlatformById(platformId)`
2. `getPlatformById` 来自 `src/config/platforms.ts`,用于找到当前平台配置。
3. 读取平台配置中的第一个 step
```ts
const firstStep = platform.steps[0];
```
4. 创建初始任务状态 `CrawlTaskState`
- `id`
- `platformId`
- `platformName`
- `startedAt`
- `status: 'running'`
- `currentStepIndex: 0`
- `steps`
5. 调用 `setCrawlTaskState(nextState)` 写入 `chrome.storage.local`
6. 调用 `createCrawlWindow(firstStep.url)` 打开新的普通浏览器窗口。
7. 窗口创建成功后,把 `windowId` 写回任务状态。
8. 调用:
```ts
void runCrawlSteps(platform, stateWithWindow);
```
这里使用 `void`,表示后台任务异步继续跑;`startCrawl` 会先把初始状态返回给 popup。
## 5. 爬取状态保存在哪里
文件:`src/background/service/taskState.ts`
核心方法:
- `getCrawlTaskState`
- `setCrawlTaskState`
- `updateCrawlTaskState`
保存位置:
```ts
chrome.storage.local
```
保存 key
```ts
crawlTaskState
```
当前项目没有把爬取结果单独保存到数据库、文件或独立 result key。每一步的结果直接保存在
```ts
CrawlTaskState.steps[index].result
```
也就是说popup 和 content 看到的进度、暂停信息、最终结果,都来自 `chrome.storage.local` 中的 `crawlTaskState`
## 6. background 如何逐步爬取页面
文件:`src/background/service/crawlTask.ts`
核心方法:
- `runCrawlSteps`
- `getWindowActiveTabId`
- `waitForTabLoaded`
- `scrapeStepInContent`
- `sendPageRunnerMessage`
- `pauseForInterrupt`
- `waitUntilResumed`
执行过程:
1. `runCrawlSteps(platform, initialState)``platform.steps` 顺序循环。
2. 每进入一个 step会调用 `updateCrawlTaskState`
- 更新 `currentStepIndex`
- 把当前 step 标记为 `running`
- 清空当前 step 的旧 message。
3. 调用 `getWindowActiveTabId(windowId)` 找到爬取窗口里的当前 tab。
4. 调用:
```ts
chrome.tabs.update(tabId, { url: step.url, active: true })
```
跳转到当前 step 配置的页面地址。
5. 调用 `waitForTabLoaded(tabId)` 等待 Chrome 的 tab 状态变成 `complete`
6. 调用 `scrapeStepInContent(tabId, step)`,让目标页面里的 content script 开始检查页面和抓取 DOM。
## 7. background 怎么通知目标网页抓取
文件:`src/background/service/crawlTask.ts`
触发方法:
- `scrapeStepInContent`
- `sendPageRunnerMessage`
发送消息:
```ts
{
action: 'SCRAPE_STEP',
payload: {
fields: step.fields,
checkSelector: step.checkSelector,
},
}
```
实际调用:
```ts
chrome.tabs.sendMessage(tabId, message)
```
这里不是 popup 发给 background而是 background 发给目标网页 tab 里的 content script。
`scrapeStepInContent` 还有一个容错逻辑:如果 content script 还没注入完成,出现类似 `Could not establish connection. Receiving end does not exist.` 的错误,会在 20 秒内每 500ms 重试一次。
## 8. content script 如何接住抓取消息
### 8.1 content script 挂载
文件:`src/content/main.ts`
触发方法:
- `mountApp`
- `setupPageRunner`
执行过程:
1. 目标页面加载时Chrome 根据 `manifest.config.ts` 注入 `src/content/main.ts`
2. `main.ts` 等待 DOM 可用后执行 `mountApp()`
3. `mountApp()` 创建 `#dianshan-crx-root` 容器。
4. 挂载 `src/content/App.vue`,用于显示右下角爬取计时按钮和进度面板。
5. 调用 `setupPageRunner()` 注册页面消息监听器。
### 8.2 页面执行器接收 SCRAPE_STEP
文件:`src/content/pageRunner.ts`
触发方法:
- `setupPageRunner`
- `handlePageRunnerMessage`
- `detectPageInterrupt`
- `waitForStableSelector`
- `processFields`
执行过程:
1. `setupPageRunner()` 注册:
```ts
chrome.runtime.onMessage.addListener(...)
```
2. background 发送 `SCRAPE_STEP` 后,进入 `handlePageRunnerMessage()`
3. 先调用 `detectPageInterrupt()` 判断是否遇到:
- 登录页:`reauth`
- 验证码或风控:`shield`
- 页面不存在:`not_found`
4. 如果检测到中断,返回:
```ts
{ ok: false, interrupt }
```
5. 如果没有中断,调用 `waitForStableSelector(checkSelector, 18000)`
6. `checkSelector` 来自 `src/config/platforms.ts` 的 step 配置,用于判断页面关键 DOM 是否出现并可见。
7. 如果 18 秒内关键 DOM 仍未稳定出现,返回 `page_not_ready` 中断。
8. 如果页面可用,调用:
```ts
processFields(message.payload.fields, document.body)
```
开始真正的 DOM 字段采集。
## 9. DOM 数据怎么提取
文件:`src/background/domScraper.ts`
虽然文件在 `background` 目录下,但它被 `src/content/pageRunner.ts` import 后,实际是在目标网页的 content script 环境里执行 DOM 查询。
核心方法:
- `processFields`
- `processList`
- `processTable`
- `autoClick`
- `extractValue`
- `waitForElement`
执行逻辑:
1. `processFields(columns, rootDom)` 遍历当前 step 的 `fields`
2. 每个字段先执行 `autoClick(item, rootDom)`
- 如果字段配置了 `condition.list`,就按顺序点击对应选择器。
- 每次点击后等待 `condition.time`
3. 再根据 `item.className` 查询元素。
4. 普通字段:
- 没有 `keys` 时,调用 `extractValue(element, item)`
-`keys` 时,递归调用 `processFields(item.keys, element)`
5. 列表字段:
- `type === 1` 时进入 `processList`
-`config.className` 找到列表项。
- 对每个列表项递归执行 `processFields(config.keys, element)`
- 如果配置了 `pagination`,会点击下一页继续采集。
6. 表格字段:
- `type === 2` 时进入 `processTable`
-`tableParts` 找到不同 table 片段。
- 以第一个 part 的行数为准,按行拼接不同 part 的字段。
- 如果配置了 `pagination`,会点击下一页继续采集。
7. `extractValue` 的取值规则:
- 配置了 `attr` 就取指定属性。
- `IMG` 默认取 `src`
- `A` 默认取 `href`,相对路径会拼上当前 origin。
- 其他元素默认取 `textContent`
## 10. 采集结果怎么回传和保存
数据流向:
1. `processFields` 返回当前 step 的 DOM 采集结果。
2. `src/content/pageRunner.ts` 返回:
```ts
{ ok: true, data }
```
3. `src/background/service/crawlTask.ts``scrapeStepInContent` 接到 response。
4. `runCrawlSteps` 调用 `updateCrawlTaskState`
```ts
steps[index].result = response.data
steps[index].status = 'success'
```
5. 最新任务状态写回 `chrome.storage.local``crawlTaskState`
6. popup 和 content 浮窗每秒发送 `GET_CRAWL_STATE`,读取到最新结果并展示。
所以当前项目的数据传递是:
```text
目标网页 DOM
-> content/pageRunner.ts
-> background/crawlTask.ts
-> chrome.storage.local:crawlTaskState
-> popup/App.vue 和 content/App.vue 轮询展示
```
## 11. popup 进度如何刷新
文件:`src/popup/App.vue`
触发方法:
- `refreshCrawlState`
- `sendBackgroundMessage`
- `updateElapsedSeconds`
执行过程:
1. popup 打开后每秒调用一次 `refreshCrawlState()`
2. `refreshCrawlState()` 发送:
```ts
{ action: 'GET_CRAWL_STATE' }
```
3. background 的 `handleBackgroundCommand` 收到后调用 `getCrawlTaskState()`
4. popup 拿到 `CrawlTaskState` 后:
- 展示平台名。
- 展示运行时间。
- 展示每个 step 的状态。
- 如果 step 有 `result`,用 `JSON.stringify(step.result, null, 2)` 打印出来。
## 12. 目标网页右下角按钮如何刷新
文件:`src/content/App.vue`
触发方法:
- `onMounted`
- `refreshCrawlState`
- `updateElapsedSeconds`
- `handleResumeCrawl`
执行过程:
1. content script 挂载后,`src/content/App.vue` 每秒调用 `refreshCrawlState()`
2. 它同样发送:
```ts
{ action: 'GET_CRAWL_STATE' }
```
3. 如果任务状态是 `running``paused`,显示右下角计时按钮。
4. 点击按钮会展开进度面板。
5. 如果任务是 `paused`,面板里显示暂停原因,并提供“我已处理,继续”按钮。
6. 点击继续时,调用 `handleResumeCrawl()`,发送:
```ts
{ action: 'RESUME_CRAWL' }
```
## 13. 暂停和继续流程
触发原因主要来自 `src/content/pageRunner.ts`
- `isLoginPage()` 检测到登录页。
- `isShieldPage()` 检测到验证码或风控。
- `isNotFoundPage()` 检测到页面不存在。
- `waitForStableSelector()` 超时,认为页面关键内容没准备好。
流程:
1. content 返回:
```ts
{ ok: false, interrupt }
```
2. background 的 `runCrawlSteps` 检测到 `response.interrupt`
3. 调用 `pauseForInterrupt(taskId, stepIndex, interrupt)`
4. `pauseForInterrupt` 把状态写成:
- `status: 'paused'`
- `pause: interrupt`
- 当前 step 保持 `running`
- 当前 step 的 `message` 设置为中断提示
5. popup 和 content 每秒轮询到 paused 状态后展示提示。
6. 用户处理完登录或验证码后,点击继续。
7. popup 或 content 发送 `RESUME_CRAWL`
8. background 调用 `resumeCrawl()`
- `status` 改回 `running`
- 清空 `pause`
- 当前 step 的 `message` 清空
9. `runCrawlSteps` 中的 `waitUntilResumed()` 发现状态恢复为 `running`,重新执行当前 step。
## 14. 取消和关闭窗口流程
### 14.1 用户点击取消
文件:
- `src/popup/App.vue`
- `src/background/service/lifecycle.ts`
- `src/background/service/crawlTask.ts`
方法:
- `handleCancelCrawl`
- `handleBackgroundCommand`
- `cancelCrawl`
流程:
1. popup 发送:
```ts
{ action: 'CANCEL_CRAWL' }
```
2. background 分发到 `cancelCrawl()`
3. `cancelCrawl()` 把当前任务状态改成 `canceled`
4. 当前 step 被标记为 `failed`message 为 `用户已取消`
5. 如果存在 `windowId`,调用 `chrome.windows.remove(windowId)` 关闭爬取窗口。
### 14.2 用户直接关闭爬取窗口
文件:
- `src/background/index.ts`
- `src/background/service/lifecycle.ts`
- `src/background/service/crawlTask.ts`
方法:
- `chrome.windows.onRemoved.addListener`
- `handleWindowRemoved`
- `cancelCrawlWhenWindowRemoved`
流程:
1. Chrome 触发 `windows.onRemoved`
2. `handleWindowRemoved(windowId)` 被调用。
3. `cancelCrawlWhenWindowRemoved(windowId)` 检查关闭的是否是当前爬取窗口。
4. 如果匹配且任务还在 running就把任务改成 `canceled`
5. 当前 step 标记为 `failed`message 为 `爬取窗口已关闭`
## 15. 平台配置如何驱动爬取
文件:`src/config/platforms.ts`
核心导出:
- `PLATFORM_CONFIGS`
- `getPlatformById`
平台配置结构来自 `src/types/platform.ts`
- `PlatformConfig`
- `PlatformStepConfig`
- `PlatformFieldConfig`
- `PlatformPaginationConfig`
- `PlatformTablePartConfig`
- `PlatformClickCondition`
关键字段:
- `steps`:平台要按顺序爬取的页面。
- `step.url`:当前步骤要打开的页面地址。
- `step.checkSelector`:判断页面是否可抓取的关键元素。
- `step.fields`:当前页面要采集的字段。
- `field.className`:字段选择器。
- `field.keys`:子字段,支持递归。
- `field.type`:字段类型,默认普通字段,`1` 是列表,`2` 是表格。
- `field.condition`:采集字段前要自动点击的元素。
- `field.pagination`:列表或表格翻页配置。
- `field.tableParts`:表格分段配置,用来拼接多个 table 片段。
## 16. 类型结构在哪里看
主要类型文件:
- `src/background/types.ts`background 接收的消息类型和统一响应类型。
- `src/types/crawl.ts`:爬取任务状态、步骤状态、暂停原因。
- `src/types/platform.ts`:平台配置、字段配置、分页配置、表格配置。
- `src/types/index.ts`:统一导出类型。
最关键的运行时状态是 `CrawlTaskState`
```ts
interface CrawlTaskState {
id: string;
platformId: string;
platformName: string;
windowId?: number;
startedAt: number;
status: 'running' | 'paused' | 'completed' | 'failed' | 'canceled';
pause?: CrawlPauseInfo;
currentStepIndex: number;
steps: CrawlProgressStep[];
}
```
每个 step 的结果保存在:
```ts
interface CrawlProgressStep {
name: string;
uniqueKey: string;
status: 'pending' | 'running' | 'success' | 'failed';
message?: string;
result?: unknown;
}
```
## 17. 整体数据流图
```text
用户点击插件图标
-> src/popup/App.vue:onMounted
-> 读取 token 和 crawlTaskState
用户点击立即爬取
-> src/popup/App.vue:handleScan
-> chrome.runtime.sendMessage({ action: 'START_CRAWL' })
background 接收消息
-> src/background/index.ts:handleBackgroundMessage
-> src/background/service/lifecycle.ts:handleBackgroundCommand
-> src/background/service/crawlTask.ts:startCrawl
创建任务和窗口
-> src/background/service/taskState.ts:setCrawlTaskState
-> src/background/service/crawlTask.ts:createCrawlWindow
-> src/background/service/crawlTask.ts:runCrawlSteps
逐个页面跳转
-> chrome.tabs.update(tabId, { url: step.url })
-> src/background/service/crawlTask.ts:waitForTabLoaded
-> src/background/service/crawlTask.ts:scrapeStepInContent
目标页面执行抓取
-> chrome.tabs.sendMessage({ action: 'SCRAPE_STEP' })
-> src/content/pageRunner.ts:handlePageRunnerMessage
-> src/content/pageRunner.ts:waitForStableSelector
-> src/background/domScraper.ts:processFields
结果回到 background
-> src/background/service/crawlTask.ts:runCrawlSteps
-> src/background/service/taskState.ts:updateCrawlTaskState
-> 写入 chrome.storage.local:crawlTaskState
多端展示
-> src/popup/App.vue 每秒 GET_CRAWL_STATE
-> src/content/App.vue 每秒 GET_CRAWL_STATE
-> popup 展示完整进度和结果
-> 目标网页右下角展示计时按钮和进度面板
```
## 18. 推荐阅读代码顺序
如果要按最顺的方式读代码,可以这样看:
1. `manifest.config.ts`:先看扩展入口。
2. `src/popup/App.vue`:看用户点按钮时发什么消息。
3. `src/background/index.ts`:看 background 怎么接消息。
4. `src/background/service/lifecycle.ts`:看消息怎么分发。
5. `src/background/service/crawlTask.ts`:看任务创建、窗口打开、页面跳转、暂停恢复。
6. `src/background/service/taskState.ts`:看状态怎么保存。
7. `src/content/main.ts`:看 content script 怎么挂载。
8. `src/content/pageRunner.ts`:看目标页面怎么接收 background 抓取指令。
9. `src/background/domScraper.ts`:看 DOM 字段怎么递归提取。
10. `src/config/platforms.ts`:结合抓取逻辑看配置如何控制页面和字段。
11. `src/types/crawl.ts``src/types/platform.ts`:最后看数据结构。