Compare commits
18 Commits
8b9985873a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d1397c277 | ||
|
|
cb5a13d352 | ||
|
|
302311b3af | ||
|
|
c7cb977243 | ||
|
|
cf7ea741a6 | ||
|
|
a59178b816 | ||
|
|
f8972f72ba | ||
|
|
6677ec5eec | ||
|
|
186840ba23 | ||
|
|
30f9467cc8 | ||
|
|
6587b0c1d9 | ||
|
|
d6dc8a0db4 | ||
|
|
a22f1a42e4 | ||
|
|
7e2a83efd6 | ||
|
|
40df507300 | ||
|
|
d78d70bde0 | ||
|
|
53e4f0b2f4 | ||
|
|
350d4fc2e2 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,6 +9,7 @@ lerna-debug.log*
|
|||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
|
storeai-extension-v0.1.0
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
|||||||
@@ -19,5 +19,3 @@
|
|||||||
7.在窗口中记得显示一个取消按钮,点击后关闭窗口,取消爬取
|
7.在窗口中记得显示一个取消按钮,点击后关闭窗口,取消爬取
|
||||||
|
|
||||||
|
|
||||||
# 具体代码实现流程
|
|
||||||
请阅读./step.md文档,并严格按照步骤进行执行
|
|
||||||
@@ -22,9 +22,14 @@ 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',
|
||||||
},
|
},
|
||||||
|
externally_connectable: {
|
||||||
|
matches: [
|
||||||
|
"http://localhost/*",
|
||||||
|
]
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "vite-project",
|
"name": "vite-project",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.1.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -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
211
pnpm-lock.yaml
generated
@@ -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: {}
|
||||||
|
|||||||
389
s.md
Normal file
389
s.md
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
# dianshan 插件爬取任务开发文档(逻辑/通信/状态)
|
||||||
|
|
||||||
|
> 目的:把“点击爬取后发生了什么、会触发哪些方法、给谁通信、改变哪些状态”说清楚,方便后续二开与排查。
|
||||||
|
>
|
||||||
|
> 范围:`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. 关键文件索引(按职责)
|
||||||
|
|
||||||
|
### 1.1 Popup(扩展弹窗 UI)
|
||||||
|
- `dianshan/src/popup/App.vue`
|
||||||
|
- UI 展示:平台选择、开始/取消/继续按钮、步骤列表、底部语言切换/版本号
|
||||||
|
- `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 / ...` 等事件
|
||||||
|
|
||||||
|
### 1.3 Content Script(爬取窗口页内逻辑)
|
||||||
|
- `dianshan/src/content/pageRunner.ts`
|
||||||
|
- 接收 background 指令:
|
||||||
|
- `CHECK_INTERRUPT`:判断登录/验证码/404/未就绪等“需要人工处理”的中断
|
||||||
|
- `SCRAPE_STEP`:等待关键 selector 稳定后执行 DOM 抓取
|
||||||
|
- `dianshan/src/content/crawlOverlay.ts`
|
||||||
|
- 爬取窗口左下角悬浮 UI(圆形计时菜单)
|
||||||
|
- 点击圆形菜单展开后展示“与 popup 类似”的步骤进度/暂停提示/继续/取消
|
||||||
|
- 接收 background 推送的 `CRAWL_STATE_UPDATE` 状态
|
||||||
|
- 初始化时会拉取一次 `GET_CRAWL_STATE_FOR_TAB` 快照(只在爬取 tab 返回状态)
|
||||||
|
|
||||||
|
### 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` 等任务数据结构
|
||||||
|
|
||||||
|
### 1.5 关键方法定位(按真实代码)
|
||||||
|
> 下面列的都是当前项目里“确实存在”的方法/入口,后面章节会反复引用;每条都附带文件地址+行号,方便你 Ctrl+P 直达。
|
||||||
|
|
||||||
|
- 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`
|
||||||
|
|
||||||
|
- background 消息总入口:`chrome.runtime.onMessage.addListener(...)`:`dianshan/src/background/index.ts:22`
|
||||||
|
- background 路由:`case "START_CRAWL"` 等:`dianshan/src/background/index.ts:34`
|
||||||
|
- background 窗口/Tab 关闭监听:`windows.onRemoved`/`tabs.onRemoved`:`dianshan/src/background/index.ts:92`
|
||||||
|
|
||||||
|
- 任务启动:`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`
|
||||||
|
|
||||||
|
- 状态读写:`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`
|
||||||
|
|
||||||
|
- content 执行器入口:`setupPageRunner()`:`dianshan/src/content/pageRunner.ts:28`
|
||||||
|
- content 抓取处理:`handlePageRunnerMessage()`:`dianshan/src/content/pageRunner.ts:38`
|
||||||
|
|
||||||
|
- 爬取窗口悬浮 UI:`mountCrawlOverlay()`:`dianshan/src/content/crawlOverlay.ts:36`
|
||||||
|
- 悬浮 UI 初始化拉取快照:`GET_CRAWL_STATE_FOR_TAB`:`dianshan/src/content/crawlOverlay.ts:67`
|
||||||
|
- 悬浮 UI 实时状态推送:`CRAWL_STATE_UPDATE`:`dianshan/src/content/crawlOverlay.ts:54`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 核心数据结构:CrawlTaskState(状态机)
|
||||||
|
|
||||||
|
存储位置:`chrome.storage.local['crawlTaskState']`
|
||||||
|
|
||||||
|
关键字段(简化说明):
|
||||||
|
- `id`:任务唯一 ID(`platformId-startedAt`)
|
||||||
|
- `platformId/platformName`:平台信息
|
||||||
|
- `windowId/tabId`:当前爬取窗口与承载爬取的 tab(overlay 只在这个 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`:抓取结果或失败原因
|
||||||
|
|
||||||
|
状态同步策略:
|
||||||
|
- background 每次 `setCrawlTaskState()` / `updateCrawlTaskState()` 都会写入 storage
|
||||||
|
- popup:监听 `chrome.storage.onChanged`,永远以 storage 为准渲染 UI
|
||||||
|
- overlay:通过 `sendTabMessage(tabId,'CRAWL_STATE_UPDATE',state)` 实时更新(并可在初始化时请求快照)
|
||||||
|
- 外部网页:`chrome.storage.onChanged` -> `externalBridge.broadcastCrawlStorageChange()` 广播结果/状态
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. “点击爬取”后的完整时序(从 UI 到抓取)
|
||||||
|
|
||||||
|
以下用“触发方法 / 通信对象 / 状态变化”描述每一步。
|
||||||
|
|
||||||
|
### 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 } })`
|
||||||
|
|
||||||
|
3) background 入口
|
||||||
|
- `background/index.ts` 的 `chrome.runtime.onMessage` 收到 `START_CRAWL`(`dianshan/src/background/index.ts:22` / `dianshan/src/background/index.ts:34`)
|
||||||
|
- 调用:`crawlTask.startCrawl(platformId)`(`dianshan/src/background/task/crawlTask.ts:14`)
|
||||||
|
|
||||||
|
4) 状态变化(startCrawl)
|
||||||
|
- `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)
|
||||||
|
|
||||||
|
5) 启动执行器
|
||||||
|
- background:创建 `AbortController`,并异步执行:
|
||||||
|
- `runCrawlSteps(taskId, tabId, platform.steps, signal, startIndex=0)`(`dianshan/src/background/task/crawlTask.ts:63`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. “执行一个步骤”会发生什么(runCrawlSteps 的循环体)
|
||||||
|
|
||||||
|
每个 step 的处理流程如下(对每个 i 从 startIndex 到 steps.length-1):
|
||||||
|
|
||||||
|
### 4.1 进入新 step:先更新状态机
|
||||||
|
1) 触发方法
|
||||||
|
- background:`updateCrawlTaskState(taskId, updater)`
|
||||||
|
|
||||||
|
2) 状态变化
|
||||||
|
- `currentStepIndex = i`
|
||||||
|
- `steps[i].status = 'running'`(其它步骤保持原样)
|
||||||
|
|
||||||
|
3) 通信影响
|
||||||
|
- 因为 `updateCrawlTaskState()` 内部会 `setCrawlTaskState()`:
|
||||||
|
- storage 写入 -> popup UI 更新
|
||||||
|
- 同时 `sendTabMessage(tabId,'CRAWL_STATE_UPDATE',state)` -> overlay 更新
|
||||||
|
- storage.onChanged -> external bridge 广播(如果外部网页连着)
|
||||||
|
|
||||||
|
### 4.2 跳转页面并等待加载完成
|
||||||
|
1) 触发方法
|
||||||
|
- background:`chrome.tabs.update(tabId, { url: step.url, active: true })`
|
||||||
|
- background:`waitForTabLoaded(tabId, signal)`
|
||||||
|
|
||||||
|
2) 通信对象
|
||||||
|
- background -> Chrome tabs API(无 content 参与)
|
||||||
|
|
||||||
|
3) 状态变化
|
||||||
|
- 不改变任务状态,只是准备让 content script 进入目标页面上下文
|
||||||
|
|
||||||
|
### 4.3 让 content script 执行抓取(核心通信)
|
||||||
|
1) 触发方法
|
||||||
|
- background:`scrapeStepInContent(tabId, step, signal)`
|
||||||
|
|
||||||
|
2) 通信
|
||||||
|
- background -> content(爬取窗口 tab):`chrome.tabs.sendMessage(tabId, { action:'SCRAPE_STEP', payload:{ fields, checkSelector } })`
|
||||||
|
|
||||||
|
3) content 入口
|
||||||
|
- `content/pageRunner.ts` 的 `chrome.runtime.onMessage` 收到 `SCRAPE_STEP`
|
||||||
|
- `detectPageInterrupt()`:先判断是否需要人工处理(登录/验证码/404/未就绪)
|
||||||
|
- `waitForStableSelector(checkSelector, timeout)`:等待关键 DOM 稳定出现
|
||||||
|
- `processFields(fields, document.body)`:按配置抓取 DOM 数据
|
||||||
|
|
||||||
|
4) 返回值约定(PageRunnerResponse)
|
||||||
|
- `ok: true, data: DomScrapeResult`:本步骤抓取成功
|
||||||
|
- `interrupt: CrawlPauseInfo`:需要人工处理(会进入 paused)
|
||||||
|
- `ok: false, error`:未就绪/异常,background 会重试
|
||||||
|
|
||||||
|
### 4.4 抓取成功:写入结果并标记 step success
|
||||||
|
1) 触发方法
|
||||||
|
- background:`updateCrawlTaskState(taskId, updater)`
|
||||||
|
|
||||||
|
2) 状态变化
|
||||||
|
- `steps[i].status = 'success'`
|
||||||
|
- `steps[i].result = res.data`
|
||||||
|
|
||||||
|
3) 通信影响
|
||||||
|
- 同 4.1:storage + 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)
|
||||||
|
- 网页建立 Port(name=`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
24
src/api/me.ts
Normal 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
12
src/api/scan.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,35 +1,37 @@
|
|||||||
import type { PlatformFieldConfig } from '@/types';
|
import type {PlatformFieldConfig} from '@/types';
|
||||||
|
|
||||||
/** DOM 抓取后的通用结果结构。 */
|
|
||||||
export type DomScrapeResult = Record<string, unknown>;
|
export type DomScrapeResult = Record<string, unknown>;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 在目标网页上下文中执行 DOM 抓取。
|
* 等待重试机制
|
||||||
*
|
|
||||||
* 注意:该方法会通过 chrome.scripting.executeScript 注入到页面中执行,
|
|
||||||
* 所以依赖的辅助方法都写在函数内部,避免注入后丢失模块作用域。
|
|
||||||
*/
|
*/
|
||||||
export async function scrapeDomFields(fields: PlatformFieldConfig[]): Promise<DomScrapeResult | null> {
|
async function waitForElement(rootDom: ParentNode, selector: string) {
|
||||||
if (!document.body) {
|
let retryCount = 5;
|
||||||
return null;
|
for (let i = 0; i < retryCount; i++) {
|
||||||
|
const element = rootDom.querySelector(selector);
|
||||||
|
if (element) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
if (i < retryCount) {
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
return processFields(fields, document.body);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** 睡眠工具,给点击、翻页、异步渲染留出等待时间。 */
|
// 睡眠工具,给点击、翻页、异步渲染留出等待时间。
|
||||||
const sleep = (ms?: number) => new Promise((resolve) => window.setTimeout(resolve, ms ?? 1500));
|
const sleep = (ms?: number) => new Promise((resolve) => window.setTimeout(resolve, ms ?? 1500));
|
||||||
|
|
||||||
/** 从元素中提取实际值,默认取文本,也支持 attr、图片 src、链接 href。 */
|
/**
|
||||||
|
* 从元素中提取实际值,默认取文本,也支持 attr、图片 src、链接 href。
|
||||||
|
*/
|
||||||
function extractValue(el: Element | null, config: PlatformFieldConfig): string | null {
|
function extractValue(el: Element | null, config: PlatformFieldConfig): string | null {
|
||||||
if (!el) {
|
if (el == null) {
|
||||||
return null;
|
return "未找到"
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.attr) {
|
if (config.attr) {
|
||||||
return (el.getAttribute(config.attr) || '').trim();
|
return (el.getAttribute(config.attr) || "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagName = el.tagName.toUpperCase();
|
const tagName = el.tagName.toUpperCase();
|
||||||
@@ -46,30 +48,33 @@ function extractValue(el: Element | null, config: PlatformFieldConfig): string |
|
|||||||
return (el.textContent || '').replace(/\n/g, '').trim();
|
return (el.textContent || '').replace(/\n/g, '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 根据字段 condition 配置在指定 DOM 范围内自动点击目标元素。 */
|
/**
|
||||||
async function autoClick(config: PlatformFieldConfig, rootDom: ParentNode): Promise<void> {
|
* 自动点击
|
||||||
|
* 根据字段 condition 配置在指定 DOM 范围内自动点击目标元素。
|
||||||
|
*/
|
||||||
|
async function autoClick(config: PlatformFieldConfig, rootDom: Element): Promise<void> {
|
||||||
if (!config.condition) {
|
if (!config.condition) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
for (const condition of config.condition.list) {
|
||||||
for (const selector of config.condition.list) {
|
let targets: HTMLElement[] = Array.from(rootDom.querySelectorAll(condition))
|
||||||
const targets = Array.from(rootDom.querySelectorAll<HTMLElement>(selector));
|
|
||||||
|
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
target.click();
|
target.click();
|
||||||
await sleep(config.condition.time);
|
await sleep(config?.condition.time);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 递归处理字段配置,支持普通字段、嵌套 row、列表和表格。 */
|
/**
|
||||||
async function processFields(columns: PlatformFieldConfig[], rootDom: ParentNode): Promise<DomScrapeResult> {
|
* 递归处理字段配置,支持普通字段、嵌套 row、列表和表格。
|
||||||
const result: DomScrapeResult = {};
|
*/
|
||||||
|
export async function processFields(columns: PlatformFieldConfig[], rootDom: Element) {
|
||||||
|
const result = {} as any;
|
||||||
|
|
||||||
for (const item of columns) {
|
for (const item of columns) {
|
||||||
await autoClick(item, rootDom);
|
await autoClick(item, rootDom);
|
||||||
|
|
||||||
const element = rootDom.querySelector(item.className);
|
const element = await waitForElement(rootDom, item.className)
|
||||||
|
|
||||||
if (!element) {
|
if (!element) {
|
||||||
result[item.label] = '没找到该元素';
|
result[item.label] = '没找到该元素';
|
||||||
@@ -100,9 +105,13 @@ async function processFields(columns: PlatformFieldConfig[], rootDom: ParentNode
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按列表配置抓取所有列表项,并按分页配置继续翻页。 */
|
/**
|
||||||
async function processList(config: PlatformFieldConfig, rootDom: ParentNode): Promise<DomScrapeResult[]> {
|
* 提取列表的数据
|
||||||
const allList: DomScrapeResult[] = [];
|
* @param config 配置
|
||||||
|
* @param rootDom 父节点
|
||||||
|
*/
|
||||||
|
async function processList(config: PlatformFieldConfig, rootDom: ParentNode) {
|
||||||
|
const allList = [];
|
||||||
let pageCount = 0;
|
let pageCount = 0;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -148,55 +157,52 @@ async function processList(config: PlatformFieldConfig, rootDom: ParentNode): Pr
|
|||||||
return allList;
|
return allList;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按表格配置抓取表格行数据,并按分页配置继续翻页。 */
|
/**
|
||||||
async function processTable(config: PlatformFieldConfig, rootDom: ParentNode): Promise<DomScrapeResult[]> {
|
* 按表格配置抓取表格行数据,并按分页配置继续翻页。
|
||||||
const allTableData: DomScrapeResult[] = [];
|
*/
|
||||||
|
async function processTable(config: PlatformFieldConfig, rootDom: ParentNode) {
|
||||||
|
const allTableData: any[] = [];
|
||||||
let pageCount = 0;
|
let pageCount = 0;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
pageCount += 1;
|
pageCount += 1;
|
||||||
|
|
||||||
const partsNodes: Record<string, Element[]> = {};
|
const partsNodes: any = {};
|
||||||
|
|
||||||
for (const part of config.tableParts ?? []) {
|
config.tableParts!.forEach(part => {
|
||||||
const partKey = part.name ?? part.label;
|
partsNodes[part.name as any] = rootDom.querySelectorAll(`${part.select} tr`);
|
||||||
const partSelector = part.select ?? part.className;
|
});
|
||||||
const rowSelector = part.rowSelector ?? `${partSelector} tr`;
|
|
||||||
partsNodes[partKey] = Array.from(rootDom.querySelectorAll(rowSelector));
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstPart = config.tableParts?.[0];
|
// //以第一个part的行数为准,进行横向扫描
|
||||||
const firstPartKey = firstPart ? firstPart.name ?? firstPart.label : '';
|
const rowCount = partsNodes[config.tableParts![0].name!]?.length || 0
|
||||||
const rowCount = partsNodes[firstPartKey]?.length || 0;
|
|
||||||
|
|
||||||
for (let index = 0; index < rowCount; index += 1) {
|
|
||||||
const rowData: DomScrapeResult = {};
|
|
||||||
|
|
||||||
for (const keyItem of config.keys ?? []) {
|
for (let i = 0; i < rowCount; i++) {
|
||||||
const partKey = keyItem.part ?? firstPartKey;
|
const rowData: any = {};
|
||||||
const targetRowNode = partsNodes[partKey]?.[index];
|
|
||||||
|
|
||||||
if (!targetRowNode) {
|
//遍历keys,根据part映射,取对应的里面找
|
||||||
continue;
|
for (const keyItem of config.keys!) {
|
||||||
}
|
const targetRowNode = partsNodes[keyItem.part!][i];
|
||||||
|
|
||||||
if (keyItem.keys) {
|
if (targetRowNode) {
|
||||||
rowData[keyItem.label] = await processFields(keyItem.keys, targetRowNode);
|
//提取值
|
||||||
} else {
|
if (keyItem.keys) {
|
||||||
rowData[keyItem.label] = extractValue(targetRowNode.querySelector(keyItem.className), keyItem);
|
rowData[keyItem.label] = await processFields(keyItem.keys, targetRowNode)
|
||||||
|
} else {
|
||||||
|
rowData[keyItem.label] = extractValue(targetRowNode.querySelector(keyItem.className), keyItem);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
allTableData.push(rowData);
|
allTableData.push(rowData);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!config.pagination) {
|
if (!config.pagination) {
|
||||||
console.log('未配置分页信息,抓取单页后结束。');
|
console.log("未配置分页信息,抓取单页后结束。");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.pagination.maxPage && pageCount >= config.pagination.maxPage) {
|
if (config.pagination.maxPage && pageCount >= config.pagination.maxPage) {
|
||||||
console.log('已达到配置的最大页数,停止。');
|
console.log("已达到配置的最大页数,停止。");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,4 +227,4 @@ async function processTable(config: PlatformFieldConfig, rootDom: ParentNode): P
|
|||||||
}
|
}
|
||||||
|
|
||||||
return allTableData;
|
return allTableData;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,138 @@
|
|||||||
import { handleBackgroundCommand, handleInstalled, handleStartup, handleWindowRemoved } from './service';
|
import {broadcastCrawlStorageChange, handleExternalConnect, handleExternalMessage} from "./service/externalBridge";
|
||||||
import type { BackgroundCommand } from './types';
|
import type {MessageAction} from "@/shared/message";
|
||||||
|
import {
|
||||||
|
cancelCrawl,
|
||||||
|
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, _sender, sendResponse) => {
|
/**
|
||||||
void handleBackgroundMessage(message, sendResponse);
|
* 接收 popup/content 的消息
|
||||||
return true;
|
*/
|
||||||
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
|
const action = message.action as MessageAction;
|
||||||
|
const payload = message.payload;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
let resultData: any = null;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
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) => {
|
chrome.windows.onRemoved.addListener((windowId) => {
|
||||||
void handleWindowRemoved(windowId);
|
void pauseCrawlOnWindowRemoved(windowId);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 统一包装后台消息处理,确保异步错误能回给调用方。 */
|
chrome.tabs.onRemoved.addListener((tabId) => {
|
||||||
async function handleBackgroundMessage(
|
void pauseCrawlOnTabRemoved(tabId);
|
||||||
message: BackgroundCommand,
|
});
|
||||||
sendResponse: (response?: unknown) => void,
|
|
||||||
) {
|
/**
|
||||||
try {
|
* 接收外部网页消息(onMessageExternal)
|
||||||
const result = await handleBackgroundCommand(message);
|
*/
|
||||||
sendResponse(result);
|
chrome.runtime.onMessageExternal.addListener((message, _sender, sendResponse) => {
|
||||||
} catch (error: unknown) {
|
void handleExternalMessage(message)
|
||||||
const messageText = error instanceof Error ? error.message : 'Unknown error';
|
.then(sendResponse)
|
||||||
sendResponse({ ok: false, error: messageText });
|
.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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,410 +0,0 @@
|
|||||||
import { getPlatformById } from '@/config/platforms';
|
|
||||||
import type { CrawlProgressStep, CrawlTaskState, PlatformConfig, PlatformStepConfig } from '@/types';
|
|
||||||
import { scrapeDomFields, type DomScrapeResult } from './domScraper';
|
|
||||||
import type { BackgroundCommand, BackgroundResponse, CrawlStateResponse } from './types';
|
|
||||||
|
|
||||||
/** chrome.storage.local 中保存当前爬取任务状态的键名。 */
|
|
||||||
const CRAWL_TASK_STORAGE_KEY = 'crawlTaskState';
|
|
||||||
|
|
||||||
/** 扩展安装完成时的初始化入口,当前仅保留日志方便调试生命周期。 */
|
|
||||||
export async function handleInstalled(): Promise<void> {
|
|
||||||
console.log('[background] installed');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 浏览器启动并加载扩展时的初始化入口,当前仅保留日志方便调试生命周期。 */
|
|
||||||
export async function handleStartup(): Promise<void> {
|
|
||||||
console.log('[background] startup');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 监听窗口关闭事件;如果关闭的是爬取窗口,就把当前任务标记为取消。 */
|
|
||||||
export async function handleWindowRemoved(windowId: number): Promise<void> {
|
|
||||||
console.log('[background] window removed', windowId);
|
|
||||||
|
|
||||||
/** 当前保存的爬取任务状态。 */
|
|
||||||
const state = await getCrawlTaskState();
|
|
||||||
|
|
||||||
if (state?.windowId === windowId && state.status === 'running') {
|
|
||||||
await setCrawlTaskState({
|
|
||||||
...state,
|
|
||||||
status: 'canceled',
|
|
||||||
steps: state.steps.map((step, index) =>
|
|
||||||
index === state.currentStepIndex ? { ...step, status: 'failed', message: '爬取窗口已关闭' } : step,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 根据 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':
|
|
||||||
return { ok: true, data: await getCrawlTaskState() };
|
|
||||||
case 'CANCEL_CRAWL':
|
|
||||||
return cancelCrawl();
|
|
||||||
default:
|
|
||||||
return { ok: false, error: '未知的后台指令' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 创建新的爬取任务,打开目标平台窗口,并把初始时间轴状态写入 storage。 */
|
|
||||||
async function startCrawl(platformId: string): Promise<CrawlStateResponse> {
|
|
||||||
/** 根据平台 ID 找到对应的平台爬取配置。 */
|
|
||||||
const platform = getPlatformById(platformId);
|
|
||||||
|
|
||||||
if (!platform) {
|
|
||||||
return { ok: false, error: '平台配置不存在' };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 当前任务的开始时间戳,用于计算正计时。 */
|
|
||||||
const startedAt = Date.now();
|
|
||||||
/** 窗口创建前的初始任务状态,先写入 storage 让所有页面能立即感知爬取开始。 */
|
|
||||||
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 {
|
|
||||||
/** background 创建出来的目标平台窗口信息。 */
|
|
||||||
const windowInfo = await createCrawlWindow(platform.baseUrl);
|
|
||||||
/** 补充 windowId 后的任务状态,后续可用于取消或监听窗口关闭。 */
|
|
||||||
const stateWithWindow = { ...nextState, windowId: windowInfo.id };
|
|
||||||
await setCrawlTaskState(stateWithWindow);
|
|
||||||
void runCrawlSteps(platform, stateWithWindow);
|
|
||||||
return { ok: true, data: stateWithWindow };
|
|
||||||
} catch (error: unknown) {
|
|
||||||
/** 窗口创建失败时写入的失败状态,供 popup/content 显示错误进度。 */
|
|
||||||
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 : '打开平台窗口失败' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 按平台 steps 顺序执行页面跳转、DOM 等待、字段抓取和进度更新。 */
|
|
||||||
async function runCrawlSteps(platform: PlatformConfig, initialState: CrawlTaskState): Promise<void> {
|
|
||||||
if (!initialState.windowId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
/** 新窗口中的目标标签页 ID,后续所有跳转和脚本注入都依赖它。 */
|
|
||||||
const tabId = await getWindowActiveTabId(initialState.windowId);
|
|
||||||
|
|
||||||
for (let stepIndex = 0; stepIndex < platform.steps.length; stepIndex += 1) {
|
|
||||||
/** 当前正在执行的平台页面步骤配置。 */
|
|
||||||
const step = platform.steps[stepIndex];
|
|
||||||
|
|
||||||
if (!(await isTaskRunning(initialState.id))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await markStepRunning(initialState.id, stepIndex);
|
|
||||||
await openStepPage(tabId, step.url);
|
|
||||||
|
|
||||||
/** 当前页面核心 DOM 是否已经出现。 */
|
|
||||||
const isReady = await waitForStepReady(tabId, step);
|
|
||||||
|
|
||||||
if (!isReady) {
|
|
||||||
await markStepFailed(initialState.id, stepIndex, '页面关键 DOM 未加载完成');
|
|
||||||
await markTaskFailed(initialState.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 注入页面执行后的字段抓取结果。 */
|
|
||||||
const data = await scrapeStepFields(tabId, step);
|
|
||||||
console.log(`[crawl] ${platform.name} - ${step.name} 提取成功`, data);
|
|
||||||
await markStepSuccess(initialState.id, stepIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
await markTaskCompleted(initialState.id);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error('[crawl] 执行失败', error);
|
|
||||||
await markTaskFailed(initialState.id, error instanceof Error ? error.message : '爬取执行失败');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取指定窗口中的活动 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 打开某个 steps 页面,并等待浏览器报告 tab 加载完成。 */
|
|
||||||
async function openStepPage(tabId: number, url: string): Promise<void> {
|
|
||||||
await chrome.tabs.update(tabId, { url, active: true });
|
|
||||||
await waitForTabLoaded(tabId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 等待 tab 完成页面加载。 */
|
|
||||||
function waitForTabLoaded(tabId: number): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
/** 页面加载兜底定时器,避免某些站点不触发 complete 时流程永久挂起。 */
|
|
||||||
const timeout = globalThis.setTimeout(() => {
|
|
||||||
chrome.tabs.onUpdated.removeListener(handleUpdated);
|
|
||||||
resolve();
|
|
||||||
}, 15000);
|
|
||||||
|
|
||||||
/** chrome.tabs.onUpdated 的监听器,用于捕获指定 tab 的 complete 状态。 */
|
|
||||||
function handleUpdated(updatedTabId: number, changeInfo: { status?: string }) {
|
|
||||||
if (updatedTabId === tabId && changeInfo.status === 'complete') {
|
|
||||||
globalThis.clearTimeout(timeout);
|
|
||||||
chrome.tabs.onUpdated.removeListener(handleUpdated);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
chrome.tabs.onUpdated.addListener(handleUpdated);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 等待步骤配置中的 checkSelector 出现;第一次超时后刷新页面再重试一次。 */
|
|
||||||
async function waitForStepReady(tabId: number, step: PlatformStepConfig): Promise<boolean> {
|
|
||||||
if (await waitForSelector(tabId, step.checkSelector, 5000)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
await chrome.tabs.reload(tabId);
|
|
||||||
await waitForTabLoaded(tabId);
|
|
||||||
return waitForSelector(tabId, step.checkSelector, 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 在目标页面轮询检查指定 selector 是否存在。 */
|
|
||||||
async function waitForSelector(tabId: number, selector: string, timeoutMs: number): Promise<boolean> {
|
|
||||||
/** 轮询开始时间,用于控制最大等待时长。 */
|
|
||||||
const startedAt = Date.now();
|
|
||||||
|
|
||||||
while (Date.now() - startedAt < timeoutMs) {
|
|
||||||
/** 当前页面是否已经能查询到目标元素。 */
|
|
||||||
const exists = await checkSelectorExists(tabId, selector);
|
|
||||||
|
|
||||||
if (exists) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
await sleep(500);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 注入轻量脚本检查页面里是否存在指定 selector。 */
|
|
||||||
async function checkSelectorExists(tabId: number, selector: string): Promise<boolean> {
|
|
||||||
/** chrome.scripting.executeScript 返回的注入执行结果。 */
|
|
||||||
const results = await chrome.scripting.executeScript({
|
|
||||||
target: { tabId },
|
|
||||||
func: (targetSelector: string) => Boolean(document.querySelector(targetSelector)),
|
|
||||||
args: [selector],
|
|
||||||
});
|
|
||||||
|
|
||||||
return Boolean(results[0]?.result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 注入 domScraper 到目标页面,并根据当前 step.fields 提取页面数据。 */
|
|
||||||
async function scrapeStepFields(tabId: number, step: PlatformStepConfig): Promise<DomScrapeResult | null> {
|
|
||||||
/** 目标页面执行 DOM 抓取后返回的结果数组。 */
|
|
||||||
const results = await chrome.scripting.executeScript({
|
|
||||||
target: { tabId },
|
|
||||||
func: scrapeDomFields,
|
|
||||||
args: [step.fields],
|
|
||||||
});
|
|
||||||
|
|
||||||
return results[0]?.result ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 判断指定任务是否仍处于 running 状态。 */
|
|
||||||
async function isTaskRunning(taskId: string): Promise<boolean> {
|
|
||||||
/** 当前 storage 中的任务状态。 */
|
|
||||||
const state = await getCrawlTaskState();
|
|
||||||
return state?.id === taskId && state.status === 'running';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 将指定步骤标记为运行中,同时把其它未完成步骤保持为等待。 */
|
|
||||||
async function markStepRunning(taskId: string, stepIndex: number): Promise<void> {
|
|
||||||
await updateCrawlTaskState(taskId, (state) => ({
|
|
||||||
...state,
|
|
||||||
currentStepIndex: stepIndex,
|
|
||||||
status: 'running',
|
|
||||||
steps: state.steps.map((step, index) => ({
|
|
||||||
...step,
|
|
||||||
status: index === stepIndex ? 'running' : step.status,
|
|
||||||
message: index === stepIndex ? undefined : step.message,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 将指定步骤标记为成功。 */
|
|
||||||
async function markStepSuccess(taskId: string, stepIndex: number): Promise<void> {
|
|
||||||
await updateCrawlTaskState(taskId, (state) => ({
|
|
||||||
...state,
|
|
||||||
steps: state.steps.map((step, index) =>
|
|
||||||
index === stepIndex ? { ...step, status: 'success', message: undefined } : step,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 将指定步骤标记为失败,并记录失败原因。 */
|
|
||||||
async function markStepFailed(taskId: string, stepIndex: number, message: string): Promise<void> {
|
|
||||||
await updateCrawlTaskState(taskId, (state) => ({
|
|
||||||
...state,
|
|
||||||
currentStepIndex: stepIndex,
|
|
||||||
steps: state.steps.map((step, index) =>
|
|
||||||
index === stepIndex ? { ...step, status: 'failed', message } : step,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 将整个任务标记为完成。 */
|
|
||||||
async function markTaskCompleted(taskId: string): Promise<void> {
|
|
||||||
await updateCrawlTaskState(taskId, (state) => ({
|
|
||||||
...state,
|
|
||||||
status: 'completed',
|
|
||||||
steps: state.steps.map((step) => (step.status === 'running' ? { ...step, status: 'success' } : step)),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 将整个任务标记为失败。 */
|
|
||||||
async function markTaskFailed(taskId: string, message = '爬取失败'): Promise<void> {
|
|
||||||
await updateCrawlTaskState(taskId, (state) => ({
|
|
||||||
...state,
|
|
||||||
status: 'failed',
|
|
||||||
steps: state.steps.map((step, index) =>
|
|
||||||
index === state.currentStepIndex && step.status === 'running' ? { ...step, status: 'failed', message } : step,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 读取任务状态后执行不可变更新,避免覆盖已取消或已替换的任务。 */
|
|
||||||
async function updateCrawlTaskState(
|
|
||||||
taskId: string,
|
|
||||||
updater: (state: CrawlTaskState) => CrawlTaskState,
|
|
||||||
): Promise<void> {
|
|
||||||
/** 当前 storage 中最新的任务状态。 */
|
|
||||||
const state = await getCrawlTaskState();
|
|
||||||
|
|
||||||
if (!state || state.id !== taskId || state.status === 'canceled') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await setCrawlTaskState(updater(state));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 睡眠工具,用于轮询 DOM 等待。 */
|
|
||||||
function sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
globalThis.setTimeout(resolve, ms);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 取消当前爬取任务,并尝试关闭正在爬取的平台窗口。 */
|
|
||||||
async function cancelCrawl(): Promise<CrawlStateResponse> {
|
|
||||||
/** 当前保存的爬取任务状态。 */
|
|
||||||
const state = await getCrawlTaskState();
|
|
||||||
|
|
||||||
if (!state) {
|
|
||||||
return { ok: true, data: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 用户取消后的任务状态,当前执行步骤会显示为失败并附带取消原因。 */
|
|
||||||
const canceledState: CrawlTaskState = {
|
|
||||||
...state,
|
|
||||||
status: 'canceled',
|
|
||||||
steps: state.steps.map((step, index) =>
|
|
||||||
index === state.currentStepIndex ? { ...step, status: 'failed', message: '用户已取消' } : step,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
await setCrawlTaskState(canceledState);
|
|
||||||
|
|
||||||
if (state.windowId) {
|
|
||||||
await removeWindow(state.windowId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ok: true, data: canceledState };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 从 chrome.storage.local 读取当前爬取任务状态。 */
|
|
||||||
async function getCrawlTaskState(): Promise<CrawlTaskState | null> {
|
|
||||||
/** chrome.storage.local 返回的原始键值对象。 */
|
|
||||||
const result = await chrome.storage.local.get(CRAWL_TASK_STORAGE_KEY);
|
|
||||||
/** 取出的任务状态候选值,需要经过结构校验后才能使用。 */
|
|
||||||
const state = result[CRAWL_TASK_STORAGE_KEY];
|
|
||||||
return isCrawlTaskState(state) ? state : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 将最新爬取任务状态写入 chrome.storage.local,供 popup 和 content script 同步读取。 */
|
|
||||||
async function setCrawlTaskState(state: CrawlTaskState): Promise<void> {
|
|
||||||
await chrome.storage.local.set({ [CRAWL_TASK_STORAGE_KEY]: state });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 打开一个普通浏览器窗口承载目标平台页面。 */
|
|
||||||
function createCrawlWindow(url: string): Promise<chrome.windows.Window> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
chrome.windows.create(
|
|
||||||
{
|
|
||||||
url,
|
|
||||||
type: 'normal',
|
|
||||||
focused: true,
|
|
||||||
width: 1280,
|
|
||||||
height: 900,
|
|
||||||
},
|
|
||||||
(windowInfo) => {
|
|
||||||
/** Chrome 扩展 API 回调中的运行时错误。 */
|
|
||||||
const runtimeError = chrome.runtime.lastError;
|
|
||||||
|
|
||||||
if (runtimeError) {
|
|
||||||
reject(new Error(runtimeError.message));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!windowInfo?.id) {
|
|
||||||
reject(new Error('窗口创建失败'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(windowInfo);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 根据窗口 ID 关闭爬取窗口;关闭失败时不阻塞取消状态写入。 */
|
|
||||||
function removeWindow(windowId: number): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
chrome.windows.remove(windowId, () => {
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 粗略判断 storage 中读取到的值是否像一个爬取任务状态对象。 */
|
|
||||||
function isCrawlTaskState(value: unknown): value is CrawlTaskState {
|
|
||||||
return typeof value === 'object' && value !== null && 'id' in value && 'steps' in value;
|
|
||||||
}
|
|
||||||
231
src/background/service/autoScanScheduler.ts
Normal file
231
src/background/service/autoScanScheduler.ts
Normal 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);
|
||||||
|
}
|
||||||
288
src/background/service/externalBridge.ts
Normal file
288
src/background/service/externalBridge.ts
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
import {platformConfigs} from '@/config/platforms';
|
||||||
|
import type {CrawlTaskState} from '@/types';
|
||||||
|
import {getCrawlTaskState} from "@/background/task/taskState";
|
||||||
|
import {cancelCrawl, startCrawl} from "@/background/task/crawlTask";
|
||||||
|
import {setAuthState} from "@/shared/store";
|
||||||
|
|
||||||
|
/** 存储任务状态的 Key,需与存储层保持一致 */
|
||||||
|
const CRAWL_TASK_STORAGE_KEY = 'crawlTaskState';
|
||||||
|
/** 外部通信的长连接端口名称 */
|
||||||
|
const EXTERNAL_PORT_NAME = 'DIANSHAN_CRAWL';
|
||||||
|
|
||||||
|
/** 定义外部(网页侧)可以发起的动作类型 */
|
||||||
|
type ExternalAction =
|
||||||
|
| 'DIANSHAN_PING' // 探测插件是否安装/活跃
|
||||||
|
| 'DIANSHAN_AUTH_CHECK' // 网页侧检查扩展是否已配对
|
||||||
|
| 'DIANSHAN_SSO_HANDOFF' // 网页侧回传 extension token(配对结果)
|
||||||
|
| 'DIANSHAN_START_CRAWL' // 从网页发起爬取
|
||||||
|
| 'DIANSHAN_GET_CRAWL_STATE' // 获取当前进度
|
||||||
|
| 'DIANSHAN_CANCEL_CRAWL' // 取消爬取
|
||||||
|
| 'STORE_AI_PING'; // 兼容性探测
|
||||||
|
|
||||||
|
/** 外部消息结构 */
|
||||||
|
interface ExternalMessage {
|
||||||
|
type?: ExternalAction;
|
||||||
|
action?: ExternalAction;
|
||||||
|
payload?: {
|
||||||
|
platformId?: string;
|
||||||
|
// 中文备注:网页侧配对后回传的登录信息(全部用 any,按要求不定义复杂类型)
|
||||||
|
authState?: any;
|
||||||
|
// 中文备注:兼容网页侧老参数(例如 ingestContext),扩展侧目前不使用,但保留以免网页传多字段报错。
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回给网页的统一响应格式 */
|
||||||
|
interface ExternalResponse<T = unknown> {
|
||||||
|
ok: boolean;
|
||||||
|
success?: boolean;
|
||||||
|
type?: string;
|
||||||
|
data?: T;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 网页侧接收到的复合载荷:包含任务状态和(完成后的)抓取结果 */
|
||||||
|
interface CrawlWebPayload {
|
||||||
|
state: CrawlTaskState | null;
|
||||||
|
result: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 维护当前所有已连接的网页端口(用于实时广播进度) */
|
||||||
|
const externalPorts = new Set<chrome.runtime.Port>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理外部网页发送的单次指令(一问一答模式)
|
||||||
|
*/
|
||||||
|
export async function handleExternalMessage(message: ExternalMessage): Promise<ExternalResponse> {
|
||||||
|
const action = message.type ?? message.action;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
// 插件存活探测:返回版本号及支持的平台列表
|
||||||
|
case 'STORE_AI_PING':
|
||||||
|
case 'DIANSHAN_PING':
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
version: chrome.runtime.getManifest().version,
|
||||||
|
platforms: platformConfigs.map((platform) => ({
|
||||||
|
id: platform.id,
|
||||||
|
name: platform.name,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 网页发起爬取指令
|
||||||
|
case 'DIANSHAN_START_CRAWL':
|
||||||
|
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':
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
data: buildCrawlWebPayload(await getCrawlTaskState()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 网页强制取消任务
|
||||||
|
case 'DIANSHAN_CANCEL_CRAWL':
|
||||||
|
await cancelCrawl();
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
data: buildCrawlWebPayload(null),
|
||||||
|
};
|
||||||
|
|
||||||
|
default:
|
||||||
|
return {ok: false, error: 'unknown_external_action'};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理外部网页的长连接请求(用于实时推送进度)
|
||||||
|
*/
|
||||||
|
export function handleExternalConnect(port: chrome.runtime.Port): void {
|
||||||
|
// 只接受指定名称的端口连接
|
||||||
|
if (port.name !== EXTERNAL_PORT_NAME) {
|
||||||
|
port.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
externalPorts.add(port);
|
||||||
|
|
||||||
|
// 连接建立时,立即推送一次当前状态
|
||||||
|
getCrawlTaskState()
|
||||||
|
.then((state) => {
|
||||||
|
postToExternalPort(port, {
|
||||||
|
ok: true,
|
||||||
|
type: 'DIANSHAN_CRAWL_STATE',
|
||||||
|
data: buildCrawlWebPayload(state),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
postToExternalPort(port, {
|
||||||
|
ok: false,
|
||||||
|
type: 'DIANSHAN_CRAWL_ERROR',
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听网页通过长连接发送的消息
|
||||||
|
port.onMessage.addListener((message: ExternalMessage) => {
|
||||||
|
void handleExternalMessage(message)
|
||||||
|
.then((response) => {
|
||||||
|
postToExternalPort(port, response);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
postToExternalPort(port, {
|
||||||
|
ok: false,
|
||||||
|
type: 'DIANSHAN_CRAWL_ERROR',
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 端口断开(网页关闭)时,从集合中移除
|
||||||
|
port.onDisconnect.addListener(() => {
|
||||||
|
externalPorts.delete(port);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 监听 Storage 变化并广播给所有网页端口
|
||||||
|
* 这是实现网页端进度条“丝滑跳动”的核心逻辑
|
||||||
|
*/
|
||||||
|
export function broadcastCrawlStorageChange(changes: Record<string, chrome.storage.StorageChange>, areaName: string): void {
|
||||||
|
if (areaName !== 'local') return;
|
||||||
|
|
||||||
|
const change = changes[CRAWL_TASK_STORAGE_KEY];
|
||||||
|
if (!change) return;
|
||||||
|
|
||||||
|
const nextState = isCrawlTaskState(change.newValue) ? change.newValue : null;
|
||||||
|
const oldState = isCrawlTaskState(change.oldValue) ? change.oldValue : null;
|
||||||
|
|
||||||
|
// 根据状态变化确定通知类型(如:已完成、已取消、普通更新)
|
||||||
|
const type = getBroadcastType(nextState, oldState);
|
||||||
|
|
||||||
|
broadcastToExternalPorts({
|
||||||
|
ok: true,
|
||||||
|
type,
|
||||||
|
data: buildCrawlWebPayload(nextState),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 封装网页调起的爬取逻辑:做一层错误兼容处理
|
||||||
|
*/
|
||||||
|
async function startCrawlForWebsite(platformId?: string): Promise<ExternalResponse<CrawlWebPayload>> {
|
||||||
|
// 调用核心爬取逻辑
|
||||||
|
// 中文备注:网页侧触发属于手动触发(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 {
|
||||||
|
ok: !isError,
|
||||||
|
type: 'DIANSHAN_CRAWL_STARTED',
|
||||||
|
data: buildCrawlWebPayload(state),
|
||||||
|
error: isError ? String(response.error ?? 'start_failed') : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建网页端专用的数据载荷
|
||||||
|
* 如果任务已完成,则顺便把所有抓取到的结果打包带走
|
||||||
|
*/
|
||||||
|
function buildCrawlWebPayload(state: CrawlTaskState | null): CrawlWebPayload {
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
result: state?.status === 'completed' ? collectStepResults(state) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 汇总任务中所有步骤的抓取结果
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据状态机的变化,转换成对应的外部事件名称
|
||||||
|
*/
|
||||||
|
function getBroadcastType(nextState: CrawlTaskState | null, oldState: CrawlTaskState | null): string {
|
||||||
|
if (!nextState) {
|
||||||
|
return oldState ? 'DIANSHAN_CRAWL_CLEARED' : 'DIANSHAN_CRAWL_STATE';
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (nextState.status) {
|
||||||
|
case 'completed': return 'DIANSHAN_CRAWL_DONE';
|
||||||
|
case 'failed': return 'DIANSHAN_CRAWL_FAILED';
|
||||||
|
case 'canceled': return 'DIANSHAN_CRAWL_CANCELED';
|
||||||
|
default: return 'DIANSHAN_CRAWL_STATE';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 向所有已连接的网页广播消息 */
|
||||||
|
function broadcastToExternalPorts(message: ExternalResponse<CrawlWebPayload>): void {
|
||||||
|
for (const port of externalPorts) {
|
||||||
|
postToExternalPort(port, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 向单个端口发送消息,并处理连接失效的情况 */
|
||||||
|
function postToExternalPort(port: chrome.runtime.Port, message: ExternalResponse): void {
|
||||||
|
try {
|
||||||
|
port.postMessage(message);
|
||||||
|
} catch {
|
||||||
|
// 如果发送失败(通常因为网页已关闭),则强制清理
|
||||||
|
externalPorts.delete(port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 类型守卫:判断对象是否为有效的任务状态 */
|
||||||
|
function isCrawlTaskState(value: unknown): value is CrawlTaskState {
|
||||||
|
return typeof value === 'object' && value !== null && 'id' in value && 'steps' in value;
|
||||||
|
}
|
||||||
214
src/background/service/scan_payload.ts
Normal file
214
src/background/service/scan_payload.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
373
src/background/task/crawlTask.ts
Normal file
373
src/background/task/crawlTask.ts
Normal 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,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
171
src/background/task/helper.ts
Normal file
171
src/background/task/helper.ts
Normal 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});
|
||||||
|
});
|
||||||
|
}
|
||||||
54
src/background/task/taskState.ts
Normal file
54
src/background/task/taskState.ts
Normal 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));
|
||||||
|
}
|
||||||
@@ -1,40 +1,15 @@
|
|||||||
import type { CrawlTaskState } from '@/types';
|
import type { CrawlTaskState } from '@/types';
|
||||||
|
|
||||||
/** 启动爬取任务的后台消息。 */
|
|
||||||
export interface StartCrawlCommand {
|
|
||||||
/** 消息动作类型:请求 background 创建爬取窗口并初始化任务状态。 */
|
|
||||||
action: 'START_CRAWL';
|
|
||||||
/** 启动爬取所需参数。 */
|
|
||||||
payload: {
|
|
||||||
/** 当前要爬取的平台 ID,对应 config/platforms.ts 中的平台配置。 */
|
|
||||||
platformId: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取当前爬取任务状态的后台消息。 */
|
// background 统一响应结构。
|
||||||
export interface GetCrawlStateCommand {
|
|
||||||
/** 消息动作类型:请求 background 返回当前任务快照。 */
|
|
||||||
action: 'GET_CRAWL_STATE';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 取消当前爬取任务的后台消息。 */
|
|
||||||
export interface CancelCrawlCommand {
|
|
||||||
/** 消息动作类型:请求 background 标记任务取消并关闭爬取窗口。 */
|
|
||||||
action: 'CANCEL_CRAWL';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** popup/content script 能发送给 background 的全部消息类型。 */
|
|
||||||
export type BackgroundCommand = StartCrawlCommand | GetCrawlStateCommand | CancelCrawlCommand;
|
|
||||||
|
|
||||||
/** background 统一响应结构。 */
|
|
||||||
export interface BackgroundResponse<T = unknown> {
|
export interface BackgroundResponse<T = unknown> {
|
||||||
/** 当前请求是否处理成功。 */
|
// 当前请求是否处理成功。
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
/** 成功或部分失败时返回的业务数据。 */
|
// 成功或部分失败时返回的业务数据。
|
||||||
data?: T;
|
data?: T;
|
||||||
/** 请求失败时返回的错误文案。 */
|
// 请求失败时返回的错误文案。
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取或变更爬取任务后返回的响应结构。 */
|
// 获取或变更爬取任务后返回的响应结构。
|
||||||
export type CrawlStateResponse = BackgroundResponse<CrawlTaskState | null>;
|
export type CrawlStateResponse = BackgroundResponse<CrawlTaskState | null>;
|
||||||
|
|||||||
12
src/config.ts
Normal file
12
src/config.ts
Normal 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;
|
||||||
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
|
import type {PlatformConfig} from '@/types';
|
||||||
|
|
||||||
import type { PlatformConfig } from '@/types';
|
export const platformConfigs: PlatformConfig[] = [
|
||||||
|
|
||||||
export const PLATFORM_CONFIGS: PlatformConfig[] = [
|
|
||||||
{
|
{
|
||||||
id: 'Shopee',
|
id: 'Shopee',
|
||||||
name: 'Shopee 后台',
|
name: 'Shopee 后台',
|
||||||
@@ -10,7 +9,7 @@ export const PLATFORM_CONFIGS: PlatformConfig[] = [
|
|||||||
name: '数据看板',
|
name: '数据看板',
|
||||||
uniqueKey: 'databoard',
|
uniqueKey: 'databoard',
|
||||||
url: 'https://seller.shopee.com.my/',
|
url: 'https://seller.shopee.com.my/',
|
||||||
checkSelector: '.rate-manager-content',
|
checkSelector: '.page-container',
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
label: "出货统计",
|
label: "出货统计",
|
||||||
@@ -36,46 +35,46 @@ export const PLATFORM_CONFIGS: PlatformConfig[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "商业分析",
|
label: "商业分析",
|
||||||
className: ".data-dashboard-async-data-wrapper .custom-row",
|
className: ".data-dashboard .metrics",
|
||||||
keys: [
|
keys: [
|
||||||
{
|
{
|
||||||
label: "销售",
|
label: "销售",
|
||||||
className: ".custom-col-5:nth-child(1) ",
|
className: ".metric:nth-child(1) ",
|
||||||
keys: [
|
keys: [
|
||||||
{ label: "value", className: ".dashboard-item-value" },
|
{label: "value", className: ".metric-value"},
|
||||||
{ label: "change", className: ".dashboard-item-rate-number" }
|
{label: "change", className: ".metric-rate"}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "访客数",
|
label: "访客数",
|
||||||
className: ".custom-col-5:nth-child(2) ",
|
className: ".metric:nth-child(2) ",
|
||||||
keys: [
|
keys: [
|
||||||
{ label: "value", className: ".dashboard-item-value" },
|
{label: "value", className: ".metric-value"},
|
||||||
{ label: "change", className: ".dashboard-item-rate-number" }
|
{label: "change", className: ".metric-rate"}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Product Clicks",
|
label: "Product Clicks",
|
||||||
className: ".custom-col-5:nth-child(3)",
|
className: ".metric:nth-child(3)",
|
||||||
keys: [
|
keys: [
|
||||||
{ label: "value", className: ".dashboard-item-value" },
|
{label: "value", className: ".metric-value"},
|
||||||
{ label: "change", className: ".dashboard-item-rate-number" }
|
{label: "change", className: ".metric-rate"}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "订单",
|
label: "订单",
|
||||||
className: ".custom-col-5:nth-child(4)",
|
className: ".metric:nth-child(4)",
|
||||||
keys: [
|
keys: [
|
||||||
{ label: "value", className: ".dashboard-item-value" },
|
{label: "value", className: ".metric-value"},
|
||||||
{ label: "change", className: ".dashboard-item-rate-number" }
|
{label: "change", className: ".metric-rate"}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Order Conversion Rate",
|
label: "Order Conversion Rate",
|
||||||
className: ".custom-col-5:nth-child(5)",
|
className: ".metric:nth-child(5)",
|
||||||
keys: [
|
keys: [
|
||||||
{ label: "value", className: ".dashboard-item-value" },
|
{label: "value", className: ".metric-value"},
|
||||||
{ label: "change", className: ".dashboard-item-rate-number" }
|
{label: "change", className: ".metric-rate"}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -88,42 +87,224 @@ export const PLATFORM_CONFIGS: PlatformConfig[] = [
|
|||||||
label: "广告余额",
|
label: "广告余额",
|
||||||
className: ".ads-data-cell:nth-of-type(1) ",
|
className: ".ads-data-cell:nth-of-type(1) ",
|
||||||
keys: [
|
keys: [
|
||||||
{ label: "value", className: ".ads-data-report-number" },
|
{label: "value", className: ".ads-data-report-number"},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "销售额",
|
label: "销售额",
|
||||||
className: ".ads-data-cell:nth-child(3) ",
|
className: ".ads-data-cell:nth-child(3) ",
|
||||||
keys: [
|
keys: [
|
||||||
{ label: "value", className: ".ads-data-report-number" },
|
{label: "value", className: ".ads-data-report-number"},
|
||||||
{ label: "change", className: ".ratio " }
|
{label: "change", className: ".ratio "}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "花费",
|
label: "花费",
|
||||||
className: ".ads-data-cell:nth-child(4)",
|
className: ".ads-data-cell:nth-child(4)",
|
||||||
keys: [
|
keys: [
|
||||||
{ label: "value", className: ".ads-data-report-number" },
|
{label: "value", className: ".ads-data-report-number"},
|
||||||
{ label: "change", className: ".ratio " }
|
{label: "change", className: ".ratio "}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "广告支出回报率",
|
label: "广告支出回报率",
|
||||||
className: ".ads-data-cell:nth-child(5)",
|
className: ".ads-data-cell:nth-child(5)",
|
||||||
keys: [
|
keys: [
|
||||||
{ label: "value", className: ".ads-data-report-number" },
|
{label: "value", className: ".ads-data-report-number"},
|
||||||
{ label: "change", className: ".ratio " }
|
{label: "change", className: ".ratio "}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "广告中心",
|
||||||
|
uniqueKey: "adscenter",
|
||||||
|
url: "https://seller.shopee.com.my/portal/marketing/pas/index",
|
||||||
|
checkSelector: '.page-container',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: "我的账户",
|
||||||
|
className: ".my-account-wrap",
|
||||||
|
keys: [
|
||||||
|
{
|
||||||
|
label: "广告余额",
|
||||||
|
className: ".credit-expense-label-wrapper:nth-child(1) .ellipsis-content"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "今日广告花费",
|
||||||
|
className: ".credit-expense-label-wrapper:nth-child(2) .ellipsis-content"
|
||||||
|
},
|
||||||
|
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "进行中广告列表",
|
||||||
|
className: ".eds-table__body-container",
|
||||||
|
type: 2,
|
||||||
|
condition: {
|
||||||
|
list: [".eds-radio-group label:nth-child(3)"],
|
||||||
|
time: 400
|
||||||
|
},
|
||||||
|
tableParts: [
|
||||||
|
{name: "fixed", select: ".eds-table__fix-body"},
|
||||||
|
{name: "main", select: ".eds-table__main-body"}
|
||||||
|
],
|
||||||
|
keys: [
|
||||||
|
{
|
||||||
|
label: "广告信息",
|
||||||
|
className: ".info-containter",
|
||||||
|
part: "fixed",
|
||||||
|
keys: [
|
||||||
|
{
|
||||||
|
label: "广告名称",
|
||||||
|
className: ".campaign-name-container"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "广告类型",
|
||||||
|
className: ".gmv-max-noti"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "结束时间",
|
||||||
|
className: ".time-edit-wrapper"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "每日预算",
|
||||||
|
part: "main",
|
||||||
|
className: "td:nth-child(1)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "目标ROAS",
|
||||||
|
part: "main",
|
||||||
|
className: "td:nth-child(2)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "花费",
|
||||||
|
part: "main",
|
||||||
|
className: "td:nth-child(4)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "销售额",
|
||||||
|
part: "main",
|
||||||
|
className: "td:nth-child(5)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "广告支出回报率",
|
||||||
|
part: "main",
|
||||||
|
className: "td:nth-child(6)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
pagination: {
|
||||||
|
nextBtn: ".eds-pager__button-next", // 下一页按钮
|
||||||
|
disabledClass: ".eds-button--disabled", // 按钮禁用时的class(用来判断结束)
|
||||||
|
maxPage: 1, // 最大爬取页数
|
||||||
|
delay: 2000 // 翻页后的等待加载时间
|
||||||
|
},
|
||||||
|
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "评论管理",
|
||||||
|
uniqueKey: "message",
|
||||||
|
url: "https://seller.shopee.com.my/portal/settings/shop/rating?pageNumber=1&fromPageNumber=1&cursor=0&pageSize=20&replied=TO_REPLY&ratingStar=2&ratingStar=1",
|
||||||
|
checkSelector: '.page-container',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: "低星评论",
|
||||||
|
className: ".border-solid.rounded",
|
||||||
|
type: 1,
|
||||||
|
keys: [
|
||||||
|
{
|
||||||
|
label: "用户",
|
||||||
|
className: ".flex.items-center.justify-start .ml-2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "订单编号",
|
||||||
|
className: ".underline.px-1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "商品名称",
|
||||||
|
className: ".min-w-0.font-medium.break-all"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "规格",
|
||||||
|
className: ".min-w-0.font-medium.break-all + div"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "评价内容",
|
||||||
|
className: ".min-w-0.overflow-hidden",
|
||||||
|
condition: {
|
||||||
|
list: [
|
||||||
|
"span.cursor-pointer"
|
||||||
|
],
|
||||||
|
time: 200,
|
||||||
|
},
|
||||||
|
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pagination: {
|
||||||
|
nextBtn: ".eds-react-pagination-pager__button-next",
|
||||||
|
maxPage: 2, // 最大爬取页数
|
||||||
|
delay: 2000 // 翻页后的等待加载时间
|
||||||
|
},
|
||||||
|
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "账户健康状态2",
|
||||||
|
uniqueKey: "accounthealth",
|
||||||
|
url: "https://seller.shopee.com.my/portal/accounthealth/home",
|
||||||
|
checkSelector: '.page-container',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: "健康状态",
|
||||||
|
className: ".metric-content",
|
||||||
|
type: 1,
|
||||||
|
keys: [
|
||||||
|
{
|
||||||
|
label: "模块名",
|
||||||
|
className: ".metric-type"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "值",
|
||||||
|
className: ".metric-item",
|
||||||
|
type: 1,
|
||||||
|
keys: [
|
||||||
|
{
|
||||||
|
label: "指标",
|
||||||
|
className: "p.metric-text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "值",
|
||||||
|
className: ".metric-my"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "目标",
|
||||||
|
className: ".metric-target"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "使用类型",
|
||||||
|
className: ".metric-applied-to"
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
/** 根据平台 ID 返回对应的平台抓取配置。 */
|
/**
|
||||||
|
* 根据平台 ID 返回对应的平台抓取配置。
|
||||||
|
*/
|
||||||
export function getPlatformById(platformId: string) {
|
export function getPlatformById(platformId: string) {
|
||||||
return PLATFORM_CONFIGS.find((item) => item.id === platformId) ?? null;
|
return platformConfigs.find((item) => item.id === platformId) ?? null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed, onMounted, onUnmounted, ref} from 'vue';
|
import {computed, onMounted, onUnmounted, ref} from 'vue';
|
||||||
import type {CrawlTaskState} from '@/types';
|
import type {CrawlTaskState} from '@/types';
|
||||||
|
|
||||||
/** 当前后台保存的爬取任务快照,用于决定是否展示右下角浮窗。 */
|
// 当前后台保存的爬取任务快照,用于决定是否展示右下角浮窗。
|
||||||
const crawlState = ref<CrawlTaskState | null>(null);
|
const crawlState = ref<CrawlTaskState | null>(null);
|
||||||
/** 当前爬取任务已经运行的秒数,页面上会格式化为 mm:ss。 */
|
// 当前爬取任务已经运行的秒数,页面上会格式化为 mm:ss。
|
||||||
const elapsedSeconds = ref(0);
|
const elapsedSeconds = ref(0);
|
||||||
/** 控制右下角时间轴面板是否展开。 */
|
// 控制右下角时间轴面板是否展开。
|
||||||
const isPanelOpen = ref(false);
|
const isPanelOpen = ref(false);
|
||||||
/** 轮询后台爬取状态和刷新计时器的定时器 ID。 */
|
// 轮询后台爬取状态和刷新计时器的定时器 ID。
|
||||||
let timer: number | undefined;
|
let timer: number | undefined;
|
||||||
|
|
||||||
/** 只有任务处于运行中时,才在网页右下角展示计时按钮。 */
|
// 只有任务处于运行中时,才在网页右下角展示计时按钮。
|
||||||
const isVisible = computed(() => crawlState.value?.status === 'running');
|
const isVisible = computed(() => crawlState.value ? ['running', 'paused'].includes(crawlState.value.status) : false);
|
||||||
|
|
||||||
/** 内容脚本挂载后立即同步一次状态,并开始每秒刷新计时和任务进度。 */
|
// 内容脚本挂载后立即同步一次状态,并开始每秒刷新计时和任务进度。
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void refreshCrawlState();
|
void refreshCrawlState();
|
||||||
timer = window.setInterval(() => {
|
timer = window.setInterval(() => {
|
||||||
@@ -23,16 +23,18 @@ onMounted(() => {
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 内容脚本卸载时清理定时器,避免页面残留轮询。 */
|
// 内容脚本卸载时清理定时器,避免页面残留轮询。
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (timer) {
|
if (timer) {
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 从 background 获取最新爬取任务状态,并在任务结束时自动收起面板。 */
|
/**
|
||||||
|
* 从 background 获取最新爬取任务状态,并在任务结束时自动收起面板。
|
||||||
|
*/
|
||||||
async function refreshCrawlState() {
|
async function refreshCrawlState() {
|
||||||
/** background 返回的当前爬取任务状态响应。 */
|
// background 返回的当前爬取任务状态响应。
|
||||||
const response = await sendBackgroundMessage<CrawlTaskState | null>({action: 'GET_CRAWL_STATE'});
|
const response = await sendBackgroundMessage<CrawlTaskState | null>({action: 'GET_CRAWL_STATE'});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -45,7 +47,9 @@ async function refreshCrawlState() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 根据任务开始时间实时计算已经运行的秒数。 */
|
/**
|
||||||
|
* 根据任务开始时间实时计算已经运行的秒数。
|
||||||
|
*/
|
||||||
function updateElapsedSeconds() {
|
function updateElapsedSeconds() {
|
||||||
if (!crawlState.value) {
|
if (!crawlState.value) {
|
||||||
elapsedSeconds.value = 0;
|
elapsedSeconds.value = 0;
|
||||||
@@ -55,18 +59,22 @@ function updateElapsedSeconds() {
|
|||||||
elapsedSeconds.value = Math.max(0, Math.floor((Date.now() - crawlState.value.startedAt) / 1000));
|
elapsedSeconds.value = Math.max(0, Math.floor((Date.now() - crawlState.value.startedAt) / 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 将秒数格式化为 mm:ss,展示在圆形计时按钮和面板标题里。 */
|
/**
|
||||||
|
* 将秒数格式化为 mm:ss,展示在圆形计时按钮和面板标题里。
|
||||||
|
*/
|
||||||
function formatElapsed(totalSeconds: number): string {
|
function formatElapsed(totalSeconds: number): string {
|
||||||
/** 运行时长中的分钟部分。 */
|
// 运行时长中的分钟部分。
|
||||||
const minutes = Math.floor(totalSeconds / 60).toString().padStart(2, '0');
|
const minutes = Math.floor(totalSeconds / 60).toString().padStart(2, '0');
|
||||||
/** 运行时长中的秒数部分。 */
|
// 运行时长中的秒数部分。
|
||||||
const seconds = (totalSeconds % 60).toString().padStart(2, '0');
|
const seconds = (totalSeconds % 60).toString().padStart(2, '0');
|
||||||
return `${minutes}:${seconds}`;
|
return `${minutes}:${seconds}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 将步骤状态枚举转换成中文展示文案。 */
|
/**
|
||||||
|
* 将步骤状态枚举转换成中文展示文案。
|
||||||
|
*/
|
||||||
function getStepText(status: string): string {
|
function getStepText(status: string): string {
|
||||||
/** 步骤状态到展示文案的映射表。 */
|
// 步骤状态到展示文案的映射表。
|
||||||
const textMap: Record<string, string> = {
|
const textMap: Record<string, string> = {
|
||||||
pending: '等待中',
|
pending: '等待中',
|
||||||
running: '爬取中',
|
running: '爬取中',
|
||||||
@@ -77,7 +85,17 @@ function getStepText(status: string): string {
|
|||||||
return textMap[status] ?? status;
|
return textMap[status] ?? status;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 发送消息到 background;非扩展环境下返回空成功响应,方便本地页面不报错。 */
|
/**
|
||||||
|
* 请求 background 继续暂停中的爬取任务。
|
||||||
|
*/
|
||||||
|
async function handleResumeCrawl() {
|
||||||
|
await sendBackgroundMessage({ action: 'RESUME_CRAWL' });
|
||||||
|
await refreshCrawlState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送消息到 background;非扩展环境下返回空成功响应,方便本地页面不报错。
|
||||||
|
*/
|
||||||
function sendBackgroundMessage<T>(message: unknown): Promise<{ ok: boolean; data?: T; error?: string }> {
|
function sendBackgroundMessage<T>(message: unknown): Promise<{ ok: boolean; data?: T; error?: string }> {
|
||||||
if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) {
|
if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) {
|
||||||
return Promise.resolve({ok: true, data: null as T});
|
return Promise.resolve({ok: true, data: null as T});
|
||||||
@@ -104,15 +122,20 @@ function sendBackgroundMessage<T>(message: unknown): Promise<{ ok: boolean; data
|
|||||||
<ol class="dianshan-crawl-timeline">
|
<ol class="dianshan-crawl-timeline">
|
||||||
<li v-for="(step, index) in crawlState.steps" :key="step.uniqueKey" :class="`is-${step.status}`">
|
<li v-for="(step, index) in crawlState.steps" :key="step.uniqueKey" :class="`is-${step.status}`">
|
||||||
<span class="dianshan-crawl-dot"></span>
|
<span class="dianshan-crawl-dot"></span>
|
||||||
<div class="dianshan-crawl-step">
|
<div class="dianshan-crawl-step">
|
||||||
<strong>{{ index + 1 }}. {{ step.name }}</strong>
|
<strong>{{ index + 1 }}. {{ step.name }}</strong>
|
||||||
<em>{{ getStepText(step.status) }}</em>
|
<em>{{ getStepText(step.status) }}</em>
|
||||||
<small v-if="step.message">{{ step.message }}</small>
|
<small v-if="step.message">{{ step.message }}</small>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</section>
|
|
||||||
</div>
|
<div v-if="crawlState.status === 'paused' && crawlState.pause" class="dianshan-crawl-pause">
|
||||||
|
<p>{{ crawlState.pause.message }}</p>
|
||||||
|
<button type="button" @click="handleResumeCrawl">我已处理,继续</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -213,6 +236,35 @@ function sendBackgroundMessage<T>(message: unknown): Promise<{ ok: boolean; data
|
|||||||
color: #b91c1c;
|
color: #b91c1c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dianshan-crawl-pause {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #f59e0b;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fffbeb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dianshan-crawl-pause p {
|
||||||
|
margin: 0;
|
||||||
|
color: #92400e;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dianshan-crawl-pause button {
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: #ffffff;
|
||||||
|
background: #059669;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.is-running .dianshan-crawl-dot,
|
.is-running .dianshan-crawl-dot,
|
||||||
.is-success .dianshan-crawl-dot {
|
.is-success .dianshan-crawl-dot {
|
||||||
background: #10b981;
|
background: #10b981;
|
||||||
|
|||||||
615
src/content/crawlOverlay.ts
Normal file
615
src/content/crawlOverlay.ts
Normal file
@@ -0,0 +1,615 @@
|
|||||||
|
import type { CrawlTaskState, CrawlStepStatus, CrawlTaskStatus } from '@/types';
|
||||||
|
|
||||||
|
const OVERLAY_HOST_ID = 'dianshan-crawl-overlay-host';
|
||||||
|
const MASK_HOST_ID = 'dianshan-crawl-mask-host';
|
||||||
|
|
||||||
|
type OverlayPhase = 'running' | 'paused' | 'done' | 'failed' | 'cancelled';
|
||||||
|
|
||||||
|
interface OverlayRefs {
|
||||||
|
host: HTMLDivElement;
|
||||||
|
root: ShadowRoot;
|
||||||
|
container: HTMLDivElement;
|
||||||
|
expanded: HTMLDivElement;
|
||||||
|
capsule: HTMLButtonElement;
|
||||||
|
stepsList: HTMLDivElement;
|
||||||
|
pauseBanner: HTMLDivElement;
|
||||||
|
pauseMessage: HTMLDivElement;
|
||||||
|
resumeBtn: HTMLButtonElement;
|
||||||
|
cancelBtn: HTMLButtonElement;
|
||||||
|
minimiseBtn: HTMLButtonElement;
|
||||||
|
titleEl: HTMLDivElement;
|
||||||
|
subtitleEl: HTMLDivElement;
|
||||||
|
currentDetail: HTMLDivElement;
|
||||||
|
autocloseBanner: HTMLDivElement;
|
||||||
|
autocloseText: HTMLDivElement;
|
||||||
|
stayOpenBtn: HTMLButtonElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
let refs: OverlayRefs | null = null;
|
||||||
|
let maskHost: HTMLDivElement | null = null;
|
||||||
|
let currentState: CrawlTaskState | null = null;
|
||||||
|
let clockTimer: number | null = null;
|
||||||
|
let autoCollapseTimer: number | null = null;
|
||||||
|
let hasExpandedOnceForThisTask = false;
|
||||||
|
let hasKeptOpen = false;
|
||||||
|
|
||||||
|
export function mountCrawlOverlay(): void {
|
||||||
|
if (document.getElementById(OVERLAY_HOST_ID)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!document.body) {
|
||||||
|
document.addEventListener('DOMContentLoaded', mountCrawlOverlay, { once: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
refs = buildDom();
|
||||||
|
refs.host.style.display = 'none';
|
||||||
|
maskHost = buildMaskHost();
|
||||||
|
|
||||||
|
// 中文备注:接收 background 通过 sendTabMessage(tabId, 'CRAWL_STATE_UPDATE', state) 推送的任务状态。
|
||||||
|
// 旧逻辑使用 type='crawl_state_update',这里改为适配当前的 action/payload 协议。
|
||||||
|
chrome.runtime.onMessage.addListener((raw) => {
|
||||||
|
const msg = raw as { action?: string; payload?: unknown } | undefined;
|
||||||
|
if (msg?.action === 'CRAWL_STATE_UPDATE') {
|
||||||
|
applyState(isCrawlTaskState(msg.payload) ? (msg.payload as CrawlTaskState) : null);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial state snapshot (tab-gated in background; other tabs get null).
|
||||||
|
void refreshForThisTab();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshForThisTab(): Promise<void> {
|
||||||
|
if (!chrome.runtime?.sendMessage) return;
|
||||||
|
try {
|
||||||
|
const response = await chrome.runtime.sendMessage({ action: 'GET_CRAWL_STATE_FOR_TAB' });
|
||||||
|
const next = (response && typeof response === 'object' ? (response as { data?: unknown }).data : null) ?? null;
|
||||||
|
applyState(isCrawlTaskState(next) ? (next as CrawlTaskState) : null);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCrawlTaskState(value: unknown): value is CrawlTaskState {
|
||||||
|
return typeof value === 'object' && value !== null && 'id' in value && 'steps' in value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function phaseFor(state: CrawlTaskState): OverlayPhase {
|
||||||
|
if (state.status === 'paused') return 'paused';
|
||||||
|
if (state.status === 'completed') return 'done';
|
||||||
|
if (state.status === 'failed') return 'failed';
|
||||||
|
if (state.status === 'canceled') return 'cancelled';
|
||||||
|
return 'running';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActive(status: CrawlTaskStatus): boolean {
|
||||||
|
return status === 'running' || status === 'paused' || status === 'completed' || status === 'failed' || status === 'canceled';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTerminal(status: CrawlTaskStatus): boolean {
|
||||||
|
return status === 'completed' || status === 'failed' || status === 'canceled';
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyState(next: CrawlTaskState | null): void {
|
||||||
|
if (!refs) return;
|
||||||
|
|
||||||
|
const prevId = currentState?.id;
|
||||||
|
currentState = next;
|
||||||
|
|
||||||
|
if (!next || !isActive(next.status)) {
|
||||||
|
refs.host.style.display = 'none';
|
||||||
|
setMaskActive(false);
|
||||||
|
clearTimers();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prevId !== next.id) {
|
||||||
|
hasExpandedOnceForThisTask = false;
|
||||||
|
hasKeptOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
refs.host.style.display = 'block';
|
||||||
|
|
||||||
|
// Mask blocks page interaction while running. Paused lifts mask so user can solve captcha/login.
|
||||||
|
setMaskActive(next.status === 'running');
|
||||||
|
|
||||||
|
// 中文备注:默认折叠为左下角“圆形计时菜单”,用户点击后再展开查看进度。
|
||||||
|
if (!hasExpandedOnceForThisTask && next.status === 'running') {
|
||||||
|
hasExpandedOnceForThisTask = true;
|
||||||
|
setCollapsed(true);
|
||||||
|
if (autoCollapseTimer) {
|
||||||
|
window.clearTimeout(autoCollapseTimer);
|
||||||
|
autoCollapseTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next.status === 'paused') {
|
||||||
|
setCollapsed(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the elapsed subtitle ticking without waiting for storage writes.
|
||||||
|
if (!clockTimer) {
|
||||||
|
clockTimer = window.setInterval(() => {
|
||||||
|
if (currentState) render(currentState);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTimers(): void {
|
||||||
|
if (clockTimer) {
|
||||||
|
window.clearInterval(clockTimer);
|
||||||
|
clockTimer = null;
|
||||||
|
}
|
||||||
|
if (autoCollapseTimer) {
|
||||||
|
window.clearTimeout(autoCollapseTimer);
|
||||||
|
autoCollapseTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCollapsed(collapsed: boolean): void {
|
||||||
|
if (!refs) return;
|
||||||
|
refs.container.dataset.collapsed = collapsed ? '1' : '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMaskActive(active: boolean): void {
|
||||||
|
if (!maskHost) return;
|
||||||
|
maskHost.style.display = active ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(state: CrawlTaskState): void {
|
||||||
|
if (!refs) return;
|
||||||
|
|
||||||
|
const phase = phaseFor(state);
|
||||||
|
refs.container.dataset.phase = phase;
|
||||||
|
|
||||||
|
const elapsedSeconds = Math.max(0, Math.floor((Date.now() - state.startedAt) / 1000));
|
||||||
|
const time = formatElapsed(elapsedSeconds);
|
||||||
|
|
||||||
|
refs.titleEl.textContent = state.platformName || '爬取任务';
|
||||||
|
|
||||||
|
const okCount = state.steps.filter((s) => s.status === 'success').length;
|
||||||
|
const totalCount = state.steps.length;
|
||||||
|
|
||||||
|
let subtitle = '';
|
||||||
|
if (phase === 'paused') subtitle = `已暂停 · ${time}`;
|
||||||
|
else if (phase === 'done') subtitle = `已完成 · ${okCount}/${totalCount}`;
|
||||||
|
else if (phase === 'failed') subtitle = `失败 · ${okCount}/${totalCount}`;
|
||||||
|
else if (phase === 'cancelled') subtitle = `已取消 · ${okCount}/${totalCount}`;
|
||||||
|
else subtitle = `运行中 · ${okCount}/${totalCount} · ${time}`;
|
||||||
|
|
||||||
|
refs.subtitleEl.textContent = subtitle;
|
||||||
|
refs.currentDetail.textContent = buildCurrentDetail(state);
|
||||||
|
|
||||||
|
// Steps
|
||||||
|
refs.stepsList.innerHTML = '';
|
||||||
|
for (const step of state.steps) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'step';
|
||||||
|
row.dataset.status = mapStepStatus(step.status);
|
||||||
|
|
||||||
|
const dot = document.createElement('div');
|
||||||
|
dot.className = 'dot';
|
||||||
|
dot.textContent = dotFor(step.status);
|
||||||
|
|
||||||
|
const label = document.createElement('div');
|
||||||
|
label.className = 'step-label';
|
||||||
|
label.textContent = step.name;
|
||||||
|
|
||||||
|
row.appendChild(dot);
|
||||||
|
row.appendChild(label);
|
||||||
|
refs.stepsList.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pause banner
|
||||||
|
if (state.status === 'paused' && state.pause) {
|
||||||
|
refs.pauseBanner.style.display = 'block';
|
||||||
|
refs.pauseMessage.textContent = state.pause.message;
|
||||||
|
} else {
|
||||||
|
refs.pauseBanner.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel button only while running (mask active).
|
||||||
|
refs.cancelBtn.style.display = state.status === 'running' ? 'inline-flex' : 'none';
|
||||||
|
|
||||||
|
// Autoclose banner on terminal, when autocloseAt exists and user didn't keep open.
|
||||||
|
if (isTerminal(state.status)) {
|
||||||
|
refs.autocloseBanner.style.display = 'block';
|
||||||
|
const autocloseAt = state.autocloseAt;
|
||||||
|
if (autocloseAt && !hasKeptOpen) {
|
||||||
|
const remaining = Math.max(0, Math.ceil((autocloseAt - Date.now()) / 1000));
|
||||||
|
refs.autocloseText.textContent = `窗口将在 ${remaining}s 后自动关闭`;
|
||||||
|
refs.stayOpenBtn.style.display = 'inline-flex';
|
||||||
|
} else {
|
||||||
|
refs.autocloseText.textContent = '窗口将保持打开';
|
||||||
|
refs.stayOpenBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
refs.autocloseBanner.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capsule text
|
||||||
|
// 中文备注:圆形菜单上只显示计时(mm:ss)。
|
||||||
|
const capsuleTime = refs.capsule.querySelector('.capsule-time') as HTMLSpanElement | null;
|
||||||
|
if (capsuleTime) {
|
||||||
|
capsuleTime.textContent = time;
|
||||||
|
}
|
||||||
|
|
||||||
|
const capsuleText = refs.capsule.querySelector('.capsule-text') as HTMLSpanElement | null;
|
||||||
|
if (capsuleText) {
|
||||||
|
capsuleText.textContent =
|
||||||
|
phase === 'paused'
|
||||||
|
? '已暂停'
|
||||||
|
: isTerminal(state.status)
|
||||||
|
? phase === 'done'
|
||||||
|
? '已完成'
|
||||||
|
: phase === 'failed'
|
||||||
|
? '失败'
|
||||||
|
: '已取消'
|
||||||
|
: `爬取中 ${time}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCurrentDetail(state: CrawlTaskState): string {
|
||||||
|
const step = state.steps[state.currentStepIndex];
|
||||||
|
if (!step) return '';
|
||||||
|
if (state.status === 'paused' && state.pause) return state.pause.message;
|
||||||
|
if (state.status === 'failed') return step.message ?? '爬取失败';
|
||||||
|
if (state.status === 'canceled') return step.message ?? '已取消';
|
||||||
|
if (state.status === 'completed') return '爬取完成';
|
||||||
|
return step.message ?? `正在处理:${step.name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatElapsed(totalSeconds: number): string {
|
||||||
|
const minutes = Math.floor(totalSeconds / 60).toString().padStart(2, '0');
|
||||||
|
const seconds = (totalSeconds % 60).toString().padStart(2, '0');
|
||||||
|
return `${minutes}:${seconds}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapStepStatus(status: CrawlStepStatus): 'active' | 'ok' | 'partial' | 'failed' | 'pending' {
|
||||||
|
if (status === 'running') return 'active';
|
||||||
|
if (status === 'success') return 'ok';
|
||||||
|
if (status === 'failed') return 'failed';
|
||||||
|
return 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
function dotFor(status: CrawlStepStatus): string {
|
||||||
|
if (status === 'success') return '✓';
|
||||||
|
if (status === 'failed') return '×';
|
||||||
|
if (status === 'running') return '•';
|
||||||
|
return '·';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDom(): OverlayRefs {
|
||||||
|
const host = document.createElement('div');
|
||||||
|
host.id = OVERLAY_HOST_ID;
|
||||||
|
host.style.all = 'initial';
|
||||||
|
host.style.position = 'fixed';
|
||||||
|
// 中文备注:爬取窗口左下角放圆形菜单(计时 + 入口)
|
||||||
|
host.style.left = '24px';
|
||||||
|
host.style.bottom = '24px';
|
||||||
|
host.style.zIndex = '2147483647';
|
||||||
|
|
||||||
|
const root = host.attachShadow({ mode: 'closed' });
|
||||||
|
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = 'container';
|
||||||
|
container.dataset.collapsed = '0';
|
||||||
|
container.dataset.phase = 'running';
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
${styleTag()}
|
||||||
|
<div class="expanded">
|
||||||
|
<div class="header">
|
||||||
|
<div class="radar-wrap">
|
||||||
|
<div class="radar"><div class="sweep"></div><div class="ping"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="titles">
|
||||||
|
<div class="title"></div>
|
||||||
|
<div class="subtitle"></div>
|
||||||
|
</div>
|
||||||
|
<button class="minimise-btn" type="button" aria-label="最小化">–</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="body">
|
||||||
|
<div class="steps-list"></div>
|
||||||
|
<div class="current-detail"></div>
|
||||||
|
|
||||||
|
<div class="pause-banner" style="display:none">
|
||||||
|
<div class="pause-row">
|
||||||
|
<div class="pause-icon">!</div>
|
||||||
|
<div style="flex:1 1 auto">
|
||||||
|
<div class="pause-message"></div>
|
||||||
|
<div class="pause-auto-hint">处理完成后可点下方按钮继续</div>
|
||||||
|
<button class="resume-btn" type="button">我已完成 · 继续爬取</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="autoclose-banner" style="display:none">
|
||||||
|
<div class="autoclose-text"></div>
|
||||||
|
<button class="stay-open-btn" type="button">保持打开</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<div class="coffee-hint">扫描期间你可以继续做别的事 · 完成后回到扩展查看结果</div>
|
||||||
|
<button class="cancel-btn" type="button">取消爬取</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="capsule" type="button" aria-label="展开面板">
|
||||||
|
<div class="radar-mini"><div class="sweep-mini"></div></div>
|
||||||
|
<span class="capsule-time">爬取中</span>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
root.appendChild(container);
|
||||||
|
document.body.appendChild(host);
|
||||||
|
|
||||||
|
const titleEl = container.querySelector('.title') as HTMLDivElement;
|
||||||
|
const subtitleEl = container.querySelector('.subtitle') as HTMLDivElement;
|
||||||
|
const stepsList = container.querySelector('.steps-list') as HTMLDivElement;
|
||||||
|
const pauseBanner = container.querySelector('.pause-banner') as HTMLDivElement;
|
||||||
|
const pauseMessage = container.querySelector('.pause-message') as HTMLDivElement;
|
||||||
|
const resumeBtn = container.querySelector('.resume-btn') as HTMLButtonElement;
|
||||||
|
const cancelBtn = container.querySelector('.cancel-btn') as HTMLButtonElement;
|
||||||
|
const minimiseBtn = container.querySelector('.minimise-btn') as HTMLButtonElement;
|
||||||
|
const capsule = container.querySelector('.capsule') as HTMLButtonElement;
|
||||||
|
const currentDetail = container.querySelector('.current-detail') as HTMLDivElement;
|
||||||
|
const autocloseBanner = container.querySelector('.autoclose-banner') as HTMLDivElement;
|
||||||
|
const autocloseText = container.querySelector('.autoclose-text') as HTMLDivElement;
|
||||||
|
const stayOpenBtn = container.querySelector('.stay-open-btn') as HTMLButtonElement;
|
||||||
|
|
||||||
|
minimiseBtn.addEventListener('click', () => setCollapsed(true));
|
||||||
|
capsule.addEventListener('click', () => setCollapsed(false));
|
||||||
|
|
||||||
|
// Cancel with 2-step confirm
|
||||||
|
let cancelConfirmTimer: number | null = null;
|
||||||
|
cancelBtn.addEventListener('click', () => {
|
||||||
|
if (cancelBtn.dataset.confirming === '1') {
|
||||||
|
void chrome.runtime.sendMessage({ action: 'CANCEL_CRAWL' });
|
||||||
|
cancelBtn.dataset.confirming = '0';
|
||||||
|
cancelBtn.textContent = '取消爬取';
|
||||||
|
if (cancelConfirmTimer) window.clearTimeout(cancelConfirmTimer);
|
||||||
|
cancelConfirmTimer = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelBtn.dataset.confirming = '1';
|
||||||
|
cancelBtn.textContent = '确认取消?';
|
||||||
|
cancelConfirmTimer = window.setTimeout(() => {
|
||||||
|
cancelBtn.dataset.confirming = '0';
|
||||||
|
cancelBtn.textContent = '取消爬取';
|
||||||
|
cancelConfirmTimer = null;
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
resumeBtn.addEventListener('click', () => {
|
||||||
|
void chrome.runtime.sendMessage({ action: 'RESUME_CRAWL' });
|
||||||
|
});
|
||||||
|
|
||||||
|
stayOpenBtn.addEventListener('click', () => {
|
||||||
|
hasKeptOpen = true;
|
||||||
|
void chrome.runtime.sendMessage({ action: 'CANCEL_AUTOCLOSE' });
|
||||||
|
if (currentState) render(currentState);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
host,
|
||||||
|
root,
|
||||||
|
container,
|
||||||
|
expanded: container.querySelector('.expanded') as HTMLDivElement,
|
||||||
|
capsule,
|
||||||
|
stepsList,
|
||||||
|
pauseBanner,
|
||||||
|
pauseMessage,
|
||||||
|
resumeBtn,
|
||||||
|
cancelBtn,
|
||||||
|
minimiseBtn,
|
||||||
|
titleEl,
|
||||||
|
subtitleEl,
|
||||||
|
currentDetail,
|
||||||
|
autocloseBanner,
|
||||||
|
autocloseText,
|
||||||
|
stayOpenBtn,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMaskHost(): HTMLDivElement {
|
||||||
|
const existing = document.getElementById(MASK_HOST_ID) as HTMLDivElement | null;
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const m = document.createElement('div');
|
||||||
|
m.id = MASK_HOST_ID;
|
||||||
|
m.style.all = 'initial';
|
||||||
|
m.style.position = 'fixed';
|
||||||
|
m.style.top = '0';
|
||||||
|
m.style.left = '0';
|
||||||
|
m.style.right = '0';
|
||||||
|
m.style.bottom = '0';
|
||||||
|
m.style.zIndex = '2147483646';
|
||||||
|
m.style.background = 'rgba(15, 23, 42, 0.04)';
|
||||||
|
m.style.cursor = 'progress';
|
||||||
|
m.style.pointerEvents = 'auto';
|
||||||
|
m.style.display = 'none';
|
||||||
|
m.setAttribute('aria-hidden', 'true');
|
||||||
|
|
||||||
|
const tip = document.createElement('div');
|
||||||
|
tip.id = 'dianshan-mask-tip';
|
||||||
|
tip.style.all = 'initial';
|
||||||
|
tip.style.position = 'fixed';
|
||||||
|
tip.style.maxWidth = '280px';
|
||||||
|
tip.style.padding = '8px 12px';
|
||||||
|
tip.style.background = '#0f172a';
|
||||||
|
tip.style.color = '#f8fafc';
|
||||||
|
tip.style.fontFamily = "-apple-system,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif";
|
||||||
|
tip.style.fontSize = '12px';
|
||||||
|
tip.style.lineHeight = '1.45';
|
||||||
|
tip.style.borderRadius = '6px';
|
||||||
|
tip.style.boxShadow = '0 4px 14px rgba(0,0,0,0.18), 0 1px 3px rgba(0,0,0,0.12)';
|
||||||
|
tip.style.pointerEvents = 'none';
|
||||||
|
tip.style.zIndex = '1';
|
||||||
|
tip.style.opacity = '0';
|
||||||
|
tip.style.transition = 'opacity 120ms ease';
|
||||||
|
tip.textContent = '正在爬取,请稍候…(暂停时可操作页面)';
|
||||||
|
m.appendChild(tip);
|
||||||
|
|
||||||
|
const swallow = (e: Event) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const evt of ['click', 'dblclick', 'mousedown', 'mouseup', 'pointerdown', 'pointerup', 'wheel', 'touchstart', 'touchend', 'contextmenu']) {
|
||||||
|
m.addEventListener(evt, swallow, { capture: true, passive: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
m.addEventListener(
|
||||||
|
'mousemove',
|
||||||
|
(e) => {
|
||||||
|
tip.style.opacity = '1';
|
||||||
|
const margin = 8;
|
||||||
|
const offsetX = 16;
|
||||||
|
const offsetY = 22;
|
||||||
|
const w = window.innerWidth;
|
||||||
|
const h = window.innerHeight;
|
||||||
|
const rect = tip.getBoundingClientRect();
|
||||||
|
let left = (e as MouseEvent).clientX + offsetX;
|
||||||
|
let top = (e as MouseEvent).clientY + offsetY;
|
||||||
|
if (left + rect.width > w - margin) left = (e as MouseEvent).clientX - rect.width - offsetX;
|
||||||
|
if (top + rect.height > h - margin) top = (e as MouseEvent).clientY - rect.height - offsetY;
|
||||||
|
tip.style.left = Math.max(margin, left) + 'px';
|
||||||
|
tip.style.top = Math.max(margin, top) + 'px';
|
||||||
|
},
|
||||||
|
{ passive: true },
|
||||||
|
);
|
||||||
|
m.addEventListener('mouseleave', () => {
|
||||||
|
tip.style.opacity = '0';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(m);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
function styleTag(): string {
|
||||||
|
// Copied/adapted from StoreAI overlay style for a 1:1 UI feel.
|
||||||
|
return `<style>
|
||||||
|
:host, .container, .expanded, .capsule, button {
|
||||||
|
font-family: -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #e6edf3;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
width: 360px;
|
||||||
|
background: linear-gradient(180deg, #0d1117 0%, #161b22 100%);
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 10px 32px rgba(0,0,0,0.45), 0 2px 8px rgba(0,0,0,0.25);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 220ms ease;
|
||||||
|
}
|
||||||
|
.container[data-collapsed="1"] { width: auto; background: transparent; border: none; box-shadow: none; }
|
||||||
|
.container[data-collapsed="1"] .expanded { display: none; }
|
||||||
|
.container[data-collapsed="0"] .capsule { display: none; }
|
||||||
|
|
||||||
|
.container[data-phase="paused"] { border-color: #d29922; box-shadow: 0 10px 32px rgba(210,153,34,0.3); }
|
||||||
|
.container[data-phase="done"] { border-color: #238636; }
|
||||||
|
.container[data-phase="failed"] { border-color: #da3633; }
|
||||||
|
|
||||||
|
.expanded { display: flex; flex-direction: column; }
|
||||||
|
.header { display: flex; align-items: center; gap: 12px; padding: 14px 16px 10px 16px; }
|
||||||
|
.radar-wrap { flex: 0 0 44px; height: 44px; }
|
||||||
|
.radar {
|
||||||
|
width: 44px; height: 44px; border-radius: 50%;
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
background: radial-gradient(circle at center, rgba(46,160,67,0.12), 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.7) 50deg, rgba(46,160,67,0) 60deg);
|
||||||
|
animation: sweep 2s linear infinite;
|
||||||
|
}
|
||||||
|
.ping {
|
||||||
|
position: absolute; left: 50%; top: 50%; width: 8px; height: 8px;
|
||||||
|
background: #2ea043; border-radius: 50%; transform: translate(-50%,-50%);
|
||||||
|
box-shadow: 0 0 0 0 rgba(46,160,67,0.7);
|
||||||
|
animation: ping 2s ease-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes sweep { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||||
|
@keyframes ping {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(46,160,67,0.7); }
|
||||||
|
100% { box-shadow: 0 0 0 18px rgba(46,160,67,0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.container[data-phase="paused"] .sweep,
|
||||||
|
.container[data-phase="done"] .sweep,
|
||||||
|
.container[data-phase="failed"] .sweep,
|
||||||
|
.container[data-phase="cancelled"] .sweep { animation: none; opacity: 0.3; }
|
||||||
|
.container[data-phase="done"] .ping { background: #2ea043; animation: none; }
|
||||||
|
.container[data-phase="paused"] .ping { background: #d29922; animation: none; }
|
||||||
|
.container[data-phase="failed"] .ping { background: #da3633; animation: none; }
|
||||||
|
|
||||||
|
.titles { flex: 1 1 auto; min-width: 0; }
|
||||||
|
.title { font-size: 14px; font-weight: 600; color: #f0f6fc; line-height: 1.3; margin: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.subtitle { font-size: 12px; color: #8b949e; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.minimise-btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
background: transparent; color: #8b949e;
|
||||||
|
border: 1px solid #30363d; border-radius: 6px;
|
||||||
|
width: 28px; height: 28px; cursor: pointer;
|
||||||
|
font-size: 16px; line-height: 1;
|
||||||
|
transition: all 120ms ease;
|
||||||
|
}
|
||||||
|
.minimise-btn:hover { color: #e6edf3; border-color: #8b949e; }
|
||||||
|
|
||||||
|
.body { padding: 2px 16px 12px 16px; }
|
||||||
|
.steps-list { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.step { display: flex; align-items: center; gap: 8px; font-size: 12px; line-height: 1.4; padding: 4px 0; color: #8b949e; }
|
||||||
|
.step[data-status="active"] { color: #58a6ff; }
|
||||||
|
.step[data-status="ok"] { color: #3fb950; }
|
||||||
|
.step[data-status="partial"] { color: #d29922; }
|
||||||
|
.step[data-status="failed"] { color: #f85149; }
|
||||||
|
.step .dot { flex: 0 0 auto; display: inline-flex; width: 16px; height: 16px; align-items: center; justify-content: center; font-size: 10px; font-weight: 700; }
|
||||||
|
.step .step-label { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|
||||||
|
.current-detail {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: rgba(110,118,129,0.08);
|
||||||
|
border: 1px dashed rgba(139,148,158,0.25);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #c9d1d9;
|
||||||
|
font-size: 11.5px; line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pause-banner { margin-top: 12px; padding: 10px 12px; background: rgba(210,153,34,0.08); border: 1px solid rgba(210,153,34,0.35); border-radius: 8px; }
|
||||||
|
.pause-row { display: flex; align-items: flex-start; gap: 10px; }
|
||||||
|
.pause-icon { flex: 0 0 22px; width: 22px; height: 22px; background: #d29922; color: #0d1117; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-weight: 700; font-size: 13px; }
|
||||||
|
.pause-message { flex: 1 1 auto; color: #e6edf3; font-size: 12px; line-height: 1.45; }
|
||||||
|
.pause-auto-hint { margin-top: 8px; font-size: 11px; color: #d29922; line-height: 1.45; opacity: 0.85; }
|
||||||
|
.resume-btn { margin-top: 10px; width: 100%; padding: 8px 10px; background: #d29922; color: #0d1117; border: none; border-radius: 6px; font-size: 12px; font-weight: 600; cursor: pointer; transition: filter 120ms ease; }
|
||||||
|
.resume-btn:hover { filter: brightness(1.1); }
|
||||||
|
|
||||||
|
.autoclose-banner { margin: 0 16px 8px 16px; padding: 10px 12px; display: flex; align-items: center; justify-content: space-between; gap: 10px; background: rgba(88,166,255,0.08); border: 1px solid rgba(88,166,255,0.35); border-radius: 8px; }
|
||||||
|
.autoclose-text { flex: 1 1 auto; min-width: 0; font-size: 11.5px; color: #c9d1d9; line-height: 1.4; }
|
||||||
|
.stay-open-btn { flex: 0 0 auto; padding: 6px 12px; background: #58a6ff; color: #0d1117; border: none; border-radius: 5px; font-size: 11px; font-weight: 600; cursor: pointer; transition: filter 120ms ease; }
|
||||||
|
.stay-open-btn:hover { filter: brightness(1.1); }
|
||||||
|
|
||||||
|
.footer { padding: 10px 16px 14px 16px; border-top: 1px solid rgba(48,54,61,0.6); display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.coffee-hint { font-size: 11px; color: #8b949e; line-height: 1.5; }
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
/* 中文备注:圆形菜单(左下角)只展示计时;点击展开查看详细进度 */
|
||||||
|
.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; }
|
||||||
|
.capsule:hover { transform: translateY(-1px); }
|
||||||
|
.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); }
|
||||||
|
.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; }
|
||||||
|
.capsule-time { position: relative; z-index: 1; font-variant-numeric: tabular-nums; font-weight: 700; letter-spacing: 0.2px; }
|
||||||
|
</style>`;
|
||||||
|
}
|
||||||
@@ -1,22 +1,12 @@
|
|||||||
import { createApp } from 'vue';
|
import { setupPageRunner } from './pageRunner';
|
||||||
import App from './App.vue';
|
import { mountCrawlOverlay } from './crawlOverlay';
|
||||||
|
|
||||||
/** 将内容脚本应用挂载到页面中。 */
|
/**
|
||||||
|
* 将内容脚本应用挂载到页面中。
|
||||||
|
*/
|
||||||
function mountApp() {
|
function mountApp() {
|
||||||
if (document.getElementById('dianshan-crx-root')) {
|
setupPageRunner();
|
||||||
return;
|
mountCrawlOverlay();
|
||||||
}
|
|
||||||
|
|
||||||
/** 内容脚本在宿主页面中的根容器,用于避免污染业务页面结构。 */
|
|
||||||
const container = document.createElement('div');
|
|
||||||
container.id = 'dianshan-crx-root';
|
|
||||||
/** Vue 应用实际挂载的节点。 */
|
|
||||||
const appRoot = document.createElement('div');
|
|
||||||
|
|
||||||
container.appendChild(appRoot);
|
|
||||||
document.body.appendChild(container);
|
|
||||||
|
|
||||||
createApp(App).mount(appRoot);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
|
|||||||
207
src/content/pageRunner.ts
Normal file
207
src/content/pageRunner.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { processFields, type DomScrapeResult } from '@/background/domScraper';
|
||||||
|
import type { CrawlPauseInfo, PlatformFieldConfig } from '@/types';
|
||||||
|
|
||||||
|
interface ScrapeStepMessage {
|
||||||
|
action: 'SCRAPE_STEP';
|
||||||
|
payload: {
|
||||||
|
fields: PlatformFieldConfig[];
|
||||||
|
checkSelector: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CheckInterruptMessage {
|
||||||
|
action: 'CHECK_INTERRUPT';
|
||||||
|
}
|
||||||
|
|
||||||
|
type PageRunnerMessage = ScrapeStepMessage | CheckInterruptMessage;
|
||||||
|
|
||||||
|
interface PageRunnerResponse {
|
||||||
|
ok: boolean;
|
||||||
|
data?: DomScrapeResult | null;
|
||||||
|
interrupt?: CrawlPauseInfo;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册页面执行器,供 background 在目标网页中触发中断检测和 DOM 抓取。
|
||||||
|
*/
|
||||||
|
export function setupPageRunner(): void {
|
||||||
|
chrome.runtime.onMessage.addListener((message: PageRunnerMessage, _sender, sendResponse) => {
|
||||||
|
void handlePageRunnerMessage(message).then(sendResponse);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 background 发来的页面执行消息。
|
||||||
|
*/
|
||||||
|
async function handlePageRunnerMessage(message: PageRunnerMessage): Promise<PageRunnerResponse> {
|
||||||
|
if (message.action === 'CHECK_INTERRUPT') {
|
||||||
|
return { ok: true, interrupt: detectPageInterrupt() };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.action === 'SCRAPE_STEP') {
|
||||||
|
const interrupt = detectPageInterrupt();
|
||||||
|
|
||||||
|
if (interrupt) {
|
||||||
|
return { ok: false, interrupt };
|
||||||
|
}
|
||||||
|
|
||||||
|
const readyElement = await waitForStableSelector(message.payload.checkSelector, 18000);
|
||||||
|
|
||||||
|
if (!readyElement) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
interrupt: {
|
||||||
|
reason: 'page_not_ready',
|
||||||
|
message: '页面关键内容暂未加载,请确认页面是否正常显示后继续',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await processFields(message.payload.fields, document.body);
|
||||||
|
return { ok: true, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, error: '未知页面执行指令' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测当前页面是否需要用户手动处理登录、验证码或页面不存在。
|
||||||
|
*/
|
||||||
|
function detectPageInterrupt(): CrawlPauseInfo | undefined {
|
||||||
|
if (isShieldPage()) {
|
||||||
|
return {
|
||||||
|
reason: 'shield',
|
||||||
|
message: '检测到验证码或风控验证,请在打开的商家后台窗口处理完成后继续',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoginPage()) {
|
||||||
|
return {
|
||||||
|
reason: 'reauth',
|
||||||
|
message: '检测到需要重新登录,请在打开的商家后台窗口登录完成后继续',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNotFoundPage()) {
|
||||||
|
return {
|
||||||
|
reason: 'not_found',
|
||||||
|
message: '当前页面不存在或已失效,请确认平台配置里的页面地址是否正确',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否进入验证码、流量盾或风控验证页。
|
||||||
|
*/
|
||||||
|
function isShieldPage(): boolean {
|
||||||
|
const path = location.pathname.toLowerCase();
|
||||||
|
|
||||||
|
if (path.startsWith('/verify/captcha') || path.startsWith('/verify/traffic')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shieldElement = document.querySelector(
|
||||||
|
'[data-name="verification"], .ant-captcha, #captchaContainer, [class*="captcha" i], [id*="captcha" i]',
|
||||||
|
);
|
||||||
|
|
||||||
|
return shieldElement ? isVisibleElement(shieldElement) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断当前页面是否需要登录或二次验证密码。
|
||||||
|
*/
|
||||||
|
function isLoginPage(): boolean {
|
||||||
|
const path = location.pathname.toLowerCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
/^\/(?:buyer\/)?login\b/i.test(path) ||
|
||||||
|
/^\/account\/(?:signin|login)\b/i.test(path) ||
|
||||||
|
/^\/portal\/login\b/i.test(path)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visiblePasswordInput = Array.from(document.querySelectorAll('input[type="password"]')).some(isVisibleElement);
|
||||||
|
|
||||||
|
if (visiblePasswordInput) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyText = document.body.innerText.slice(0, 3000);
|
||||||
|
const loginTextPatterns = [
|
||||||
|
/enter\s+(your\s+)?password\s+to\s+continue/i,
|
||||||
|
/sign\s+in\s+(again\s+)?to\s+continue/i,
|
||||||
|
/please\s+(re-?)?enter\s+(your\s+)?password/i,
|
||||||
|
/请(再次|重新)?输入(您的)?密码/,
|
||||||
|
/请登录|重新登录|登录后继续/,
|
||||||
|
];
|
||||||
|
|
||||||
|
return loginTextPatterns.some((pattern) => pattern.test(bodyText));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断当前页面是否是不存在、下架或错误页面。
|
||||||
|
*/
|
||||||
|
function isNotFoundPage(): boolean {
|
||||||
|
const text = document.body.innerText.slice(0, 8000);
|
||||||
|
const title = document.title;
|
||||||
|
const notFoundPatterns = [
|
||||||
|
/page\s+not\s+found/i,
|
||||||
|
/the\s+page\s+you\s+are\s+looking\s+for/i,
|
||||||
|
/this\s+page\s+(has\s+been\s+)?removed/i,
|
||||||
|
/product\s+(is\s+)?unavailable/i,
|
||||||
|
/页面不存在|找不到(此|该)?页面|抱歉.*不存在|(商品|产品)已下架/,
|
||||||
|
];
|
||||||
|
|
||||||
|
return notFoundPatterns.some((pattern) => pattern.test(title) || pattern.test(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 等待页面中出现稳定的关键元素。
|
||||||
|
*/
|
||||||
|
async function waitForStableSelector(selector: string, timeoutMs: number): Promise<Element | null> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const element = document.querySelector(selector);
|
||||||
|
|
||||||
|
if (element && isVisibleElement(element)) {
|
||||||
|
await sleep(600);
|
||||||
|
const stableElement = document.querySelector(selector);
|
||||||
|
return stableElement && isVisibleElement(stableElement) ? stableElement : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断元素是否真实可见。
|
||||||
|
*/
|
||||||
|
function isVisibleElement(element: Element): boolean {
|
||||||
|
if (!element.isConnected) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = element.ownerDocument.defaultView?.getComputedStyle(element);
|
||||||
|
|
||||||
|
if (!style || style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) < 0.05) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return rect.width > 0 && rect.height > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 简单等待工具。
|
||||||
|
*/
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
window.setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,231 +1,218 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
import {platformConfigs} from '@/config/platforms';
|
||||||
import { PLATFORM_CONFIGS } from '@/config/platforms';
|
import {formatSeconds} from '@/shared/time_format';
|
||||||
import { getToken, logout, mockLogin } from '@/shared/auth';
|
import {useLogin} from './hook/use-login';
|
||||||
import type { CrawlTaskState } from '@/types';
|
import {useScan} from './hook/use-scan';
|
||||||
|
import {useI18n, type PopupUiLang} from './hook/use-i18n';
|
||||||
|
|
||||||
const token = ref<string | null>(null);
|
const {isLoggedIn, handleLogin, handleLogout} = useLogin();
|
||||||
const selectedPlatformId = ref(PLATFORM_CONFIGS[0]?.id ?? '');
|
|
||||||
const isLoading = ref(true);
|
|
||||||
const isScanning = ref(false);
|
|
||||||
const errorMessage = ref('');
|
|
||||||
const crawlState = ref<CrawlTaskState | null>(null);
|
|
||||||
const elapsedSeconds = ref(0);
|
|
||||||
let timer: number | undefined;
|
|
||||||
|
|
||||||
const selectedPlatform = computed(() =>
|
// 中文备注:Popup 内多语言(只影响文案显示)
|
||||||
PLATFORM_CONFIGS.find((platform) => platform.id === selectedPlatformId.value) ?? null,
|
const {uiLang, setUiLang, t, langOptions} = useI18n();
|
||||||
);
|
|
||||||
|
|
||||||
const isLoggedIn = computed(() => token.value !== null);
|
const {
|
||||||
const isCrawling = computed(() => crawlState.value?.status === 'running');
|
selectedPlatformId,
|
||||||
|
isScanning,
|
||||||
|
crawlState,
|
||||||
|
elapsedSeconds,
|
||||||
|
taskStatus,
|
||||||
|
handleScan,
|
||||||
|
handleCancelCrawl,
|
||||||
|
handleResumeCrawl,
|
||||||
|
quotaLoading,
|
||||||
|
subscriptionExpired,
|
||||||
|
quotaDeniedMessage,
|
||||||
|
openBillingPage,
|
||||||
|
} = useScan();
|
||||||
|
console.log(crawlState.value)
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
token.value = await getToken();
|
|
||||||
await refreshCrawlState();
|
|
||||||
timer = window.setInterval(() => {
|
|
||||||
updateElapsedSeconds();
|
|
||||||
void refreshCrawlState();
|
|
||||||
}, 1000);
|
|
||||||
isLoading.value = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (timer) {
|
|
||||||
window.clearInterval(timer);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handleLogin() {
|
|
||||||
errorMessage.value = '';
|
|
||||||
token.value = await mockLogin();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleLogout() {
|
|
||||||
errorMessage.value = '';
|
|
||||||
await logout();
|
|
||||||
token.value = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleScan() {
|
|
||||||
errorMessage.value = '';
|
|
||||||
|
|
||||||
if (!selectedPlatform.value) {
|
|
||||||
errorMessage.value = '请选择要爬取的平台';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isScanning.value = true;
|
|
||||||
|
|
||||||
|
/** 从扩展 manifest 读取版本号(兜底 `0.0.0`)。 */
|
||||||
|
const manifestVersion = (() => {
|
||||||
try {
|
try {
|
||||||
const response = await sendBackgroundMessage<CrawlTaskState>({
|
return chrome.runtime.getManifest().version;
|
||||||
action: 'START_CRAWL',
|
} catch {
|
||||||
payload: { platformId: selectedPlatform.value.id },
|
return '0.0.0';
|
||||||
});
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
errorMessage.value = response.error ?? '打开平台窗口失败';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
crawlState.value = response.data ?? null;
|
/** 若爬取窗口存在,则将其置顶并吸引注意力。 */
|
||||||
updateElapsedSeconds();
|
async function focusCrawlWindow(): Promise<void> {
|
||||||
} catch (error: unknown) {
|
if (!crawlState.value?.windowId) return;
|
||||||
errorMessage.value = error instanceof Error ? error.message : '打开平台窗口失败';
|
try {
|
||||||
} finally {
|
await chrome.windows.update(crawlState.value.windowId, {focused: true, drawAttention: true});
|
||||||
isScanning.value = false;
|
} catch {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCancelCrawl() {
|
|
||||||
const response = await sendBackgroundMessage<CrawlTaskState>({ action: 'CANCEL_CRAWL' });
|
/**
|
||||||
crawlState.value = response.data ?? null;
|
* 取消
|
||||||
|
*/
|
||||||
|
function requestCancel(): void {
|
||||||
|
// 中文备注:不要在这里手动把 crawlState 置空。
|
||||||
|
// 任务状态以 storage 同步为准;手动置空会让 use-scan 的计时器回调访问空对象,导致 popup 闪退(表现为“闪一下”)。
|
||||||
|
void handleCancelCrawl();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshCrawlState() {
|
/**
|
||||||
const response = await sendBackgroundMessage<CrawlTaskState | null>({ action: 'GET_CRAWL_STATE' });
|
* 语言切换
|
||||||
|
* 中文备注:用事件回调承接,避免在 template 里写复杂类型断言影响可读性。
|
||||||
if (response.ok) {
|
*/
|
||||||
crawlState.value = response.data ?? null;
|
function onLangChange(event: Event): void {
|
||||||
updateElapsedSeconds();
|
const value = (event.target as HTMLSelectElement).value as PopupUiLang;
|
||||||
}
|
void setUiLang(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateElapsedSeconds() {
|
|
||||||
if (!crawlState.value) {
|
|
||||||
elapsedSeconds.value = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
elapsedSeconds.value = Math.max(0, Math.floor((Date.now() - crawlState.value.startedAt) / 1000));
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatElapsed(totalSeconds: number): string {
|
|
||||||
const minutes = Math.floor(totalSeconds / 60).toString().padStart(2, '0');
|
|
||||||
const seconds = (totalSeconds % 60).toString().padStart(2, '0');
|
|
||||||
return `${minutes}:${seconds}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStepClass(status: string): string {
|
|
||||||
if (status === 'running') {
|
|
||||||
return 'border-emerald-500 bg-emerald-50 text-emerald-700';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === 'success') {
|
|
||||||
return 'border-green-500 bg-green-50 text-green-700';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === 'failed') {
|
|
||||||
return 'border-red-500 bg-red-50 text-red-700';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'border-slate-300 bg-white text-slate-500';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStepText(status: string): string {
|
|
||||||
const textMap: Record<string, string> = {
|
|
||||||
pending: '等待中',
|
|
||||||
running: '爬取中',
|
|
||||||
success: '已完成',
|
|
||||||
failed: '爬取失败',
|
|
||||||
};
|
|
||||||
|
|
||||||
return textMap[status] ?? status;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendBackgroundMessage<T>(message: unknown): Promise<{ ok: boolean; data?: T; error?: string }> {
|
|
||||||
if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) {
|
|
||||||
return Promise.resolve({ ok: true, data: null as T });
|
|
||||||
}
|
|
||||||
|
|
||||||
return chrome.runtime.sendMessage(message);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="w-80 bg-slate-50 text-slate-900">
|
<div class="container">
|
||||||
<section class="flex min-h-64 flex-col gap-5 p-5">
|
<header>
|
||||||
<header class="space-y-2">
|
<div class="logo">
|
||||||
<p class="text-lg font-semibold leading-6">店闪</p>
|
<span class="logo-mark">SA</span>
|
||||||
<p class="text-sm leading-5 text-slate-600">自动打开商家后台,按平台配置顺序采集页面数据</p>
|
<span>StoreAI</span>
|
||||||
</header>
|
|
||||||
|
|
||||||
<div v-if="isLoading" class="rounded-md border border-slate-200 bg-white px-3 py-4 text-sm text-slate-500">
|
|
||||||
正在读取登录状态...
|
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<template v-else-if="!isLoggedIn">
|
<!-- 未登录-->
|
||||||
<button type="button"
|
<template v-if="!isLoggedIn">
|
||||||
class="rounded-md bg-slate-900 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-slate-700"
|
<div class="status">{{ t('please_login') }}</div>
|
||||||
@click="handleLogin">
|
<button style="margin-top: 20px" type="button" @click="handleLogin">
|
||||||
请登录
|
{{ t('sign_in') }}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="isCrawling && crawlState">
|
<template v-else>
|
||||||
<section class="space-y-4">
|
<!-- 未开始-->
|
||||||
<div class="flex items-center justify-between rounded-md bg-white px-3 py-2 shadow-sm">
|
<template v-if="crawlState == null">
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-slate-800">{{ crawlState.platformName }}</p>
|
|
||||||
<p class="text-xs text-slate-500">已运行 {{ formatElapsed(elapsedSeconds) }}</p>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="text-xs text-red-600 transition hover:text-red-700"
|
|
||||||
@click="handleCancelCrawl">
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ol class="space-y-3">
|
<label class="platform-select">
|
||||||
<li v-for="(step, index) in crawlState.steps" :key="step.uniqueKey"
|
<span class="account">{{ t('platform_select') }}</span>
|
||||||
class="relative border-l-2 border-slate-200 pl-4">
|
|
||||||
<span
|
|
||||||
class="absolute -left-[7px] top-1 h-3 w-3 rounded-full border-2 border-white bg-slate-300"
|
|
||||||
:class="{ 'bg-emerald-500': step.status === 'running' || step.status === 'success', 'bg-red-500': step.status === 'failed' }"></span>
|
|
||||||
<div class="rounded-md border px-3 py-2 text-sm" :class="getStepClass(step.status)">
|
|
||||||
<div class="flex items-center justify-between gap-3">
|
|
||||||
<span class="font-medium">{{ index + 1 }}. {{ step.name }}</span>
|
|
||||||
<span class="text-xs">{{ getStepText(step.status) }}</span>
|
|
||||||
</div>
|
|
||||||
<p v-if="step.message" class="mt-1 text-xs">{{ step.message }}</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<label class="space-y-2">
|
|
||||||
<span class="text-sm font-medium text-slate-700">平台选择</span>
|
|
||||||
<select v-model="selectedPlatformId"
|
<select v-model="selectedPlatformId"
|
||||||
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm outline-none transition focus:border-slate-800 focus:ring-2 focus:ring-slate-200">
|
class="platform-select__control">
|
||||||
<option v-for="platform in PLATFORM_CONFIGS" :key="platform.id" :value="platform.id">
|
<option v-for="platform in platformConfigs"
|
||||||
|
:key="platform.id"
|
||||||
|
:value="platform.id">
|
||||||
{{ platform.name }}
|
{{ platform.name }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button type="button"
|
<!-- 中文备注:订阅到期时,不允许用户在扩展里直接点“立即扫描”绕过订阅限制 -->
|
||||||
class="rounded-md bg-emerald-600 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:bg-slate-300"
|
<template v-if="subscriptionExpired">
|
||||||
:disabled="isScanning" @click="handleScan">
|
<div class="status">{{ t('subscription_expired') }}</div>
|
||||||
{{ isScanning ? '正在打开...' : '立即爬取' }}
|
<button type="button" @click="openBillingPage">
|
||||||
</button>
|
{{ 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>
|
||||||
|
|
||||||
<p v-if="errorMessage" class="rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">
|
<!-- 进行中-->
|
||||||
{{ errorMessage }}
|
<template v-else>
|
||||||
</p>
|
<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>
|
||||||
|
|
||||||
<footer
|
<div class="steps">
|
||||||
class="mt-auto flex items-center justify-between border-t border-slate-200 pt-4 text-xs text-slate-500">
|
<div v-for="(step, index) in crawlState.steps" :key="step.uniqueKey" class="step">
|
||||||
<button v-if="isLoggedIn" type="button" class="text-slate-600 transition hover:text-slate-900"
|
<div class="step-left">
|
||||||
@click="handleLogout">
|
<div class="step-dot">
|
||||||
退出
|
<span v-if="step.status =='success'">✓</span>
|
||||||
</button>
|
<span v-else-if="step.status =='failed'">×</span>
|
||||||
<span v-else></span>
|
<span v-else-if="step.status =='running'">•</span>
|
||||||
<span>v1.0.0</span>
|
</div>
|
||||||
</footer>
|
<div class="step-label">{{ index + 1 }}. {{ step.name }}</div>
|
||||||
</section>
|
</div>
|
||||||
</main>
|
<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>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
119
src/popup/hook/use-i18n.ts
Normal file
119
src/popup/hook/use-i18n.ts
Normal 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'},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
57
src/popup/hook/use-login.ts
Normal file
57
src/popup/hook/use-login.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import {computed, onMounted, ref} from "vue";
|
||||||
|
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 = () => {
|
||||||
|
const token = ref<string | null>(null);
|
||||||
|
|
||||||
|
// 当前是否已登录(是否已有 extension token)
|
||||||
|
const isLoggedIn = computed(() => token.value !== null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录:打开网页,让网页完成配对并回传 extension token
|
||||||
|
*/
|
||||||
|
const handleLogin = async () => {
|
||||||
|
const extId = chrome?.runtime?.id || "";
|
||||||
|
const url = `${APP_URL.replace(/\/$/, "")}/onboarding/extension?from=extension&extId=${encodeURIComponent(extId)}`;
|
||||||
|
await chrome.tabs.create({url, active: true});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退出:清空本地 token 与上下文
|
||||||
|
*/
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await clearAuthState();
|
||||||
|
token.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// 中文备注:先恢复内存态 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 {
|
||||||
|
isLoggedIn,
|
||||||
|
handleLogin,
|
||||||
|
handleLogout,
|
||||||
|
};
|
||||||
|
};
|
||||||
223
src/popup/hook/use-scan.ts
Normal file
223
src/popup/hook/use-scan.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import {computed, onMounted, onUnmounted, ref} from "vue";
|
||||||
|
import {platformConfigs} from "@/config/platforms";
|
||||||
|
import type {CrawlTaskState} from "@/types";
|
||||||
|
import {sendBackgroundMessage} from "@/shared/message";
|
||||||
|
import {getScanQuotaApi} from "@/api/me";
|
||||||
|
import {APP_URL} from "@/config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Popup 内的爬取状态与操作集合
|
||||||
|
*
|
||||||
|
* 中文备注:
|
||||||
|
* - 订阅存在“隐藏逻辑”:订阅到期时不能让用户绕过网页,直接在扩展 popup 点“立即扫描”
|
||||||
|
* - 这里会在 popup 打开时调用一次 `/api/me/scan-quota`,如果订阅过期则隐藏“立即扫描”按钮,改为引导去订阅
|
||||||
|
*/
|
||||||
|
export const useScan = () => {
|
||||||
|
/** 当前选中的平台 id */
|
||||||
|
const selectedPlatformId = ref(platformConfigs[0]?.id ?? "");
|
||||||
|
/** 防止重复点击 “Scan now” */
|
||||||
|
const isScanning = ref<boolean>(false);
|
||||||
|
/** 当前爬取任务状态(从 background 同步) */
|
||||||
|
const crawlState = ref<CrawlTaskState | null>(null);
|
||||||
|
|
||||||
|
const taskStatus = computed(() => crawlState.value?.status);
|
||||||
|
|
||||||
|
/** 任务耗时(秒) */
|
||||||
|
const elapsedSeconds = ref<number>(0);
|
||||||
|
let timer: number | undefined;
|
||||||
|
|
||||||
|
// 中文备注:订阅/权限检查(控制“立即扫描”按钮是否可用)
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
quotaLoading.value = true;
|
||||||
|
try {
|
||||||
|
const quota: any = await getScanQuotaApi();
|
||||||
|
if (quota?.allowed === true) {
|
||||||
|
quotaDeniedReason.value = null;
|
||||||
|
quotaDeniedMessage.value = null;
|
||||||
|
} else {
|
||||||
|
quotaDeniedReason.value = String(quota?.reason || "scan_not_allowed");
|
||||||
|
quotaDeniedMessage.value = String(quota?.message || "");
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
// 中文备注:接口失败时不在 UI 里误判为“订阅到期”,只记录为不可扫描
|
||||||
|
quotaDeniedReason.value = "quota_check_failed";
|
||||||
|
quotaDeniedMessage.value = e?.response?.data?.message || e?.message || "";
|
||||||
|
} finally {
|
||||||
|
quotaLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发扫描(开始前会先做订阅校验)
|
||||||
|
*/
|
||||||
|
const handleScan = async () => {
|
||||||
|
if (isScanning.value) return;
|
||||||
|
isScanning.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 中文备注:点击“立即扫描”前先刷新一次订阅/权限,避免订阅刚到期但按钮仍可点
|
||||||
|
await refreshQuota();
|
||||||
|
if (subscriptionExpired.value) {
|
||||||
|
await openBillingPage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (quotaDeniedReason.value) {
|
||||||
|
console.error("[crawl] scan blocked:", quotaDeniedReason.value, quotaDeniedMessage.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await sendBackgroundMessage<CrawlTaskState>({
|
||||||
|
action: "START_CRAWL",
|
||||||
|
payload: {platformId: selectedPlatformId.value},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
syncCrawlState(response.data ?? null);
|
||||||
|
} else {
|
||||||
|
console.error("[crawl] start failed", response.error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isScanning.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelCrawl = async () => {
|
||||||
|
const response = await sendBackgroundMessage<CrawlTaskState | null>({action: "CANCEL_CRAWL"});
|
||||||
|
if (response.ok) {
|
||||||
|
syncCrawlState(response.data ?? null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error("[crawl] cancel failed", response.error);
|
||||||
|
await refreshCrawlState();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResumeCrawl = async () => {
|
||||||
|
const response = await sendBackgroundMessage<CrawlTaskState | null>({action: "RESUME_CRAWL"});
|
||||||
|
if (response.ok) {
|
||||||
|
syncCrawlState(response.data ?? null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error("[crawl] resume failed", response.error);
|
||||||
|
await refreshCrawlState();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDismissCrawl = async () => {
|
||||||
|
const response = await sendBackgroundMessage<CrawlTaskState | null>({action: "DISMISS_CRAWL"});
|
||||||
|
if (response.ok) {
|
||||||
|
syncCrawlState(response.data ?? null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error("[crawl] dismiss failed", response.error);
|
||||||
|
await refreshCrawlState();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 监听 storage 变化:
|
||||||
|
* - crawlTaskState:同步任务进度
|
||||||
|
* - auth_state:登录/退出/重新配对后刷新订阅状态
|
||||||
|
*/
|
||||||
|
function handleStorageChanged(changes: Record<string, chrome.storage.StorageChange>, areaName: string) {
|
||||||
|
if (areaName !== "local") return;
|
||||||
|
|
||||||
|
if (changes["auth_state"]) {
|
||||||
|
void refreshQuota();
|
||||||
|
}
|
||||||
|
|
||||||
|
const change = changes["crawlTaskState"];
|
||||||
|
if (!change) return;
|
||||||
|
syncCrawlState(isCrawlTaskState(change.newValue) ? change.newValue : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await refreshCrawlState();
|
||||||
|
await refreshQuota();
|
||||||
|
chrome.storage.onChanged.addListener(handleStorageChanged);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopElapsedTimer();
|
||||||
|
chrome.storage.onChanged.removeListener(handleStorageChanged);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedPlatformId,
|
||||||
|
isScanning,
|
||||||
|
crawlState,
|
||||||
|
taskStatus,
|
||||||
|
elapsedSeconds,
|
||||||
|
handleScan,
|
||||||
|
handleCancelCrawl,
|
||||||
|
handleResumeCrawl,
|
||||||
|
handleDismissCrawl,
|
||||||
|
// 订阅/权限
|
||||||
|
quotaLoading,
|
||||||
|
subscriptionExpired,
|
||||||
|
quotaDeniedMessage,
|
||||||
|
openBillingPage,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function isCrawlTaskState(value: unknown): value is CrawlTaskState {
|
||||||
|
return typeof value === "object" && value !== null && "id" in value && "steps" in value;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createApp } from 'vue';
|
import { createApp } from 'vue';
|
||||||
import App from './App.vue';
|
import App from './App.vue';
|
||||||
|
import './popup.scss';
|
||||||
|
|
||||||
createApp(App).mount('#app');
|
createApp(App).mount('#app');
|
||||||
|
|
||||||
|
|||||||
410
src/popup/popup.scss
Normal file
410
src/popup/popup.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
const AUTH_TOKEN_KEY = 'token';
|
const AUTH_TOKEN_KEY = 'token';
|
||||||
const MOCK_TOKEN = 'mock-extension-token';
|
|
||||||
|
|
||||||
/** 获取当前登录 token。 */
|
/**
|
||||||
|
* 获取当前登录 token。
|
||||||
|
*/
|
||||||
export async function getToken(): Promise<string | null> {
|
export async function getToken(): Promise<string | null> {
|
||||||
const storage = getChromeStorage();
|
const storage = getChromeStorage();
|
||||||
|
|
||||||
@@ -14,13 +15,10 @@ export async function getToken(): Promise<string | null> {
|
|||||||
return window.localStorage.getItem(AUTH_TOKEN_KEY);
|
return window.localStorage.getItem(AUTH_TOKEN_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 模拟登录,写入一个临时 token,方便后续替换真实登录逻辑。 */
|
|
||||||
export async function mockLogin(): Promise<string> {
|
|
||||||
await setToken(MOCK_TOKEN);
|
|
||||||
return MOCK_TOKEN;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 清除当前登录 token。 */
|
/**
|
||||||
|
* 清除当前登录 token。
|
||||||
|
*/
|
||||||
export async function logout(): Promise<void> {
|
export async function logout(): Promise<void> {
|
||||||
const storage = getChromeStorage();
|
const storage = getChromeStorage();
|
||||||
|
|
||||||
@@ -32,7 +30,7 @@ export async function logout(): Promise<void> {
|
|||||||
window.localStorage.removeItem(AUTH_TOKEN_KEY);
|
window.localStorage.removeItem(AUTH_TOKEN_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setToken(token: string): Promise<void> {
|
export async function setToken(token: string): Promise<void> {
|
||||||
const storage = getChromeStorage();
|
const storage = getChromeStorage();
|
||||||
|
|
||||||
if (storage) {
|
if (storage) {
|
||||||
@@ -44,7 +42,7 @@ async function setToken(token: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取谷歌缓存
|
* * 获取谷歌缓存
|
||||||
*/
|
*/
|
||||||
function getChromeStorage(): chrome.storage.StorageArea | null {
|
function getChromeStorage(): chrome.storage.StorageArea | null {
|
||||||
if (typeof chrome === 'undefined' || !chrome.storage?.local) {
|
if (typeof chrome === 'undefined' || !chrome.storage?.local) {
|
||||||
|
|||||||
54
src/shared/message.ts
Normal file
54
src/shared/message.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
export type MessageAction =
|
||||||
|
/** 获取当前爬取任务的状态*/
|
||||||
|
| 'GET_CRAWL_STATE'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅供 content script(爬取窗口页内悬浮面板)拉取状态:
|
||||||
|
* - 只在“当前 tab == 爬取 tab”时返回状态,其它 tab 返回 null。
|
||||||
|
* 中文备注:用于悬浮面板初始化时拿到任务快照。
|
||||||
|
*/
|
||||||
|
| 'GET_CRAWL_STATE_FOR_TAB'
|
||||||
|
|
||||||
|
/** 启动一个新的爬取任务 */
|
||||||
|
| 'START_CRAWL'
|
||||||
|
|
||||||
|
/** 彻底取消并停止当前的爬取任务 */
|
||||||
|
| 'CANCEL_CRAWL'
|
||||||
|
|
||||||
|
/** 恢复之前被暂停或因中断而停止的爬取任务 */
|
||||||
|
| 'RESUME_CRAWL'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消自动关闭(旧悬浮面板按钮会发这个指令)。
|
||||||
|
* 中文备注:当前版本默认完成后会清理并关闭窗口;这里保留用于兼容,避免后台报“未知指令”。
|
||||||
|
*/
|
||||||
|
| 'CANCEL_AUTOCLOSE'
|
||||||
|
|
||||||
|
|
||||||
|
/** 忽略/关闭当前爬取任务的 UI 提示或通知(通常指任务结束后清理界面) */
|
||||||
|
| 'DISMISS_CRAWL';
|
||||||
|
|
||||||
|
interface BackgroundMessage<T = unknown> {
|
||||||
|
action: MessageAction;
|
||||||
|
payload?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackgroundResponse<T = unknown> {
|
||||||
|
ok: boolean;
|
||||||
|
data: T | null;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送消息给服务
|
||||||
|
*/
|
||||||
|
export function sendBackgroundMessage<T>(data: BackgroundMessage): Promise<BackgroundResponse<T>> {
|
||||||
|
if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) {
|
||||||
|
return Promise.resolve({ok: true, data: null});
|
||||||
|
}
|
||||||
|
|
||||||
|
return chrome.runtime.sendMessage(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//接受
|
||||||
98
src/shared/request.ts
Normal file
98
src/shared/request.ts
Normal 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
57
src/shared/store.ts
Normal 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
34
src/shared/tab.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
9
src/shared/time_format.ts
Normal file
9
src/shared/time_format.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* 秒格式化成 00:00
|
||||||
|
* @param totalSeconds
|
||||||
|
*/
|
||||||
|
export function formatSeconds(totalSeconds: number): string {
|
||||||
|
const minutes = Math.floor(totalSeconds / 60).toString().padStart(2, '0');
|
||||||
|
const seconds = (totalSeconds % 60).toString().padStart(2, '0');
|
||||||
|
return `${minutes}:${seconds}`;
|
||||||
|
}
|
||||||
@@ -1,45 +1,55 @@
|
|||||||
/**
|
// 单个爬取步骤的执行状态。
|
||||||
* 单个爬取步骤的执行状态。
|
|
||||||
*/
|
|
||||||
export type CrawlStepStatus = 'pending' | 'running' | 'success' | 'failed';
|
export type CrawlStepStatus = 'pending' | 'running' | 'success' | 'failed';
|
||||||
|
|
||||||
/**
|
// 整体爬取任务状态。
|
||||||
* 整体爬取任务状态。
|
export type CrawlTaskStatus = 'running' | 'paused' | 'completed' | 'failed' | 'canceled';
|
||||||
*/
|
|
||||||
export type CrawlTaskStatus = 'running' | 'completed' | 'failed' | 'canceled';
|
|
||||||
|
|
||||||
/**
|
// 时间轴中的单个爬取步骤进度。
|
||||||
* 时间轴中的单个爬取步骤进度。
|
|
||||||
*/
|
|
||||||
export interface CrawlProgressStep {
|
export interface CrawlProgressStep {
|
||||||
/** 步骤名称,用于展示给用户。 */
|
// 步骤名称,用于展示给用户。
|
||||||
name: string;
|
name: string;
|
||||||
/** 步骤唯一标识,对应平台配置 steps 中的 uniqueKey。 */
|
// 步骤唯一标识,对应平台配置 steps 中的 uniqueKey。
|
||||||
uniqueKey: string;
|
uniqueKey: string;
|
||||||
/** 当前步骤执行状态。 */
|
// 当前步骤执行状态。
|
||||||
status: CrawlStepStatus;
|
status: CrawlStepStatus;
|
||||||
/** 状态补充说明,如失败原因。 */
|
// 状态补充说明,如失败原因。
|
||||||
message?: string;
|
message?: string;
|
||||||
|
// 当前步骤抓取到的数据结果。
|
||||||
|
result?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 爬取暂停原因,通常由登录、验证码或页面不存在触发。
|
||||||
|
export interface CrawlPauseInfo {
|
||||||
|
// 暂停原因编码。
|
||||||
|
reason: 'reauth' | 'shield' | 'not_found' | 'page_not_ready' | 'window_closed';
|
||||||
|
// 展示给用户看的处理提示。
|
||||||
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 当前正在执行的爬取任务快照,供 popup 和 content script 同步展示。
|
* 当前正在执行的爬取任务快照,供 popup 和 content script 同步展示。
|
||||||
*/
|
*/
|
||||||
export interface CrawlTaskState {
|
export interface CrawlTaskState {
|
||||||
/** 任务唯一标识。 */
|
// 任务唯一标识。
|
||||||
id: string;
|
id: string;
|
||||||
/** 当前爬取平台 ID。 */
|
// 当前爬取平台 ID。
|
||||||
platformId: string;
|
platformId: string;
|
||||||
/** 当前爬取平台名称。 */
|
// 当前爬取平台名称。
|
||||||
platformName: string;
|
platformName: string;
|
||||||
/** 爬取窗口 ID,由 background 创建窗口后写入。 */
|
// 爬取窗口 ID,由 background 创建窗口后写入。
|
||||||
windowId?: number;
|
windowId?: number;
|
||||||
/** 任务开始时间戳。 */
|
// 爬取窗口内承载任务的 tab ID(用于只在扫描 tab 显示 overlay)。
|
||||||
|
tabId?: number;
|
||||||
|
// 终态时自动关窗的截止时间戳(ms)。null 表示保持打开;undefined 表示未启用。
|
||||||
|
autocloseAt?: number | null;
|
||||||
|
// 任务开始时间戳。
|
||||||
startedAt: number;
|
startedAt: number;
|
||||||
/** 当前任务状态。 */
|
// 当前任务状态。
|
||||||
status: CrawlTaskStatus;
|
status: CrawlTaskStatus;
|
||||||
/** 当前执行到的步骤下标。 */
|
// 暂停信息;仅 status 为 paused 时存在。
|
||||||
|
pause?: CrawlPauseInfo;
|
||||||
|
// 当前执行到的步骤下标。
|
||||||
currentStepIndex: number;
|
currentStepIndex: number;
|
||||||
/** 平台 steps 映射出的时间轴进度。 */
|
// 平台 steps 映射出的时间轴进度。
|
||||||
steps: CrawlProgressStep[];
|
steps: CrawlProgressStep[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export type {
|
export type {
|
||||||
|
CrawlPauseInfo,
|
||||||
CrawlProgressStep,
|
CrawlProgressStep,
|
||||||
CrawlStepStatus,
|
CrawlStepStatus,
|
||||||
CrawlTaskState,
|
CrawlTaskState,
|
||||||
|
|||||||
@@ -1,100 +1,76 @@
|
|||||||
/**
|
// 字段采集类型:0 普通元素(默认),1 列表,2 表格(带分页)。
|
||||||
* 字段采集类型:0 普通元素(默认),1 列表,2 表格(带分页)。
|
|
||||||
*/
|
|
||||||
export type PlatformFieldType = 0 | 1 | 2;
|
export type PlatformFieldType = 0 | 1 | 2;
|
||||||
|
|
||||||
/**
|
// 条件点击配置,用于进入某个页面或采集某个字段前按顺序点击页面元素。
|
||||||
* 条件点击配置,用于进入某个页面或采集某个字段前按顺序点击页面元素。
|
|
||||||
*/
|
|
||||||
export interface PlatformClickCondition {
|
export interface PlatformClickCondition {
|
||||||
/** 需要点击的元素选择器列表,会按数组顺序依次执行。 */
|
// 需要点击的元素选择器列表,会按数组顺序依次执行。
|
||||||
list: string[];
|
list: string[];
|
||||||
/** 点击后的等待时间,单位毫秒。 */
|
// 点击后的等待时间,单位毫秒。
|
||||||
time: number;
|
time: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// 分页配置,用于列表或表格字段存在翻页时控制下一页采集。
|
||||||
* 分页配置,用于列表或表格字段存在翻页时控制下一页采集。
|
|
||||||
*/
|
|
||||||
export interface PlatformPaginationConfig {
|
export interface PlatformPaginationConfig {
|
||||||
/** 下一页按钮的 CSS 选择器。 */
|
// 下一页按钮的 CSS 选择器。
|
||||||
nextBtn: string;
|
nextBtn: string;
|
||||||
/** 最多采集页数,避免无限翻页。 */
|
// 最多采集页数,避免无限翻页。
|
||||||
maxPage?: number;
|
maxPage?: number;
|
||||||
/** 每次翻页后的等待时间,单位毫秒。 */
|
// 每次翻页后的等待时间,单位毫秒。
|
||||||
delay?: number;
|
delay?: number;
|
||||||
/** 下一页按钮不可用时的 class 名称。 */
|
// 下一页按钮不可用时的 class 名称。
|
||||||
disabledClass?: string;
|
disabledClass?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// 表格分段配置,用于兼容一个数据块由多个 table 或多个 table 片段组成的情况。
|
||||||
* 表格分段配置,用于兼容一个数据块由多个 table 或多个 table 片段组成的情况。
|
|
||||||
*/
|
|
||||||
export interface PlatformTablePartConfig {
|
export interface PlatformTablePartConfig {
|
||||||
/** 当前 table 或表格片段的名称。 */
|
|
||||||
label: string;
|
|
||||||
/** 当前 table 或表格片段的兼容名称,兼容 message.js 中的 name 写法。 */
|
|
||||||
name?: string;
|
name?: string;
|
||||||
/** 当前 table 或表格片段的 CSS 选择器。 */
|
|
||||||
className: string;
|
|
||||||
/** 当前 table 或表格片段的兼容选择器,兼容 message.js 中的 select 写法。 */
|
|
||||||
select?: string;
|
select?: string;
|
||||||
/** 行元素选择器,不填时由采集逻辑使用默认行选择器。 */
|
|
||||||
rowSelector?: string;
|
|
||||||
/** 当前 table 或表格片段下需要采集的字段。 */
|
|
||||||
keys?: PlatformFieldConfig[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// 页面字段配置,描述一个普通元素、列表元素或表格元素如何从 DOM 中提取数据。
|
||||||
* 页面字段配置,描述一个普通元素、列表元素或表格元素如何从 DOM 中提取数据。
|
|
||||||
*/
|
|
||||||
export interface PlatformFieldConfig {
|
export interface PlatformFieldConfig {
|
||||||
/** 字段显示名,也是最终打印数据中的键名。 */
|
// 字段显示名,也是最终打印数据中的键名。
|
||||||
label: string;
|
label: string;
|
||||||
/** 字段对应的 CSS 选择器。 */
|
// 字段对应的 CSS 选择器。
|
||||||
className: string;
|
className: string;
|
||||||
/** 需要提取的属性名;不填时默认提取文本,图片和链接会自动取 src/href。 */
|
// 需要提取的属性名;不填时默认提取文本,图片和链接会自动取 src/href。
|
||||||
attr?: string;
|
attr?: string;
|
||||||
/** 表格字段所属的表格分段名称,用于横向拼接多 table 行数据。 */
|
// 表格字段所属的表格分段名称,用于横向拼接多 table 行数据。
|
||||||
part?: string;
|
part?: string;
|
||||||
/** 字段类型:0 普通元素(默认),1 列表,2 表格。 */
|
// 字段类型:0 普通元素(默认),1 列表,2 表格。
|
||||||
type?: PlatformFieldType;
|
type?: PlatformFieldType;
|
||||||
/** 进入该字段采集前需要执行的点击条件。 */
|
// 进入该字段采集前需要执行的点击条件。
|
||||||
condition?: PlatformClickCondition;
|
condition?: PlatformClickCondition;
|
||||||
/** 子元素字段;普通元素下表示嵌套键值,列表或表格下表示每项/每行的字段。 */
|
// 子元素字段;普通元素下表示嵌套键值,列表或表格下表示每项/每行的字段。
|
||||||
keys?: PlatformFieldConfig[];
|
keys?: PlatformFieldConfig[];
|
||||||
/** 表格专用配置,用于多个 table 或分段 table 的组合采集。 */
|
// 表格专用配置,用于多个 table 或分段 table 的组合采集。
|
||||||
tableParts?: PlatformTablePartConfig[];
|
tableParts?: PlatformTablePartConfig[];
|
||||||
/** 分页配置,常用于列表和表格字段。 */
|
// 分页配置,常用于列表和表格字段。
|
||||||
pagination?: PlatformPaginationConfig;
|
pagination?: PlatformPaginationConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// 单个抓取页面步骤配置,描述页面地址、可用性检查和需要采集的字段。
|
||||||
* 单个抓取页面步骤配置,描述页面地址、可用性检查和需要采集的字段。
|
|
||||||
*/
|
|
||||||
export interface PlatformStepConfig {
|
export interface PlatformStepConfig {
|
||||||
/** 步骤显示名,用于进度展示。 */
|
// 步骤显示名,用于进度展示。
|
||||||
name: string;
|
name: string;
|
||||||
/** 步骤唯一标识,用于状态记录和结果归类。 */
|
// 步骤唯一标识,用于状态记录和结果归类。
|
||||||
uniqueKey: string;
|
uniqueKey: string;
|
||||||
/** 当前步骤需要打开或跳转到的页面地址。 */
|
// 当前步骤需要打开或跳转到的页面地址。
|
||||||
url: string;
|
url: string;
|
||||||
/** 判断页面 DOM 是否加载完成的 CSS 选择器。 */
|
// 判断页面 DOM 是否加载完成的 CSS 选择器。
|
||||||
checkSelector: string;
|
checkSelector: string;
|
||||||
/** 当前页面需要采集的字段列表。 */
|
// 当前页面需要采集的字段列表。
|
||||||
fields: PlatformFieldConfig[];
|
fields: PlatformFieldConfig[];
|
||||||
/** 进入该步骤前需要执行的点击条件。 */
|
// 进入该步骤前需要执行的点击条件。
|
||||||
condition?: PlatformClickCondition;
|
condition?: PlatformClickCondition;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// 平台抓取配置,描述一个商家后台平台的入口地址和页面抓取顺序。
|
||||||
* 平台抓取配置,描述一个商家后台平台的入口地址和页面抓取顺序。
|
|
||||||
*/
|
|
||||||
export interface PlatformConfig {
|
export interface PlatformConfig {
|
||||||
/** 平台唯一标识,用于 popup 选择和后台任务定位。 */
|
// 平台唯一标识,用于 popup 选择和后台任务定位。
|
||||||
id: string;
|
id: string;
|
||||||
/** 平台显示名称。 */
|
// 平台显示名称。
|
||||||
name: string;
|
name: string;
|
||||||
/** 当前平台的页面抓取顺序。 */
|
// 当前平台的页面抓取顺序。
|
||||||
steps: PlatformStepConfig[];
|
steps: PlatformStepConfig[];
|
||||||
}
|
}
|
||||||
|
|||||||
52
step.md
52
step.md
@@ -1,52 +0,0 @@
|
|||||||
# 项目结构
|
|
||||||
```angular2html
|
|
||||||
src:.
|
|
||||||
├─assets # 静态资源目录
|
|
||||||
│ vite.svg # 这里的资源通常用于图标、Logo 或扩展程序内部引用的图片
|
|
||||||
│
|
|
||||||
├─background # 后台脚本 (Background Script / Service Worker)
|
|
||||||
│ index.ts # 扩展的“大脑”,常驻后台运行,处理事件监听、报文转发、存储管理等
|
|
||||||
│
|
|
||||||
├─config # 配置目录
|
|
||||||
│ platforms.ts # 自定义配置,各种平台(如不同网站、不同浏览器)的适配配置
|
|
||||||
│
|
|
||||||
├─content # 内容脚本 (Content Script)
|
|
||||||
│ │ App.vue # 注入到网页中的 UI 组件(通常用于在目标页面侧边栏或浮窗显示界面)
|
|
||||||
│ │ main.ts # 内容脚本的入口文件,负责将 Vue 组件挂载到宿主页面的 DOM 中
|
|
||||||
│ │
|
|
||||||
│ └─views # 内容脚本相关的子视图或组件
|
|
||||||
│
|
|
||||||
├─options # 选项页 (Options Page)
|
|
||||||
│ App.vue # 扩展设置页面的 UI(右键扩展图标点击“选项”打开的页面)
|
|
||||||
│ index.html # 选项页的 HTML 宿主文件
|
|
||||||
│ main.ts # 选项页的 Vue 入口文件
|
|
||||||
│
|
|
||||||
├─popup # 弹窗页 (Popup Page)
|
|
||||||
│ App.vue # 点击扩展图标时显示的弹出框 UI
|
|
||||||
│ index.html # 弹窗页的 HTML 宿主文件
|
|
||||||
│ main.ts # 弹窗页的 Vue 入口文件
|
|
||||||
│
|
|
||||||
├─shared # 共享代码库 (Shared)
|
|
||||||
│ # 存放被 background、content、popup 等多个模块共同引用的工具函数、常量、API封装等
|
|
||||||
│
|
|
||||||
└─types # 类型定义目录
|
|
||||||
index.ts # 存放全局的 TypeScript 接口(Interface)和类型(Type)定义
|
|
||||||
```
|
|
||||||
|
|
||||||
# 开发步骤
|
|
||||||
1.在popup模块中的App.vue中用tailwindcss编写,点击扩展图标时出现的弹窗,逻辑如下
|
|
||||||
- 在未登录情况下,即storage中token字段是否存在,如果不存在,弹窗内容只用显示扩展名字、描述、请登录按钮,底部扩展版本
|
|
||||||
- 当点击登录按钮后,先模拟登录,写死token,之后ui如下
|
|
||||||
- 显示扩展名字、描述、一个平台选择框(通过读取config/platforms.ts)的内容for循环显示平台、扫描按钮、最底部Row(退出按钮,扩展版本号)
|
|
||||||
- 注意:token的存储和获取逻辑放到/shared/auth.ts中去,如果涉及到接口和枚举的定义,请判断是否是全局类型
|
|
||||||
- 如果是,该类型写到一个新文件中,并放到types/下,如果不是,放到当前模块的types/目录下(如果没用,新建)
|
|
||||||
|
|
||||||
2.前提:当1完成后,点击popup的立即爬取已经可以打开一个新的窗口了
|
|
||||||
- 在所有网页(包括新打开的窗口和所有网页)的右下角都放一个圆形正计时(表示正在爬取中)
|
|
||||||
- 点击圆形正计时时,出现一个popup,内容如下
|
|
||||||
- 以时间轴的形式,表示当前爬取进度,即:根据platforms.ts中的steps
|
|
||||||
- 同时点击扩展的popup里的内容,也变得和上面的时间轴内容一致,显示爬取进度,隐藏立即爬取等按钮,
|
|
||||||
|
|
||||||
3.前提:1和2都已完成,ui和交互操作上ok
|
|
||||||
- 开始爬取网页中的数据,查看message.js内容,吧里面的爬取方法都提取出来放到background/domScraper.ts中去,
|
|
||||||
- 基于2,每次根据steps打开一个新网页后,根据它的fields数组字段,调用domScraper中的方法,来提取数据,并打印到控制台即可
|
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
"@crxjs/vite-plugin/client",
|
"@crxjs/vite-plugin/client",
|
||||||
"chrome"
|
"chrome"
|
||||||
],
|
],
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": false,
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"strictNullChecks": true,
|
"strictNullChecks": true,
|
||||||
|
|||||||
@@ -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/crawl.ts","./src/background/service/lifecycle.ts","./src/background/service/state.ts","./src/background/service/tab.ts","./src/config/platforms.ts","./src/content/app.vue","./src/content/main.ts","./src/options/app.vue","./src/options/main.ts","./src/popup/app.vue","./src/popup/main.ts","./src/shared/auth.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"}
|
||||||
@@ -3,7 +3,7 @@ import {crx} from '@crxjs/vite-plugin'
|
|||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
import {defineConfig} from 'vite'
|
import {defineConfig} from 'vite'
|
||||||
import manifest from './manifest.config.ts'
|
import manifest from './manifest.config'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
resolve: {
|
resolve: {
|
||||||
|
|||||||
Reference in New Issue
Block a user