Compare commits
331 Commits
ef0e0df0ac
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bca1335dc6 | ||
|
|
ba48bbc6af | ||
|
|
cbde22ec79 | ||
|
|
69784b8d32 | ||
|
|
8543dad514 | ||
|
|
8a9126e626 | ||
|
|
d8ca8de537 | ||
|
|
aab4ce942c | ||
|
|
0ecf5cd45a | ||
|
|
021b0c618b | ||
|
|
f44110fae5 | ||
|
|
ac19637ad6 | ||
|
|
8672668c33 | ||
|
|
17236265c6 | ||
|
|
c9efdf8b0e | ||
|
|
c3d43aa26a | ||
|
|
b0462df3c1 | ||
|
|
967eedcad7 | ||
|
|
98e0e54309 | ||
|
|
eeb27e21ed | ||
|
|
c85e5b278f | ||
|
|
8ab647c419 | ||
|
|
95ccb00d25 | ||
|
|
ee9cf5f8c6 | ||
|
|
e98a1a1207 | ||
|
|
ea37d82d73 | ||
|
|
9835831415 | ||
|
|
080625567b | ||
|
|
266c0f17c1 | ||
|
|
225d13fb6e | ||
|
|
2ed1250604 | ||
|
|
ca4a2cd07a | ||
|
|
73ac9187a6 | ||
|
|
7503e3fa8b | ||
|
|
532438faba | ||
|
|
a1376b51b0 | ||
|
|
1e087c1aae | ||
|
|
2e2de02476 | ||
|
|
aeb4e1710c | ||
|
|
b4b80cb572 | ||
|
|
70e902e7ea | ||
|
|
14cebf22c3 | ||
|
|
73d25fcbcb | ||
|
|
01ecde45c5 | ||
|
|
a2ebfc0b81 | ||
|
|
1dc2cac19a | ||
|
|
14db1e0cb1 | ||
|
|
01f4dbb83a | ||
|
|
0d63d6d75a | ||
|
|
fe692fcd1d | ||
|
|
79b177b345 | ||
|
|
18b0c2d211 | ||
|
|
e4e01c1686 | ||
|
|
648e7d2f14 | ||
|
|
0d78d63437 | ||
|
|
289b1c67ce | ||
|
|
5c990e651e | ||
|
|
52095eb992 | ||
|
|
a4e4745921 | ||
|
|
a2d0bd3c1c | ||
|
|
b9275bc174 | ||
|
|
683a3934fc | ||
|
|
e5d0b2d9ab | ||
|
|
b0514c3e9a | ||
|
|
d618a5abf0 | ||
|
|
8be12ca9bc | ||
|
|
5ca46b0a57 | ||
|
|
afeeb8327c | ||
|
|
a7d8f8be6c | ||
|
|
4d2da94692 | ||
|
|
8cd1a4e2b1 | ||
|
|
b746812e23 | ||
|
|
3fbbf87c10 | ||
|
|
b0e97f21a4 | ||
|
|
a00ff1804c | ||
|
|
c3810fdb7b | ||
|
|
203937335d | ||
|
|
b16c63225f | ||
|
|
710b953120 | ||
|
|
45a45dc0fe | ||
|
|
3ca569ed1e | ||
|
|
c315bb350c | ||
|
|
111f30d150 | ||
|
|
6d398b66bc | ||
|
|
f46c231323 | ||
|
|
934919c699 | ||
|
|
12d84a22e4 | ||
|
|
9d4810eace | ||
|
|
dc457aff9e | ||
|
|
3ce66a59ee | ||
|
|
578847d596 | ||
|
|
147b324658 | ||
|
|
6416c14f20 | ||
|
|
e47c87f3a3 | ||
|
|
add6e6aeb5 | ||
|
|
1ecce13814 | ||
|
|
7968cef8c3 | ||
|
|
7c2b154c20 | ||
|
|
34148b5d8a | ||
|
|
7ba916f00c | ||
|
|
79e408333d | ||
|
|
af4f4a9ed4 | ||
|
|
74a7cd22b0 | ||
|
|
aece8123c3 | ||
|
|
e1cca219b8 | ||
|
|
70658041a5 | ||
|
|
4419c5bacd | ||
|
|
668226a99d | ||
|
|
4eec0dd5a4 | ||
|
|
b315d11c28 | ||
|
|
8e60f50616 | ||
|
|
72faf0433a | ||
|
|
f85e3be40e | ||
|
|
3616d64735 | ||
|
|
7f9116f12a | ||
|
|
01bae23df5 | ||
|
|
11f0d2c745 | ||
|
|
b073bbe97d | ||
|
|
955a6439a0 | ||
|
|
c6ecfb5b77 | ||
|
|
172917ac41 | ||
|
|
ad2bfcd2f5 | ||
|
|
a5f6898d06 | ||
|
|
83bd5839d0 | ||
|
|
83bc90ecf2 | ||
|
|
6ea3a960e7 | ||
|
|
cbfc29e953 | ||
|
|
fd2013b448 | ||
|
|
b66465aa6a | ||
|
|
a684fde472 | ||
|
|
fbd1a4dbea | ||
|
|
824fa7b76c | ||
|
|
be8a8492a9 | ||
|
|
b429721070 | ||
|
|
b352eea21d | ||
|
|
73d1dff21d | ||
|
|
51bf0aaa8c | ||
|
|
d7d1021ec3 | ||
|
|
28a752265c | ||
|
|
735234f7bc | ||
|
|
1d9d138423 | ||
|
|
8c5edc6ad6 | ||
|
|
e09ce6d13b | ||
|
|
ee6fb5d33a | ||
|
|
46e55fa623 | ||
|
|
cd2b437be5 | ||
|
|
a92d1af4f2 | ||
|
|
a504e7a13b | ||
|
|
1be28f10e9 | ||
|
|
325687d532 | ||
|
|
9c16c9c589 | ||
|
|
9760d1171c | ||
|
|
8711b205cf | ||
|
|
d42ed57119 | ||
|
|
e316adca1f | ||
|
|
18208f691c | ||
|
|
0457fbfac0 | ||
|
|
c06a4d0e06 | ||
|
|
58df800685 | ||
|
|
f21934e55b | ||
|
|
7c198f7d6f | ||
|
|
515135c120 | ||
|
|
ec4d1b2567 | ||
|
|
42bdd59c71 | ||
|
|
4ff4d8cfdc | ||
|
|
8be85fd332 | ||
|
|
e4bf104ae2 | ||
|
|
b96b136fa3 | ||
|
|
5105bf7049 | ||
|
|
02ddfc7a76 | ||
|
|
08997e9e20 | ||
|
|
8defef904f | ||
|
|
9146d17625 | ||
|
|
13c3a04a48 | ||
|
|
1aca3d6152 | ||
|
|
60a6494260 | ||
|
|
052bb31aea | ||
|
|
995e2ee65a | ||
|
|
225b525ba1 | ||
|
|
7f83bceba1 | ||
|
|
e1543416e3 | ||
|
|
1eb622d33f | ||
|
|
0b57aea5f6 | ||
|
|
5d8ee8fe79 | ||
|
|
a04c2f9a19 | ||
|
|
6f1b8e46f5 | ||
|
|
c352c34501 | ||
|
|
f9b2d2da25 | ||
|
|
5c9671df44 | ||
|
|
26a920a1cc | ||
|
|
8a8d3c5cd9 | ||
|
|
e695fd5184 | ||
|
|
d272afae1c | ||
|
|
0caf62c3d2 | ||
|
|
03e697f5d3 | ||
|
|
326f78705d | ||
|
|
c502afb588 | ||
|
|
afa8e44909 | ||
|
|
3fada5d198 | ||
|
|
72fdb1841c | ||
|
|
c0fdea6570 | ||
|
|
34bc980eea | ||
|
|
0391cb223f | ||
|
|
2dab555ca5 | ||
|
|
b25111e4b4 | ||
|
|
d537924aa9 | ||
|
|
2f2db4986e | ||
|
|
dc6ecc1edf | ||
|
|
6f970b3783 | ||
|
|
95d5c82474 | ||
|
|
87507708ce | ||
|
|
f6e83352d7 | ||
|
|
c6ae7ca170 | ||
|
|
46f46039ea | ||
|
|
6894f9cc57 | ||
|
|
8c4a6cd12a | ||
|
|
524d8763ce | ||
|
|
236b73755c | ||
|
|
1e845a1510 | ||
|
|
0341838d19 | ||
|
|
b04b05e4da | ||
|
|
d691e53886 | ||
|
|
ea35273597 | ||
|
|
72c8167472 | ||
|
|
dabb278170 | ||
|
|
9b1138c83e | ||
|
|
7b12cebb45 | ||
|
|
dc5e23892f | ||
|
|
9d75d673f8 | ||
|
|
4f8cbc3e38 | ||
|
|
8d97f5318c | ||
|
|
3ffbb2b004 | ||
|
|
34ed15a9bd | ||
|
|
110f407ae2 | ||
|
|
4b6295dd44 | ||
|
|
8db9f3a408 | ||
|
|
01116d5607 | ||
|
|
2ae720ae09 | ||
|
|
07904a29aa | ||
|
|
f87c37528b | ||
|
|
8d683a791d | ||
|
|
71b09a89de | ||
|
|
d5764f3b50 | ||
|
|
ac07416352 | ||
|
|
4e5ed76cb6 | ||
|
|
ecc8b6ce6c | ||
| 9ad6e037dc | |||
|
|
4a58b366b6 | ||
|
|
bbc833f2c9 | ||
|
|
325c2f7c82 | ||
|
|
728eea4b83 | ||
|
|
e53eaf738e | ||
|
|
ad2bb0a6ea | ||
|
|
7589b4e418 | ||
|
|
b400b494ad | ||
|
|
9a59dac6d0 | ||
|
|
03a85d5523 | ||
|
|
33186911f3 | ||
|
|
144d03965e | ||
|
|
e1cd176ab2 | ||
|
|
2e30821e0f | ||
|
|
7151f6b5e0 | ||
|
|
3149d80456 | ||
|
|
62315cee6c | ||
|
|
2f67b376ee | ||
|
|
489e7e8269 | ||
|
|
1b02160b20 | ||
|
|
d8d5b56c13 | ||
|
|
486e690204 | ||
|
|
380dd9f206 | ||
|
|
fba85ca365 | ||
|
|
99236c9feb | ||
|
|
0cad577d7c | ||
|
|
274d294b2e | ||
|
|
f63ae84853 | ||
|
|
4b50542779 | ||
|
|
20bd41008c | ||
|
|
8b490d3dd4 | ||
|
|
99e94a5e57 | ||
|
|
949a5b42ce | ||
|
|
1fdef3c67f | ||
|
|
a0624221de | ||
|
|
63043c56d4 | ||
|
|
6ed033fff2 | ||
|
|
785d7563f9 | ||
|
|
b6dd1d7931 | ||
|
|
bf802c1019 | ||
|
|
d3dd4898a4 | ||
|
|
1c1acbbe7d | ||
|
|
eeeed6d7b1 | ||
|
|
9cbede6e09 | ||
|
|
b8f88ca06e | ||
|
|
23e0705a99 | ||
|
|
a07b9d7fb2 | ||
|
|
efbff69a91 | ||
|
|
e962990ca0 | ||
|
|
e4a5f5acc0 | ||
|
|
5c7f1187c0 | ||
|
|
0aad7b3faf | ||
|
|
28d410f38f | ||
|
|
fe8c6b94ae | ||
|
|
e09f6d4a80 | ||
|
|
085e8f2f84 | ||
|
|
2952f3bc95 | ||
|
|
5dd7a92cf5 | ||
|
|
ea212c8931 | ||
|
|
14269d9513 | ||
|
|
8106a8ab7e | ||
|
|
bbca159684 | ||
|
|
6434ec3e00 | ||
|
|
8cfaab49fb | ||
|
|
2cd0fcff93 | ||
|
|
7ffe5279a3 | ||
|
|
12318f8770 | ||
|
|
e8bec339a7 | ||
|
|
e9684a81aa | ||
|
|
903c591181 | ||
|
|
923dfd2495 | ||
|
|
0d83d56a05 | ||
|
|
ef16abfbb9 | ||
|
|
cfbd5d6e84 | ||
|
|
497d65d41d | ||
|
|
c2e45128ee | ||
|
|
c80e1889ef | ||
|
|
7f9a6bd9cb | ||
|
|
32f4705491 | ||
|
|
bb06bc8f5a | ||
|
|
d39d310544 | ||
|
|
7efb86ed3a | ||
|
|
6791869e0c | ||
|
|
8a64a6cd9b |
111
.gitignore
vendored
111
.gitignore
vendored
@@ -1,41 +1,112 @@
|
||||
# frontend-vue dependencies
|
||||
# ===== frontend-vue =====
|
||||
frontend-vue/node_modules/
|
||||
|
||||
# frontend-vue build output
|
||||
frontend-vue/dist/
|
||||
frontend-vue/new_web_source
|
||||
|
||||
# generated type declarations
|
||||
frontend-vue/auto-imports.d.ts
|
||||
frontend-vue/components.d.ts
|
||||
|
||||
# logs
|
||||
frontend-vue/npm-debug.log*
|
||||
frontend-vue/yarn-debug.log*
|
||||
frontend-vue/yarn-error.log*
|
||||
frontend-vue/pnpm-debug.log*
|
||||
|
||||
# backend-java build output
|
||||
# ===== backend-java =====
|
||||
backend-java/target/
|
||||
|
||||
# backend-java runtime data
|
||||
backend-java/data/
|
||||
backend-java/*.log
|
||||
backend-java/logs/
|
||||
|
||||
# backend-java local config
|
||||
backend-java/src/main/resources/application-local.yml
|
||||
|
||||
# backend-java IDE files
|
||||
backend-java/.idea/
|
||||
backend-java/*.iml
|
||||
|
||||
# desktop app
|
||||
# ===== backend / app =====
|
||||
__pycache__/
|
||||
backend/tmp/
|
||||
backend/__pycache__
|
||||
app/__pycache__
|
||||
app/new_web_source
|
||||
app/user_data/
|
||||
|
||||
# ===== logs =====
|
||||
logs/
|
||||
aiimage-backend.log*
|
||||
|
||||
# ===== Python =====
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.pyo
|
||||
.Python
|
||||
*.egg-info/
|
||||
*.egg
|
||||
MANIFEST
|
||||
.installed.cfg
|
||||
|
||||
# Build / packaging
|
||||
app.zip
|
||||
build/
|
||||
dist/
|
||||
develop-eggs/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
|
||||
# Virtual environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
.python-version
|
||||
|
||||
# Testing
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
htmlcov/
|
||||
|
||||
# Type checking
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
.pyre/
|
||||
.pytype/
|
||||
|
||||
# Jupyter
|
||||
.ipynb_checkpoints
|
||||
*.ipynb_checkpoints/
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# ===== Database =====
|
||||
*.db
|
||||
*.sqlite3
|
||||
|
||||
# ===== Misc =====
|
||||
desktop/
|
||||
ERP-Demo/
|
||||
xlsx/
|
||||
app/__pycache__
|
||||
backend/__pycache__
|
||||
app/assets/
|
||||
app/new_web_source
|
||||
app/user_data/
|
||||
.omx/
|
||||
.codex
|
||||
.rtk
|
||||
OPS_REDIS_MYSQL_OPTIMIZATION_NOTES.md
|
||||
架构.md
|
||||
*ts.%
|
||||
.omc
|
||||
|
||||
19
app/.env
19
app/.env
@@ -1,19 +0,0 @@
|
||||
base_url=http://8.136.19.173:15124
|
||||
workflow_id=7608812635877900322
|
||||
mysql_host=8.136.19.173
|
||||
mysql_user=aiimage
|
||||
|
||||
proxy_url=https://api.jikip.com/ip-get?num=1&minute=1&format=json&area=all&protocol=1&mode=2&key=t24g6gi44ubufd8
|
||||
proxy_mode=2
|
||||
|
||||
zn_company=rongchuang123
|
||||
zn_username=%E8%87%AA%E5%8A%A8%E5%8C%96_Robot
|
||||
|
||||
client_name=ShuFuAI
|
||||
|
||||
|
||||
# java_api_base=http://47.111.163.154:18080
|
||||
java_api_base=http://127.0.0.1:18080
|
||||
# java_api_base=http://8.136.19.173:18080
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
base_url=http://8.136.19.173:15124
|
||||
workflow_id=7608812635877900322
|
||||
mysql_host=8.136.19.173
|
||||
mysql_user=aiimage
|
||||
|
||||
proxy_url=https://api.jikip.com/ip-get?num=1&minute=1&format=json&area=all&protocol=1&mode=2&key=t24g6gi44ubufd8
|
||||
proxy_mode=2
|
||||
|
||||
client_name=ShuFuAI
|
||||
|
||||
|
||||
java_api_base=http://127.0.0.1:18080
|
||||
# java_api_base=http://8.136.19.173:18080
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,73 +0,0 @@
|
||||
import argparse
|
||||
import base64
|
||||
import re
|
||||
import time
|
||||
import alibabacloud_oss_v2 as oss
|
||||
import requests
|
||||
|
||||
from config import region, endpoint, bucket, file_url_pre, bucket_path
|
||||
|
||||
|
||||
def upload_file(file_content: bytes, key: str):
|
||||
credentials_provider = oss.credentials.EnvironmentVariableCredentialsProvider()
|
||||
cfg = oss.config.load_default()
|
||||
cfg.credentials_provider = credentials_provider
|
||||
cfg.region = region
|
||||
cfg.endpoint = endpoint
|
||||
cfg.retry_max_attempts = 3
|
||||
client = oss.Client(cfg)
|
||||
|
||||
result = client.put_object(
|
||||
oss.PutObjectRequest(
|
||||
bucket=bucket, # 存储空间名称
|
||||
key=key, # 对象名称
|
||||
body=file_content # 读取文件内容
|
||||
)
|
||||
)
|
||||
# print(result)
|
||||
return file_url_pre + key
|
||||
|
||||
|
||||
def upload_data_url(data_url: str, prefix: str = "history", key_hint: str = "") -> str:
|
||||
"""
|
||||
将 base64 data URL 上传到 OSS,返回图片链接
|
||||
data_url: data:image/png;base64,xxxx 或 data:image/jpeg;base64,xxxx
|
||||
prefix: OSS key 前缀
|
||||
key_hint: 可选后缀避免重名,如 "_0", "_1"
|
||||
"""
|
||||
match = re.match(r'data:image/(\w+);base64,(.+)', data_url)
|
||||
if not match:
|
||||
raise ValueError('无效的 data URL 格式')
|
||||
ext = 'png' if match.group(1).lower() in ('png', 'webp') else 'jpg'
|
||||
file_content = base64.b64decode(match.group(2))
|
||||
ts = int(time.time() * 1000)
|
||||
key = f"{bucket_path}{prefix}/{ts}{key_hint}.{ext}"
|
||||
return upload_file(file_content, key)
|
||||
|
||||
|
||||
def upload_data_urls(data_urls: list, prefix: str = "history") -> list:
|
||||
"""批量上传 base64 图片到 OSS,返回图片链接列表"""
|
||||
urls = []
|
||||
ts = int(time.time() * 1000)
|
||||
for i, data_url in enumerate(data_urls or []):
|
||||
if not data_url or not isinstance(data_url, str):
|
||||
continue
|
||||
if not data_url.startswith("http"):
|
||||
match = re.match(r'data:image/(\w+);base64,(.+)', data_url)
|
||||
if not match:
|
||||
continue
|
||||
ext = 'png' if match.group(1).lower() in ('png', 'webp') else 'jpg'
|
||||
file_content = base64.b64decode(match.group(2))
|
||||
else:
|
||||
file_content = requests.get(data_url).content
|
||||
ext = "png"
|
||||
key = f"{bucket_path}{prefix}/{ts}_{i}.{ext}"
|
||||
urls.append(upload_file(file_content, key))
|
||||
return urls
|
||||
|
||||
|
||||
# 脚本入口,当文件被直接运行时调用main函数
|
||||
if __name__ == "__main__":
|
||||
with open("测试图片数据/IMG_2685.JPG", "rb") as f:
|
||||
file_content = f.read()
|
||||
upload_file(file_content,key=bucket_path+"test.png")
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,999 +0,0 @@
|
||||
import time
|
||||
import re
|
||||
import traceback
|
||||
from DrissionPage._pages.chromium_tab import ChromiumTab
|
||||
|
||||
from config import runing_task, runing_shop
|
||||
from datetime import datetime
|
||||
from amazon.del_brand import AmamzonBase, kill_process
|
||||
|
||||
# 导入 webview 用于前端通知
|
||||
try:
|
||||
import webview
|
||||
except ImportError:
|
||||
webview = None
|
||||
|
||||
class AmzoneApprove(AmamzonBase):
|
||||
|
||||
def SwitchPage(self):
|
||||
"""
|
||||
切换至 管理所有库存页面
|
||||
1、等待 //navigation-favorites-bar[@class="hydrated"] 出现
|
||||
"""
|
||||
navigation = self.tab.ele('xpath://navigation-favorites-bar[@class="hydrated"]')
|
||||
navigation.wait.displayed(raise_err=False)
|
||||
page_btn = navigation.sr('xpath://internal-fav-bar-links[@data-internal="navigation"]').sr(
|
||||
'xpath://a[@data-page-id="ezdpc-gui-inventory-mons"]')
|
||||
page_btn.wait.displayed(raise_err=False)
|
||||
page_btn.click(timeout=5)
|
||||
|
||||
self.tab.wait.doc_loaded()
|
||||
# 等待搜索框出现
|
||||
search_region = self.tab.ele('xpath://div[@id="searchBoxContainer"]//kat-input-group')
|
||||
search_region.wait.displayed(raise_err=False)
|
||||
|
||||
|
||||
def search(self,filter_type="ApprovalRequired"):
|
||||
sku_ls = []
|
||||
|
||||
load_ele = self.tab.eles("xpath://div[contains(@class,'Loader-module__loader')]",timeout=5)
|
||||
if len(load_ele) > 0:
|
||||
load_ele[0].wait.deleted(timeout=3, raise_err=False)
|
||||
time.sleep(0.5)
|
||||
|
||||
drop_down = self.tab.ele('xpath://div[contains(@class,"VolusListingStatusDropDown-module__verticalContainer")]//kat-dropdown')
|
||||
drop_down.wait.displayed(raise_err=False)
|
||||
drop_down.wait.enabled(raise_err=False)
|
||||
time.sleep(0.6)
|
||||
drop_down.click()
|
||||
# //kat-option[@value="SearchSuppressed"]
|
||||
xp = f'xpath://kat-option[@value="{filter_type}"]'
|
||||
print(f"正在寻找筛选条件 {filter_type},xpath: {xp}")
|
||||
approval_required = self.tab.eles(xp,timeout=5)
|
||||
if len(approval_required) == 0:
|
||||
print(f"【没有需要{filter_type}选项】没有需要{filter_type}的商品了")
|
||||
return sku_ls # "没有需要审批的商品了"
|
||||
else:
|
||||
approval_required = approval_required[0]
|
||||
approval_required.wait.displayed(raise_err=False)
|
||||
approval_required.click()
|
||||
|
||||
approval_required_text = approval_required.text
|
||||
print(f"已选择筛选条件: {approval_required_text}")
|
||||
|
||||
count = re.findall(r'\d+', approval_required_text)
|
||||
if count:
|
||||
count = int(count[0])
|
||||
print(f"待审批的商品数量: {count}")
|
||||
if count <= 0:
|
||||
print(f"没有需要{filter_type}的商品了")
|
||||
return sku_ls #"没有需要审批的商品了"
|
||||
asin = "B0C7KT6ZZN"
|
||||
search_input = self.tab.ele("xpath://kat-input[contains(@class,'SearchBox-module__searchInput')]").sr(
|
||||
'xpath://span[@class="container"]//input[@part="input"]')
|
||||
search_input.input(asin, clear=True)
|
||||
search_btn = self.tab.ele("xpath://kat-icon[@name='search']")
|
||||
search_btn.click()
|
||||
|
||||
for _ in range(3):
|
||||
# 等待加载完成
|
||||
load_ele = self.tab.ele("xpath://div[contains(@class,'Loader-module__loader')]")
|
||||
load_ele.wait.deleted(timeout=3, raise_err=False)
|
||||
time.sleep(0.5)
|
||||
search_input = self.tab.ele("xpath://kat-input[contains(@class,'SearchBox-module__searchInput')]").sr(
|
||||
'xpath://span[@class="container"]//input[@part="input"]')
|
||||
search_input.input(asin, clear=True)
|
||||
search_btn = self.tab.ele("xpath://kat-icon[@name='search']")
|
||||
search_btn.click()
|
||||
|
||||
load_ele = self.tab.ele("xpath://div[contains(@class,'Loader-module__loader')]")
|
||||
load_ele.wait.deleted(timeout=3, raise_err=False)
|
||||
time.sleep(0.5)
|
||||
|
||||
sku_ls = self.tab.eles("xpath://div[@data-sku]",timeout=3)
|
||||
if len(sku_ls) > 0:
|
||||
break
|
||||
approval_required.click()
|
||||
|
||||
return sku_ls
|
||||
|
||||
def wait_loaded(self):
|
||||
# 等待加载完成
|
||||
try:
|
||||
load_ele = self.tab.ele(
|
||||
'xpath://kat-panel[@data-testid="kat-panel-ActionPanelContent"]//div[contains(@class,"Loader-module__loader")]|//kat-panel[@data-testid="kat-panel-ActionPanelContent"]/div[@data-f1-component]//div[contains(@class,"==")]/div[contains(@class,"==")]/span',
|
||||
timeout=3)
|
||||
load_ele.wait.deleted(timeout=5, raise_err=False)
|
||||
except Exception as e:
|
||||
print("等待加载中消失出错", e)
|
||||
|
||||
def clear_tab(self):
|
||||
all_tab = self.browser.get_tabs(title="亚马逊")
|
||||
close_tab = []
|
||||
for tab in all_tab:
|
||||
tab_id = tab if isinstance(tab,str) else tab.tab_id
|
||||
if self.tab.tab_id == tab:
|
||||
continue
|
||||
close_tab.append(tab)
|
||||
print("需要关闭的标签页",close_tab)
|
||||
print("当前操作的tab_id",self.tab.tab_id)
|
||||
self.browser.close_tabs(close_tab)
|
||||
|
||||
def handle_repair_product(self,tab:ChromiumTab):
|
||||
def wait_loaded():
|
||||
# 等待加载完成
|
||||
try:
|
||||
load_ele = tab.ele(
|
||||
'xpath://div[@class="contentWrapper"]//div[contains(@class,"==")]/span[not(node())]',
|
||||
timeout=3)
|
||||
load_ele.wait.deleted(timeout=5, raise_err=False)
|
||||
except Exception as e:
|
||||
print("等待加载中消失出错", e)
|
||||
|
||||
tab.wait.doc_loaded(timeout=30)
|
||||
submit_btn_ls = tab.eles('xpath://div[@id="ahd-product-policies-table"]/div//div[@data-testid="nextStepsMetricWrapper"]//kat-button[@label="提交"]',timeout=10)
|
||||
if len(submit_btn_ls) == 0:
|
||||
print("未找到提交按钮,可能页面未加载完成或者页面结构发生变化")
|
||||
return
|
||||
# todo 遍历
|
||||
for index in range(len(submit_btn_ls)):
|
||||
if index > len(submit_btn_ls)-1:
|
||||
print("提交按钮数量发生变化,停止处理")
|
||||
break
|
||||
|
||||
submit_btn = submit_btn_ls[index]
|
||||
|
||||
# 关闭按钮
|
||||
close_x_btn = tab.eles('xpath://div[@class="flyoutPanelContent"]/span',timeout=5)
|
||||
if len(close_x_btn) > 0:
|
||||
close_x_btn[0].wait.displayed(timeout=5, raise_err=False)
|
||||
close_x_btn[0].click()
|
||||
|
||||
submit_btn.wait.enabled(timeout=5, raise_err=False)
|
||||
submit_btn.click()
|
||||
time.sleep(0.5)
|
||||
self.wait_loaded()
|
||||
|
||||
wait_loaded()
|
||||
|
||||
need_input = tab.eles('xpath://div[@class="contentWrapper"]//kat-input[@data-testid="kat-input-dew:ump_epr_resgitration_number_title"]',timeout=5)
|
||||
if len(need_input) > 0:
|
||||
print("需要输入注册号,不符合操作要求,跳过...")
|
||||
continue
|
||||
|
||||
target_title = tab.eles('xpath://div[@class="contentWrapper"]//section//h4[text()="警告和安全信息"]')
|
||||
if len(target_title) > 0:
|
||||
not_start = tab.eles('xpath://div[@class="contentWrapper"]//div[@aria-label="安全证明"]//kat-label[@text="未开始"]')
|
||||
if len(not_start) == 0:
|
||||
print("安全证明-不是未开始状态,不需要操作")
|
||||
continue
|
||||
|
||||
not_start.wait.displayed(timeout=5, raise_err=False)
|
||||
not_start.click()
|
||||
|
||||
wait_loaded()
|
||||
|
||||
check = tab.ele('xpath://div[@class="contentWrapper"]//kat-checkbox[@name="value"]')
|
||||
check.wait.displayed(timeout=5, raise_err=False)
|
||||
check.click()
|
||||
|
||||
save_btn = tab.ele('xpath://div[@class="contentWrapper"]//kat-button[@variant="primary"]')
|
||||
save_btn.wait.enabled(timeout=5, raise_err=False)
|
||||
save_btn.click()
|
||||
|
||||
wait_loaded()
|
||||
|
||||
close_btn = tab.ele('xpath://div[@class="contentWrapper"]//kat-button[@label="关闭"]',timeout=10)
|
||||
close_btn.wait.displayed(timeout=5, raise_err=False)
|
||||
close_btn.click()
|
||||
|
||||
save_btn_ls = tab.eles('xpath://div[@class="contentWrapper"]//kat-button[@variant="primary"]',timeout=5)
|
||||
if len(save_btn_ls) > 0:
|
||||
option_selection_ls = tab.eles('xpath://div[@class="contentWrapper"]//div[@role="option"]',timeout=5)
|
||||
for option in option_selection_ls:
|
||||
option.click()
|
||||
time.sleep(0.5)
|
||||
|
||||
save_btn_ls[0].wait.enabled(timeout=5, raise_err=False)
|
||||
save_btn_ls[0].click()
|
||||
|
||||
wait_loaded()
|
||||
|
||||
close_btn = tab.ele('xpath://div[@class="contentWrapper"]//kat-button[@label="关闭"]',timeout=10)
|
||||
close_btn.wait.displayed(timeout=5, raise_err=False)
|
||||
close_btn.wait.enabled(timeout=5, raise_err=False)
|
||||
close_btn.click()
|
||||
|
||||
submit_btn_ls = tab.eles('xpath://div[@id="ahd-product-policies-table"]/div//div[@data-testid="nextStepsMetricWrapper"]//kat-button[@label="提交"]',timeout=10)
|
||||
|
||||
print("修复商品信息处理完成!")
|
||||
tab.close()
|
||||
|
||||
def run_page_action(self):
|
||||
print("开始执行")
|
||||
num = 0
|
||||
retry_num = 0
|
||||
already_asin = set()
|
||||
|
||||
while retry_num < 3: # 最多重试3次
|
||||
# if num > 3: #测试
|
||||
# return
|
||||
# 等待加载完成
|
||||
try:
|
||||
load_ele = self.tab.eles("xpath://div[contains(@class,'Loader-module__loader')]",timeout=5)
|
||||
if len(load_ele) > 0:
|
||||
load_ele[0].wait.deleted(timeout=3, raise_err=False)
|
||||
time.sleep(0.5)
|
||||
|
||||
# 获取当前页码
|
||||
try:
|
||||
page_pamel = self.tab.eles('xpath://kat-pagination',timeout=5)
|
||||
if len(page_pamel) > 0:
|
||||
current_page = page_pamel[0].sr('xpath:.//ul[@class="pages"]//li[@aria-current="true"]').text
|
||||
|
||||
# 总页数
|
||||
total_page = page_pamel[0].sr.eles('xpath:.//ul[@class="pages"]//span[@class="page__inner"][last()]')
|
||||
if len(total_page) > 0:
|
||||
total_page = total_page[-1].text
|
||||
else:
|
||||
total_page = 0
|
||||
print(f"当前页码: {current_page} / 总页数: {total_page}")
|
||||
except Exception as e:
|
||||
print("获取页码失败", e)
|
||||
|
||||
|
||||
sku_ls = self.tab.eles("xpath://div[@data-sku]",timeout=10)
|
||||
print(f"获取到 {len(sku_ls)}")
|
||||
# for sku_ele in sku_ls[0:2]:
|
||||
for sku_ele in sku_ls:
|
||||
# solve_problem = sku_ele.eles('xpath:.//kat-link[@label="解决商品信息问题"]')
|
||||
|
||||
asin = sku_ele.ele('xpath:.//div[contains(@class,"JanusSplitBox-module__container")]//div[contains(@class,"JanusSplitBox-module__panel--") and contains(string(.),"ASIN")]/..//div[last()]').text
|
||||
print(f"ASIN {asin} 存在问题,正在点击解决...")
|
||||
if asin in already_asin:
|
||||
print(f"{asin} 已经处理过了,跳过")
|
||||
continue
|
||||
solve_problem = sku_ele.eles('xpath:.//kat-link[@label="解决商品信息问题"]|.//kat-link[@label="修复被禁止显示的商品"]|.//kat-link[@label="解决商品移除风险"]')
|
||||
if len(solve_problem) == 0:
|
||||
print(f"{asin},没有处理入口,不处理")
|
||||
yield (asin,"没有处理入口,不处理")
|
||||
already_asin.add(asin)
|
||||
continue
|
||||
|
||||
solve_problem[0].click()
|
||||
|
||||
action_panel = self.tab.ele('xpath://kat-panel-wrapper[@data-testid="kat-panel-wrapper-ActionPanelContent"]')
|
||||
action_panel.wait.displayed(timeout=5, raise_err=False)
|
||||
|
||||
self.wait_loaded()
|
||||
|
||||
try:
|
||||
# 如果存在“请求批准”按钮,则直接返回跳过
|
||||
request_approval_btn = self.tab.eles('xpath://kat-panel-wrapper[@data-testid="kat-panel-wrapper-ActionPanelContent"]//div[@data-testid="section-header" and contains(string(.),"移除")]',timeout=5)
|
||||
if len(request_approval_btn) > 0:
|
||||
print(f"ASIN {asin} 存在请求批准按钮,不需要处理,跳过...")
|
||||
panel_close_btn = self.tab.ele('xpath://kat-panel[@data-testid="kat-panel-ActionPanelContent"]',
|
||||
timeout=5).sr('xpath:.//button[@class="close"]')
|
||||
panel_close_btn.click()
|
||||
|
||||
action_panel.wait.deleted(timeout=3, raise_err=False)
|
||||
yield (asin,"请求批准")
|
||||
|
||||
# 无需采取任何操作
|
||||
not_operate = self.tab.eles('xpath://kat-alert[contains(@description,"如果您之前已提交更改,则这些更改当前正在处理中") and not(@dismissed)]',timeout=3)
|
||||
if len(not_operate) > 0:
|
||||
print(f"ASIN {asin} 无需采取任何操作,跳过...")
|
||||
yield (asin,"无需操作")
|
||||
already_asin.add(asin)
|
||||
continue
|
||||
|
||||
# 解决商品信息违规问题 按钮
|
||||
kat_box_ls = action_panel.eles('xpath:.//kat-box[@variant="white"]',timeout=5)
|
||||
# if len(kat_box_ls) == 0:
|
||||
# print(f"ASIN {asin} 的操作面板中未找到选项,跳过...")
|
||||
# continue
|
||||
print(f"ASIN {asin}需要处理的有:{len(kat_box_ls)}个问题.")
|
||||
# 解决商品信息违规问题 按钮,有多少个都要处理
|
||||
for i in range(len(kat_box_ls)):
|
||||
print(f"开始处理第{i}个问题")
|
||||
if len(kat_box_ls) <= i:
|
||||
print(f"ASIN {asin} 的操作面板中选项数量发生变化,停止处理...")
|
||||
break
|
||||
kat_box_ls[i].click()
|
||||
time.sleep(0.5)
|
||||
self.wait_loaded()
|
||||
|
||||
# TODO 增加 “出现警告和安全信息时候下滑” 的情况
|
||||
safe_handle_ls = self.tab.eles('xpath://kat-panel[@data-testid="kat-panel-ActionPanelContent"]//div[@aria-label="安全证明"]//kat-label[@text="未开始"]',
|
||||
timeout=5)
|
||||
if len(safe_handle_ls) > 0:
|
||||
print("存在安全证明,正在处理...")
|
||||
safe_handle_ls[0].click()
|
||||
time.sleep(0.5)
|
||||
self.wait_loaded()
|
||||
check_box = self.tab.ele('xpath://kat-panel[@data-testid="kat-panel-ActionPanelContent"]//kat-checkbox')
|
||||
check_box.click()
|
||||
else:
|
||||
problem_selection_ls = self.tab.eles(
|
||||
'xpath://kat-panel-wrapper[@data-testid="kat-panel-wrapper-ActionPanelContent"]//div[@data-testid="registry-list"]//div[@data-testid="registry"]',
|
||||
timeout=10)
|
||||
print("待选择数量",len(problem_selection_ls))
|
||||
for problem_selection in problem_selection_ls:
|
||||
problem_selection.click()
|
||||
print("点击选择完成")
|
||||
time.sleep(0.5)
|
||||
|
||||
# 点击保存
|
||||
confirm_btn = self.tab.ele('xpath://kat-panel-wrapper[@data-testid="kat-panel-wrapper-ActionPanelContent"]//kat-button[@variant="primary"]')
|
||||
confirm_btn.wait.displayed(timeout=3, raise_err=False)
|
||||
confirm_btn.click()
|
||||
|
||||
self.wait_loaded()
|
||||
|
||||
# 等待关闭按钮出现
|
||||
try:
|
||||
close_btn = self.tab.ele('xpath://kat-panel[@data-testid="kat-panel-ActionPanelContent"]//kat-button[@label="关闭"]',
|
||||
timeout=10)
|
||||
close_btn.click()
|
||||
except Exception as e:
|
||||
print("点击关闭按钮失败",e)
|
||||
|
||||
kat_box_ls = action_panel.eles('xpath:.//kat-box[@variant="white"]', timeout=5)
|
||||
|
||||
# 修复商品信息
|
||||
repair_product = action_panel.eles('xpath:.//kat-button[@class="action-button"]',timeout=5)
|
||||
if len(repair_product) > 0:
|
||||
print(f"ASIN {asin} 存在修复商品信息按钮,正在点击...")
|
||||
self.clear_tab()
|
||||
repair_product[0].click()
|
||||
time.sleep(1)
|
||||
# 获取最新的tab
|
||||
new_tab = self.browser.latest_tab
|
||||
if isinstance(new_tab, str):
|
||||
new_tab = self.browser.get_tab(id_or_num=new_tab)
|
||||
|
||||
self.handle_repair_product(new_tab)
|
||||
except Exception as e:
|
||||
print("【asin】:",asin,"处理失败",traceback.format_exc())
|
||||
yield (asin,"处理失败")
|
||||
already_asin.add(asin)
|
||||
continue
|
||||
|
||||
yield (asin,"处理完成")
|
||||
already_asin.add(asin)
|
||||
|
||||
try:
|
||||
# 全部操作完成,关闭
|
||||
panel_close_btn= self.tab.ele('xpath://kat-panel[@data-testid="kat-panel-ActionPanelContent"]',
|
||||
timeout=5).sr('xpath:.//button[@class="close"]')
|
||||
panel_close_btn.click()
|
||||
|
||||
action_panel.wait.deleted(timeout=3, raise_err=False)
|
||||
|
||||
except Exception as e:
|
||||
print("关闭操作面板失败", e)
|
||||
|
||||
# 判断是否存在需要翻页的情况
|
||||
page_pamel = self.tab.eles('xpath://kat-pagination',timeout=5)
|
||||
if len(page_pamel) == 0:
|
||||
break
|
||||
next_page_btn = page_pamel[0].sr('xpath:.//span[@part="pagination-nav-right"]')
|
||||
class_str = next_page_btn.attr('class')
|
||||
if "end" in class_str:
|
||||
break
|
||||
next_page_btn.click()
|
||||
num += 1
|
||||
print(f"【程序计算】正在翻页,已翻 {num} 页...")
|
||||
|
||||
already_asin = set()
|
||||
|
||||
except Exception as e:
|
||||
print("处理审批操作异常", e)
|
||||
traceback.print_exc()
|
||||
retry_num += 1
|
||||
self.tab.refresh()
|
||||
self.tab.wait.doc_loaded(raise_err=False,timeout=120)
|
||||
|
||||
|
||||
|
||||
|
||||
class ApproveTask:
|
||||
"""审批任务处理类:负责处理产品风险审批任务"""
|
||||
|
||||
country_info = {
|
||||
"DE": "德国",
|
||||
"FR": "法国",
|
||||
"ES": "西班牙",
|
||||
"IT": "意大利",
|
||||
"UK": "英国"
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def show_notification(message: str, message_type: str = "error"):
|
||||
"""显示 pywebview 顶层通知(5秒后自动消失)
|
||||
|
||||
Args:
|
||||
message: 通知消息
|
||||
message_type: 消息类型 (success/warning/error/info)
|
||||
"""
|
||||
if webview and webview.windows:
|
||||
try:
|
||||
# 转义单引号,防止 JavaScript 语法错误
|
||||
safe_message = message.replace("'", "\\'").replace('"', '\\"').replace('\n', '\\n')
|
||||
|
||||
# 设置通知样式颜色
|
||||
color_map = {
|
||||
'success': '#67C23A',
|
||||
'warning': '#E6A23C',
|
||||
'error': '#F56C6C',
|
||||
'info': '#909399'
|
||||
}
|
||||
bg_color = color_map.get(message_type, '#F56C6C')
|
||||
|
||||
# 构建前端通知的 JavaScript 代码 - 创建原生 HTML 通知
|
||||
js_code = f"""
|
||||
(function() {{
|
||||
// 移除已存在的通知
|
||||
var existingNotif = document.getElementById('pywebview-notification');
|
||||
if (existingNotif) {{
|
||||
existingNotif.remove();
|
||||
}}
|
||||
|
||||
// 创建通知容器
|
||||
var notif = document.createElement('div');
|
||||
notif.id = 'pywebview-notification';
|
||||
notif.style.cssText = 'position: fixed; top: 20px; left: 50%; transform: translateX(-50%); ' +
|
||||
'background: {bg_color}; color: white; padding: 12px 20px; border-radius: 4px; ' +
|
||||
'box-shadow: 0 2px 12px rgba(0,0,0,0.3); z-index: 99999; font-size: 14px; ' +
|
||||
'max-width: 600px; word-wrap: break-word; display: flex; align-items: center; gap: 10px;';
|
||||
|
||||
// 添加消息内容
|
||||
var msgSpan = document.createElement('span');
|
||||
msgSpan.textContent = '{safe_message}';
|
||||
notif.appendChild(msgSpan);
|
||||
|
||||
// 添加关闭按钮
|
||||
var closeBtn = document.createElement('span');
|
||||
closeBtn.innerHTML = '×';
|
||||
closeBtn.style.cssText = 'cursor: pointer; font-size: 18px; font-weight: bold; margin-left: 10px;';
|
||||
closeBtn.onclick = function() {{ notif.remove(); }};
|
||||
notif.appendChild(closeBtn);
|
||||
|
||||
// 添加到页面
|
||||
document.body.appendChild(notif);
|
||||
|
||||
// 5秒后自动消失
|
||||
setTimeout(function() {{
|
||||
if (notif && notif.parentNode) {{
|
||||
notif.style.transition = 'opacity 0.3s';
|
||||
notif.style.opacity = '0';
|
||||
setTimeout(function() {{ notif.remove(); }}, 300);
|
||||
}}
|
||||
}}, 5000);
|
||||
}})();
|
||||
"""
|
||||
# 在第一个窗口中执行 JavaScript
|
||||
webview.windows[0].evaluate_js(js_code)
|
||||
except Exception as e:
|
||||
print(f"显示通知失败: {str(e)}")
|
||||
|
||||
"""
|
||||
处理流程:
|
||||
1、解析任务json,提取对应的companyName 组组装user_info实例化 AmzoneApprove
|
||||
2、打开对应的店铺、遍历所有的国家站点处理
|
||||
3、切换至指定国家,切换至 管理所有库存页面
|
||||
4、调用search方法,查询出是否存在需要审批的商品,如果没有则结束;如果有则进入处理流程
|
||||
5、进入处理流程后,调用run_page_action方法(注意是通过yield 返回数据的)
|
||||
|
||||
任务json示例:
|
||||
{
|
||||
"type": "product-risk-resolve-run",
|
||||
"ts": 1775395996876,
|
||||
"data": {
|
||||
"taskId": 925,
|
||||
"items": [
|
||||
{
|
||||
"matched": true,
|
||||
"platform": "亚马逊",
|
||||
"shopName": "魏振峰",
|
||||
"shopId": "27543917795757",
|
||||
"companyName": "rongchuang123",
|
||||
"openStoreUrl": null,
|
||||
"matchedUserId": 17543915345493,
|
||||
"matchStatus": "MATCHED",
|
||||
"matchMessage": null
|
||||
}
|
||||
],
|
||||
"country_codes": [
|
||||
"UK",
|
||||
"DE",
|
||||
"FR",
|
||||
"ES"
|
||||
]
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, user_info: dict = None):
|
||||
"""初始化审批任务处理器
|
||||
|
||||
Args:
|
||||
user_info: 用户信息字典,包含 company, username, password
|
||||
"""
|
||||
self.user_info = user_info or {}
|
||||
self.running = True
|
||||
|
||||
def log(self, message: str, level: str = "INFO"):
|
||||
"""日志输出
|
||||
|
||||
Args:
|
||||
message: 日志消息
|
||||
level: 日志级别
|
||||
"""
|
||||
from datetime import datetime
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
if level == "ERROR":
|
||||
self.show_notification(message, "error")
|
||||
print(f"[{timestamp}] [ApproveTask] [{level}] {message}")
|
||||
|
||||
def process_task(self, task_data: dict):
|
||||
"""处理审批任务主入口
|
||||
|
||||
Args:
|
||||
task_data: 任务数据
|
||||
"""
|
||||
try:
|
||||
data = task_data.get("data", {})
|
||||
task_id = data.get("taskId")
|
||||
items = data.get("items", [])
|
||||
country_codes = data.get("country_codes", [])
|
||||
risk_listing_filter = data.get("risk_listing_filter", "")
|
||||
|
||||
if not task_id:
|
||||
self.log("任务ID为空,跳过", "WARNING")
|
||||
return
|
||||
|
||||
if not items:
|
||||
self.log("店铺列表为空,跳过", "WARNING")
|
||||
return
|
||||
|
||||
if not country_codes:
|
||||
self.log("国家列表为空,跳过", "WARNING")
|
||||
return
|
||||
|
||||
self.log(f"开始处理审批任务 {task_id},共 {len(items)} 个店铺,{len(country_codes)} 个国家")
|
||||
|
||||
from config import runing_task
|
||||
runing_task[task_id] = {
|
||||
"status": "running",
|
||||
"start_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"total_shops": len(items),
|
||||
"processed_shops": 0,
|
||||
"total_countries": len(country_codes) * len(items),
|
||||
"processed_countries": 0,
|
||||
"total_asins": 0,
|
||||
"processed_asins": 0,
|
||||
"success_count": 0,
|
||||
"failed_count": 0,
|
||||
"stop_requested": False
|
||||
}
|
||||
|
||||
# 遍历处理每个店铺
|
||||
for idx, shop_item in enumerate(items, 1):
|
||||
# 检查是否收到暂停请求
|
||||
if task_id in runing_task and runing_task[task_id].get("stop_requested", False):
|
||||
self.log(f"检测到任务 {task_id} 的暂停请求,停止处理", "WARNING")
|
||||
runing_task[task_id]["status"] = "stopped"
|
||||
return
|
||||
|
||||
shop_name = shop_item.get("shopName", "未知店铺")
|
||||
self.log(f"[{idx}/{len(items)}] 开始处理店铺: {shop_name}")
|
||||
self.show_notification(f"开始处理店铺: {shop_name}", "info")
|
||||
|
||||
try:
|
||||
self.process_shop(shop_item, country_codes, task_id,risk_listing_filter)
|
||||
# 更新已处理店铺数
|
||||
if task_id in runing_task:
|
||||
runing_task[task_id]["processed_shops"] += 1
|
||||
except Exception as e:
|
||||
import traceback
|
||||
self.log(f"处理店铺 {shop_name} 失败: {str(e)}", "ERROR")
|
||||
self.log(traceback.format_exc(), "ERROR")
|
||||
|
||||
# 更新任务状态
|
||||
if task_id in runing_task:
|
||||
if runing_task[task_id].get("stop_requested", False):
|
||||
runing_task[task_id]["status"] = "stopped"
|
||||
self.log(f"任务 {task_id} 已被暂停!")
|
||||
else:
|
||||
runing_task[task_id]["status"] = "completed"
|
||||
self.log(f"任务 {task_id} 处理完成!")
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
self.log(f"任务处理失败: {traceback.format_exc()}", "ERROR")
|
||||
if task_id:
|
||||
from config import runing_task
|
||||
if task_id in runing_task:
|
||||
runing_task[task_id]["status"] = "failed"
|
||||
runing_task[task_id]["error"] = str(e)
|
||||
|
||||
def process_shop(self, shop_item: dict, country_codes: list, task_id: int, risk_listing_filter: str):
|
||||
"""处理单个店铺
|
||||
|
||||
Args:
|
||||
shop_item: 店铺信息
|
||||
country_codes: 国家代码列表
|
||||
task_id: 任务ID
|
||||
risk_listing_filter: 风险商品筛选条件
|
||||
"""
|
||||
shop_name = shop_item.get("shopName", "未知店铺")
|
||||
company_name = shop_item.get("companyName", "")
|
||||
|
||||
if not company_name:
|
||||
self.log(f"店铺 {shop_name} 的公司名称为空,跳过", "WARNING")
|
||||
return
|
||||
|
||||
|
||||
if task_id in runing_task:
|
||||
runing_task[task_id]["current_shop"] = shop_name
|
||||
|
||||
# 将店铺添加到正在执行中的店铺列表
|
||||
start_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
runing_shop[shop_name] = start_time
|
||||
self.log(f"店铺 {shop_name} 已添加到执行列表,账号: {company_name},开始时间: {start_time}")
|
||||
|
||||
# 店铺打开重试最多3次
|
||||
driver = None
|
||||
max_retries = 3
|
||||
|
||||
error_info = ""
|
||||
for retry in range(max_retries):
|
||||
try:
|
||||
self.log(f"尝试打开店铺 {shop_name} (第 {retry + 1}/{max_retries} 次)")
|
||||
|
||||
# 如果不是第一次尝试,先杀进程
|
||||
# if retry > 0:
|
||||
# self.log("重试前先杀掉浏览器进程...")
|
||||
# kill_process("v6")
|
||||
# kill_process("v5")
|
||||
# time.sleep(2)
|
||||
|
||||
# 组装用户信息并创建驱动
|
||||
user_info = {
|
||||
**self.user_info,
|
||||
"company": company_name
|
||||
}
|
||||
driver = AmzoneApprove(user_info)
|
||||
browser = driver.open_shop(shop_name)
|
||||
|
||||
if browser and browser != "店铺不存在":
|
||||
self.log(f"成功打开店铺 {shop_name}")
|
||||
break
|
||||
else:
|
||||
self.log(f"打开店铺失败: {browser}", "WARNING")
|
||||
driver = None
|
||||
|
||||
# 判断是否需要登录
|
||||
need_login = driver.need_login()
|
||||
if need_login:
|
||||
self.log(f"店铺 {shop_name} 需要登录,正在登录...")
|
||||
password = ""
|
||||
|
||||
login_success = driver.login(password)
|
||||
if login_success:
|
||||
self.log(f"店铺 {shop_name} 登录成功,正在重新打开店铺...")
|
||||
browser = driver.open_shop(shop_name)
|
||||
if browser and browser != "店铺不存在":
|
||||
self.log(f"成功打开店铺 {shop_name} 登录后")
|
||||
break
|
||||
else:
|
||||
self.log(f"登录后打开店铺失败: {browser}", "WARNING")
|
||||
driver = None
|
||||
else:
|
||||
self.log(f"店铺 {shop_name} 登录失败", "WARNING")
|
||||
driver = None
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
self.log(f"打开店铺异常: {traceback.format_exc()}", "INFO")
|
||||
driver = None
|
||||
error_info = str(e)
|
||||
time.sleep(10)
|
||||
|
||||
# 如果还有重试机会,等待后继续
|
||||
if retry < max_retries - 1:
|
||||
time.sleep(3)
|
||||
|
||||
# 检查是否成功打开
|
||||
if not driver or not browser or browser == "店铺不存在":
|
||||
error_msg = f"店铺 {shop_name} 打开失败,已重试 {max_retries} 次,跳过该店铺,{error_info}"
|
||||
self.log(error_msg, "ERROR")
|
||||
# 从执行列表中移除
|
||||
if shop_name in runing_shop:
|
||||
del runing_shop[shop_name]
|
||||
return
|
||||
|
||||
try:
|
||||
# 处理每个国家
|
||||
for country_code in country_codes:
|
||||
# 检查是否收到暂停请求
|
||||
if task_id in runing_task and runing_task[task_id].get("stop_requested", False):
|
||||
self.log(f"检测到任务 {task_id} 的暂停请求,停止处理国家", "WARNING")
|
||||
break
|
||||
|
||||
try:
|
||||
self.process_country(driver, country_code, task_id, shop_name,risk_listing_filter)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
self.log(f"处理国家 {country_code} 失败: {str(e)}", "ERROR")
|
||||
self.log(traceback.format_exc(), "ERROR")
|
||||
# 最后回传,标记完成
|
||||
try:
|
||||
self.post_result(task_id, shop_name, country_code, "", "", is_done=True)
|
||||
except Exception as e:
|
||||
self.log(f"回传结果失败: {str(e)}", "ERROR")
|
||||
finally:
|
||||
# 关闭店铺
|
||||
try:
|
||||
if driver:
|
||||
self.log(f"关闭店铺 {shop_name}")
|
||||
driver.close_store()
|
||||
time.sleep(2)
|
||||
except Exception as e:
|
||||
self.log(f"关闭店铺失败: {str(e)}", "WARNING")
|
||||
|
||||
# 从正在执行中的店铺列表中移除
|
||||
if shop_name in runing_shop:
|
||||
del runing_shop[shop_name]
|
||||
self.log(f"店铺 {shop_name} 已从执行列表中移除")
|
||||
|
||||
def process_country(self, driver: AmzoneApprove, country_code: str, task_id: int, shop_name: str, risk_listing_filter: str):
|
||||
"""处理单个国家的审批任务
|
||||
|
||||
Args:
|
||||
driver: AmzoneApprove驱动实例
|
||||
country_code: 国家代码(如 UK, DE, FR 等)
|
||||
task_id: 任务ID
|
||||
shop_name: 店铺名称
|
||||
risk_listing_filter: 风险商品筛选条件
|
||||
"""
|
||||
from config import runing_task
|
||||
|
||||
# 转换国家代码为中文名称
|
||||
country_name = self.country_info.get(country_code, country_code)
|
||||
info_mes = f"开始处理国家: {country_name} ({country_code})"
|
||||
self.log(info_mes)
|
||||
self.show_notification(info_mes, "info")
|
||||
|
||||
# 更新当前处理的国家
|
||||
if task_id in runing_task:
|
||||
runing_task[task_id]["current_country"] = country_name
|
||||
|
||||
# 切换国家,最多重试3次
|
||||
max_retries = 3
|
||||
switch_success = False
|
||||
|
||||
for retry in range(max_retries):
|
||||
try:
|
||||
self.log(f"尝试切换到国家 {country_name} (第 {retry + 1}/{max_retries} 次)")
|
||||
|
||||
# 如果不是第一次尝试,先刷新页面
|
||||
if retry > 0:
|
||||
self.log("重试前刷新页面...")
|
||||
try:
|
||||
driver.tab.refresh()
|
||||
time.sleep(3)
|
||||
except Exception as e:
|
||||
self.log(f"刷新页面失败: {str(e)}", "WARNING")
|
||||
|
||||
switch_success = driver.SwitchingCountries(country_name)
|
||||
if switch_success:
|
||||
self.log(f"成功切换到国家 {country_name}")
|
||||
break
|
||||
else:
|
||||
self.log(f"切换到国家 {country_name} 失败", "WARNING")
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
self.log(f"切换国家 {country_name} 异常: {str(e)}", "ERROR")
|
||||
self.log(traceback.format_exc(), "ERROR")
|
||||
|
||||
# 如果还有重试机会,等待后继续
|
||||
if retry < max_retries - 1:
|
||||
time.sleep(2)
|
||||
|
||||
# 如果切换失败,直接返回
|
||||
if not switch_success:
|
||||
error_message = f"切换到国家 {country_name} 失败,已重试 {max_retries} 次,跳过该国家"
|
||||
self.log(error_message, "ERROR")
|
||||
if task_id in runing_task:
|
||||
runing_task[task_id]["processed_countries"] += 1
|
||||
return
|
||||
|
||||
# 切换到库存管理页面
|
||||
try:
|
||||
driver.SwitchPage()
|
||||
self.log(f"已切换到库存管理页面")
|
||||
except Exception as e:
|
||||
import traceback
|
||||
self.log(f"切换页面失败: {str(e)}", "ERROR")
|
||||
self.log(traceback.format_exc(), "ERROR")
|
||||
if task_id in runing_task:
|
||||
runing_task[task_id]["processed_countries"] += 1
|
||||
return
|
||||
|
||||
# 搜索需要审批的商品,最多重试3次
|
||||
sku_ls = []
|
||||
for retry in range(max_retries):
|
||||
try:
|
||||
self.log(f"尝试搜索需要审批的商品 (第 {retry + 1}/{max_retries} 次)")
|
||||
sku_ls = driver.search(filter_type=risk_listing_filter)
|
||||
break
|
||||
except Exception as e:
|
||||
self.log(f"搜索商品异常: {str(e)}", "ERROR")
|
||||
if retry < max_retries - 1:
|
||||
try:
|
||||
driver.tab.refresh()
|
||||
time.sleep(3)
|
||||
except Exception as refresh_error:
|
||||
self.log(f"刷新页面失败: {str(refresh_error)}", "WARNING")
|
||||
|
||||
# 如果没有需要审批的商品,直接返回
|
||||
if len(sku_ls) == 0:
|
||||
self.log(f"国家 {country_name} 没有需要审批的商品")
|
||||
if task_id in runing_task:
|
||||
runing_task[task_id]["processed_countries"] += 1
|
||||
return
|
||||
|
||||
self.log(f"国家 {country_name} 有 {len(sku_ls)} 个需要审批的商品,开始处理...")
|
||||
|
||||
# 处理所有需要审批的商品(通过yield获取结果)
|
||||
try:
|
||||
for asin, status in driver.run_page_action():
|
||||
# 检查是否收到暂停请求
|
||||
if task_id in runing_task and runing_task[task_id].get("stop_requested", False):
|
||||
self.log(f"检测到任务 {task_id} 的暂停请求,停止处理ASIN", "WARNING")
|
||||
break
|
||||
|
||||
self.log(f"ASIN {asin} 处理结果: {status}")
|
||||
|
||||
# 更新任务状态
|
||||
if task_id in runing_task:
|
||||
runing_task[task_id]["current_asin"] = asin
|
||||
runing_task[task_id]["processed_asins"] += 1
|
||||
|
||||
if status == "处理完成":
|
||||
runing_task[task_id]["success_count"] += 1
|
||||
elif status == "请求批准":
|
||||
# 请求批准的商品也算作成功(因为不需要处理)
|
||||
runing_task[task_id]["success_count"] += 1
|
||||
else:
|
||||
runing_task[task_id]["failed_count"] += 1
|
||||
|
||||
# 回传结果到API
|
||||
try:
|
||||
self.post_result(task_id, shop_name, country_code, asin, status)
|
||||
except Exception as e:
|
||||
self.log(f"回传结果失败: {str(e)}", "ERROR")
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
self.log(f"处理审批商品异常: {str(e)}", "ERROR")
|
||||
self.log(traceback.format_exc(), "ERROR")
|
||||
|
||||
# 更新已处理国家数
|
||||
if task_id in runing_task:
|
||||
runing_task[task_id]["processed_countries"] += 1
|
||||
|
||||
self.log(f"国家 {country_name} 处理完成")
|
||||
|
||||
def post_result(self, task_id: int, shop_name: str, country_code: str, asin: str, status: str,is_done: bool = False):
|
||||
"""回传处理结果到API
|
||||
|
||||
Args:
|
||||
task_id: 任务ID
|
||||
shop_name: 店铺名称
|
||||
country_code: 国家代码
|
||||
asin: ASIN
|
||||
status: 处理状态
|
||||
"""
|
||||
import requests
|
||||
from config import DELETE_BRAND_API_BASE
|
||||
|
||||
url = f"{DELETE_BRAND_API_BASE}/api/product-risk-resolve/tasks/{task_id}/result"
|
||||
if status == "请求批准":
|
||||
country_data = {
|
||||
"status": "",
|
||||
"shopName": shop_name,
|
||||
"productAsinSku": "",
|
||||
"done": is_done,
|
||||
"removeAsin": asin,
|
||||
"removeStatus": status
|
||||
}
|
||||
else:
|
||||
country_data = {
|
||||
"status": status,
|
||||
"shopName": shop_name,
|
||||
"productAsinSku": asin,
|
||||
"done": is_done,
|
||||
"removeAsin": "",
|
||||
"removeStatus": ""
|
||||
}
|
||||
|
||||
payload = {
|
||||
"shops": [
|
||||
{
|
||||
"error": "",
|
||||
"countries": {
|
||||
country_code : [ country_data ]
|
||||
},
|
||||
"shopName": shop_name
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
max_retries = 3
|
||||
for retry in range(max_retries):
|
||||
try:
|
||||
print("=====================================")
|
||||
self.log(f"尝试回传结果 (第 {retry + 1}/{max_retries} 次)")
|
||||
self.log(f"回传URL: {url}")
|
||||
self.log(f"回传数据: {payload}")
|
||||
response = requests.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=30,
|
||||
verify=False
|
||||
)
|
||||
self.log(f"回传结果: {response.text}")
|
||||
|
||||
if response.status_code == 200:
|
||||
self.log(f"结果回传成功: {asin} - {status}")
|
||||
return
|
||||
else:
|
||||
self.log(f"结果回传失败,状态码: {response.status_code}", "WARNING")
|
||||
print("=====================================")
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"调用API异常: {str(e)}", "ERROR")
|
||||
print("=====================================")
|
||||
|
||||
# 如果还有重试机会,等待后继续
|
||||
if retry < max_retries - 1:
|
||||
time.sleep(2)
|
||||
|
||||
self.log(f"已达到最大重试次数,结果回传最终失败", "ERROR")
|
||||
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
user_info = {
|
||||
"company": "rongchuang123",
|
||||
"username": "自动化_Robot",
|
||||
"password": "#20zsg25"
|
||||
}
|
||||
shop_name = "魏振峰"
|
||||
country = "西班牙"
|
||||
kill_process('v6')
|
||||
driver = AmzoneApprove(user_info)
|
||||
browser = driver.open_shop(shop_name)
|
||||
sw_suc = driver.SwitchingCountries(country)
|
||||
driver.SwitchPage()
|
||||
risk_listing_filter = "Active"
|
||||
for _ in range(3):
|
||||
try:
|
||||
sku_ls = driver.search(filter_type=risk_listing_filter)
|
||||
break
|
||||
except Exception as e:
|
||||
print(e)
|
||||
driver.tab.refresh()
|
||||
if len(sku_ls) > 0:
|
||||
print("有数据,开始操作")
|
||||
for asin, status in driver.run_page_action():
|
||||
print(f"ASIN {asin} 的处理结果: {status}")
|
||||
print("已完成操作")
|
||||
|
||||
# for i in range(0):
|
||||
# print(i)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,616 +0,0 @@
|
||||
import traceback
|
||||
import winreg
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
import requests
|
||||
import json
|
||||
import os
|
||||
|
||||
from typing import Literal
|
||||
from DrissionPage import Chromium
|
||||
from DrissionPage.common import By
|
||||
from DrissionPage._pages.chromium_tab import ChromiumTab
|
||||
|
||||
|
||||
def kill_process(version: Literal["v5", "v6"]):
|
||||
"""杀紫鸟客户端进程(独立函数版本)"""
|
||||
driver = ZiniaoDriver({})
|
||||
driver.kill_process(version)
|
||||
|
||||
|
||||
class ZiniaoDriver:
|
||||
"""紫鸟浏览器自动化驱动类"""
|
||||
|
||||
def __init__(self, user_info: dict, socket_port: int = 19890):
|
||||
"""
|
||||
初始化紫鸟浏览器驱动
|
||||
|
||||
Args:
|
||||
user_info: 用户信息字典,包含 company, username, password
|
||||
socket_port: 客户端通信端口,默认 19890
|
||||
"""
|
||||
self.user_info = user_info
|
||||
self.socket_port = socket_port
|
||||
self.client_path = None
|
||||
self.browser = None
|
||||
self.tab: ChromiumTab = None
|
||||
self.store_id = None
|
||||
|
||||
def get_zinaio_exe(self, protocol_name: str = "superbrowser"):
|
||||
"""
|
||||
获取紫鸟安装目录
|
||||
|
||||
Args:
|
||||
protocol_name: 协议名称,默认 "superbrowser"
|
||||
|
||||
Returns:
|
||||
exe_path: 紫鸟浏览器可执行文件路径
|
||||
"""
|
||||
try:
|
||||
key_path = rf"SOFTWARE\Classes\{protocol_name}\shell\open\command"
|
||||
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path)
|
||||
command, _ = winreg.QueryValueEx(key, "")
|
||||
winreg.CloseKey(key)
|
||||
if isinstance(command, str):
|
||||
sub = "ziniao.exe"
|
||||
exe_path = command[0: command.find(sub)+len(sub)+1]
|
||||
else:
|
||||
exe_path = command[0]
|
||||
return exe_path
|
||||
except FileNotFoundError:
|
||||
try:
|
||||
key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, key_path)
|
||||
command, _ = winreg.QueryValueEx(key, "")
|
||||
winreg.CloseKey(key)
|
||||
if isinstance(command, str):
|
||||
sub = "ziniao.exe"
|
||||
exe_path = command[0: command.find(sub)+len(sub)+1]
|
||||
else:
|
||||
exe_path = command[0]
|
||||
return exe_path
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
def update_core(self):
|
||||
"""
|
||||
下载所有内核,打开店铺前调用,需客户端版本5.285.7以上
|
||||
因为http有超时时间,所以这个action适合循环调用,直到返回成功
|
||||
"""
|
||||
data = {
|
||||
"action": "updateCore",
|
||||
"requestId": str(uuid.uuid4()),
|
||||
}
|
||||
data.update(self.user_info)
|
||||
while True:
|
||||
url = f'http://127.0.0.1:{self.socket_port}'
|
||||
response = requests.post(url, json.dumps(data).encode('utf-8'), timeout=120)
|
||||
result = response.json()
|
||||
print(result)
|
||||
if result is None:
|
||||
print("等待客户端启动...")
|
||||
time.sleep(2)
|
||||
continue
|
||||
if result.get("statusCode") is None or result.get("statusCode") == -10003:
|
||||
print("当前版本不支持此接口,请升级客户端")
|
||||
return
|
||||
elif result.get("statusCode") == 0:
|
||||
print("更新内核完成")
|
||||
return
|
||||
else:
|
||||
print(f"等待更新内核: {json.dumps(result)}")
|
||||
time.sleep(2)
|
||||
|
||||
def kill_process(self, version: Literal["v5", "v6"]):
|
||||
"""
|
||||
杀紫鸟客户端进程
|
||||
|
||||
Args:
|
||||
version: 客户端版本
|
||||
"""
|
||||
if version == "v5":
|
||||
process_name = 'SuperBrowser.exe'
|
||||
os.system('taskkill /f /t /im ' + "starter.exe")
|
||||
else:
|
||||
process_name = 'ziniao.exe'
|
||||
os.system('taskkill /f /t /im ' + process_name)
|
||||
time.sleep(3)
|
||||
|
||||
def get_browser_list(self) -> list:
|
||||
"""
|
||||
获取浏览器列表
|
||||
|
||||
Returns:
|
||||
list: 浏览器列表
|
||||
"""
|
||||
request_id = str(uuid.uuid4())
|
||||
data = {
|
||||
"action": "getBrowserList",
|
||||
"requestId": request_id
|
||||
}
|
||||
data.update(self.user_info)
|
||||
|
||||
url = f'http://127.0.0.1:{self.socket_port}'
|
||||
response = requests.post(url, json.dumps(data).encode('utf-8'), timeout=120)
|
||||
r = response.json()
|
||||
if str(r.get("statusCode")) == "0":
|
||||
print(r)
|
||||
return r.get("browserList")
|
||||
elif str(r.get("statusCode")) == "-10003":
|
||||
print(f"【get_browser_list】登录失败 {json.dumps(r, ensure_ascii=False)}")
|
||||
# exit()
|
||||
else:
|
||||
print(f"【get_browser_list】失败 {json.dumps(r, ensure_ascii=False)} ")
|
||||
# exit()
|
||||
|
||||
def open_store(self, store_info, isWebDriverReadOnlyMode=0, isprivacy=0,
|
||||
isHeadless=0, cookieTypeSave=0, jsInfo=""):
|
||||
"""
|
||||
打开店铺
|
||||
|
||||
Args:
|
||||
store_info: 店铺信息(browserId 或 browserOauth)
|
||||
isWebDriverReadOnlyMode: 是否只读模式,默认 0
|
||||
isprivacy: 隐私模式,默认 0
|
||||
isHeadless: 无头模式,默认 0
|
||||
cookieTypeSave: cookie保存类型,默认 0
|
||||
jsInfo: 注入的JS信息,默认 ""
|
||||
|
||||
Returns:
|
||||
dict: 返回结果
|
||||
"""
|
||||
request_id = str(uuid.uuid4())
|
||||
data = {
|
||||
"action": "startBrowser",
|
||||
"isWaitPluginUpdate": 0,
|
||||
"isHeadless": isHeadless,
|
||||
"requestId": request_id,
|
||||
"isWebDriverReadOnlyMode": isWebDriverReadOnlyMode,
|
||||
"cookieTypeLoad": 0,
|
||||
"cookieTypeSave": cookieTypeSave,
|
||||
"runMode": "1",
|
||||
"isLoadUserPlugin": False,
|
||||
"pluginIdType": 1,
|
||||
"privacyMode": isprivacy
|
||||
}
|
||||
data.update(self.user_info)
|
||||
|
||||
if store_info.isdigit():
|
||||
data["browserId"] = store_info
|
||||
else:
|
||||
data["browserOauth"] = store_info
|
||||
|
||||
if len(str(jsInfo)) > 2:
|
||||
data["injectJsInfo"] = json.dumps(jsInfo)
|
||||
|
||||
url = f'http://127.0.0.1:{self.socket_port}'
|
||||
response = requests.post(url, json.dumps(data).encode('utf-8'), timeout=120)
|
||||
r = response.json()
|
||||
if str(r.get("statusCode")) == "0":
|
||||
return r
|
||||
elif str(r.get("statusCode")) == "-10003":
|
||||
raise RuntimeError(f"【open_store】登录失败 {json.dumps(r, ensure_ascii=False)}")
|
||||
# exit()
|
||||
else:
|
||||
raise RuntimeError(f"【open_store】失败 {json.dumps(r, ensure_ascii=False)} ")
|
||||
# exit()
|
||||
|
||||
def get_browser(self, port) -> Chromium:
|
||||
"""
|
||||
获取浏览器实例
|
||||
|
||||
Args:
|
||||
port: 调试端口
|
||||
|
||||
Returns:
|
||||
Chromium: DrissionPage浏览器实例
|
||||
"""
|
||||
browser = Chromium(port)
|
||||
return browser
|
||||
|
||||
def start_client(self):
|
||||
"""启动紫鸟客户端"""
|
||||
|
||||
# 检查端口是否已经启动
|
||||
try:
|
||||
url = f'http://127.0.0.1:{self.socket_port}'
|
||||
response = requests.get(url, timeout=2)
|
||||
print(f"端口 {self.socket_port} 已经启动,跳过启动客户端操作")
|
||||
return
|
||||
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
|
||||
# 端口未启动,继续执行启动操作
|
||||
print(f"端口 {self.socket_port} 未启动,开始启动客户端")
|
||||
|
||||
self.kill_process('v6')
|
||||
time.sleep(5)
|
||||
self.client_path = self.get_zinaio_exe("superbrowserv6").strip('"')
|
||||
print(self.client_path)
|
||||
cmd = [self.client_path, '--run_type=web_driver', '--ipc_type=http',
|
||||
'--port=' + str(self.socket_port)]
|
||||
print(" ".join(cmd))
|
||||
|
||||
# 最大重试次数
|
||||
max_retries = 3
|
||||
|
||||
for retry_count in range(max_retries):
|
||||
print(f"第 {retry_count + 1} 次尝试启动客户端...")
|
||||
|
||||
# 启动进程
|
||||
subprocess.Popen(cmd)
|
||||
|
||||
# 循环检测10秒,每0.5秒检测一次
|
||||
start_check_time = time.time()
|
||||
client_started = False
|
||||
|
||||
while time.time() - start_check_time < 10:
|
||||
try:
|
||||
response = requests.get(url, timeout=2)
|
||||
print(f"客户端启动成功!(第 {retry_count + 1} 次尝试)")
|
||||
client_started = True
|
||||
break
|
||||
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
|
||||
# 端口还未启动,继续等待
|
||||
time.sleep(0.5)
|
||||
|
||||
if client_started:
|
||||
time.sleep(5) # 等待客户端完全启动
|
||||
# 更新内核
|
||||
self.update_core()
|
||||
return
|
||||
else:
|
||||
print(f"第 {retry_count + 1} 次尝试启动失败,10秒内未检测到客户端启动")
|
||||
|
||||
# 超过最大重试次数,抛出异常
|
||||
raise RuntimeError(f"客户端启动失败:重试 {max_retries} 次后仍未成功启动")
|
||||
|
||||
def open_shop(self, shop_name: str):
|
||||
"""
|
||||
打开指定店铺
|
||||
|
||||
Args:
|
||||
shop_name: 店铺名称
|
||||
|
||||
Returns:
|
||||
Chromium or str: 成功返回浏览器实例,失败返回错误信息
|
||||
"""
|
||||
# 启动客户端
|
||||
self.start_client()
|
||||
|
||||
|
||||
|
||||
# 获取店铺列表
|
||||
shop_ls = self.get_browser_list()
|
||||
self.store_id = None
|
||||
print(shop_ls)
|
||||
for shop in shop_ls:
|
||||
if shop.get("browserName") == shop_name:
|
||||
self.store_id = shop.get('browserOauth')
|
||||
break
|
||||
|
||||
if not self.store_id:
|
||||
print("店铺不存在")
|
||||
return "店铺不存在"
|
||||
|
||||
# 打开店铺
|
||||
ret_json = self.open_store(self.store_id)
|
||||
print(ret_json)
|
||||
self.store_id = ret_json.get("browserOauth")
|
||||
if self.store_id is None:
|
||||
self.store_id = ret_json.get("browserId")
|
||||
|
||||
# 获取drissionpage浏览器会话
|
||||
self.browser = self.get_browser(ret_json.get('debuggingPort'))
|
||||
|
||||
ip_check_url = ret_json.get("ipDetectionPage")
|
||||
if not ip_check_url:
|
||||
print("ip检测页地址为空,请升级紫鸟浏览器到最新版")
|
||||
print(f"=====关闭店铺:{shop_name}=====")
|
||||
self.close_store(self.store_id)
|
||||
# exit()
|
||||
raise RuntimeError("没有IP检测地址,为了店铺安全不打开店铺")
|
||||
ip_usable = self.open_ip_check(self.browser, ip_check_url)
|
||||
if ip_usable:
|
||||
print("ip检测通过,打开店铺平台主页")
|
||||
self.open_launcher_page(ret_json.get("launcherPage"), self.browser)
|
||||
else:
|
||||
print("IP检测不通过")
|
||||
return self.browser
|
||||
|
||||
def close_store(self, browser_oauth=None):
|
||||
"""
|
||||
关闭店铺
|
||||
|
||||
Args:
|
||||
browser_oauth: 店铺OAuth标识,如果不提供则使用当前打开的店铺
|
||||
|
||||
Returns:
|
||||
dict: 返回结果
|
||||
"""
|
||||
if browser_oauth is None:
|
||||
browser_oauth = self.store_id
|
||||
|
||||
request_id = str(uuid.uuid4())
|
||||
data = {
|
||||
"action": "stopBrowser",
|
||||
"requestId": request_id,
|
||||
"duplicate": 0,
|
||||
"browserOauth": browser_oauth
|
||||
}
|
||||
data.update(self.user_info)
|
||||
|
||||
url = f'http://127.0.0.1:{self.socket_port}'
|
||||
response = requests.post(url, json.dumps(data).encode('utf-8'), timeout=120)
|
||||
r = response.json()
|
||||
if str(r.get("statusCode")) == "0":
|
||||
return r
|
||||
elif str(r.get("statusCode")) == "-10003":
|
||||
raise RuntimeError(f"【close_store】登录失败 {json.dumps(r, ensure_ascii=False)}")
|
||||
# exit()
|
||||
else:
|
||||
raise RuntimeError(f"【close_store】失败: {json.dumps(r, ensure_ascii=False)} ")
|
||||
# exit()
|
||||
|
||||
def open_launcher_page(self, launcher_page: str, browser: Chromium = None):
|
||||
"""
|
||||
打开启动页面
|
||||
|
||||
Args:
|
||||
launcher_page: 要打开的页面URL
|
||||
browser: 浏览器实例,如果不提供则使用当前浏览器实例
|
||||
"""
|
||||
if browser is None:
|
||||
browser = self.browser
|
||||
|
||||
tab = browser.new_tab(url=launcher_page)
|
||||
self.tab = tab
|
||||
return tab
|
||||
|
||||
def open_ip_check(self, browser: Chromium, ip_check_url: str):
|
||||
"""
|
||||
打开ip检测页检测ip是否正常
|
||||
:param browser: drissionpage浏览器会话
|
||||
:param ip_check_url ip检测页地址
|
||||
:return 检测结果
|
||||
"""
|
||||
try:
|
||||
tab = browser.latest_tab
|
||||
tab.get(ip_check_url)
|
||||
success_button = tab.ele((By.XPATH, '//button[contains(@class, "styles_btn--success")]'),
|
||||
timeout=60) # 等待查找元素60秒
|
||||
if success_button:
|
||||
print("ip检测成功")
|
||||
return True
|
||||
else:
|
||||
print("ip检测超时")
|
||||
return False
|
||||
except Exception as e:
|
||||
print("ip检测异常:" + traceback.format_exc())
|
||||
return False
|
||||
|
||||
|
||||
class AmamzonBase(ZiniaoDriver):
|
||||
"""亚马逊操作基类,包含一些通用方法"""
|
||||
|
||||
def SwitchingCountries(self, country_name: str):
|
||||
"""
|
||||
切换国家
|
||||
操作:
|
||||
1、//div[@class="dropdown-account-switcher-header-label"]/span[last()] 获取此元素文本,判断当前国家,如果与目标国家相同则不操作,否则执行下一步
|
||||
2、点击 //div[@class="dropdown-account-switcher-header-label"] 打开下拉框
|
||||
3、点击 //div[@class="dropdown-account-switcher-list-item"] 第一个展开国家列表
|
||||
4、点击 //div[@class="dropdown-account-switcher-list-item dropdown-account-switcher-list-item-indented" and @title="国家名"] 切换到目标国家
|
||||
5、等待页面加载完成,判断国家是否切换成功,成功则返回True,否则返回False
|
||||
|
||||
Args:
|
||||
country_name: 目标国家名称
|
||||
|
||||
Returns:
|
||||
bool: 切换成功返回True,失败返回False
|
||||
"""
|
||||
try:
|
||||
if self.browser is None:
|
||||
print("浏览器实例不存在,请先打开店铺")
|
||||
return False
|
||||
|
||||
# 获取当前标签页
|
||||
tab = self.tab
|
||||
tab.wait.doc_loaded(timeout=120,raise_err=False)
|
||||
|
||||
# 步骤1:获取当前国家名称
|
||||
print(f"正在检查当前国家...")
|
||||
current_country_ele = tab.ele('xpath://div[@class="dropdown-account-switcher-header-label"]/span[last()]',
|
||||
timeout=20)
|
||||
if current_country_ele:
|
||||
current_country = current_country_ele.text.strip()
|
||||
print(f"当前国家:{current_country}")
|
||||
|
||||
# 判断是否与目标国家相同
|
||||
if current_country == country_name:
|
||||
print(f"当前已经是目标国家 {country_name},无需切换")
|
||||
return True
|
||||
else:
|
||||
print("无法获取当前国家信息")
|
||||
return False
|
||||
|
||||
# 步骤2:点击打开下拉框
|
||||
print(f"正在打开国家切换下拉框...")
|
||||
dropdown_header = tab.ele('xpath://div[@class="dropdown-account-switcher-header-label"]', timeout=10)
|
||||
if not dropdown_header:
|
||||
print("找不到国家切换下拉框")
|
||||
return False
|
||||
dropdown_header.click()
|
||||
time.sleep(1) # 等待下拉框展开
|
||||
|
||||
# 步骤3:点击第一个展开国家列表
|
||||
print(f"正在展开国家列表...")
|
||||
first_item = tab.ele('xpath://div[@class="dropdown-account-switcher-list-item"]', timeout=10)
|
||||
if not first_item:
|
||||
print("找不到国家列表项")
|
||||
return False
|
||||
first_item.click()
|
||||
time.sleep(1) # 等待国家列表展开
|
||||
|
||||
# 步骤4:点击目标国家
|
||||
print(f"正在切换到国家:{country_name}")
|
||||
target_country_xpath = f'//div[@class="dropdown-account-switcher-list-item dropdown-account-switcher-list-item-indented" and @title="{country_name}"]'
|
||||
target_country = tab.ele(f'xpath:{target_country_xpath}', timeout=10)
|
||||
if not target_country:
|
||||
print(f"找不到目标国家:{country_name}")
|
||||
return False
|
||||
target_country.click()
|
||||
|
||||
# 步骤5:等待页面加载完成并验证切换结果
|
||||
print(f"等待页面加载...")
|
||||
# time.sleep(3) # 等待页面加载
|
||||
self.tab.wait.doc_loaded()
|
||||
|
||||
# 再次检查当前国家
|
||||
new_country_ele = tab.ele('xpath://div[@class="dropdown-account-switcher-header-label"]/span[last()]',
|
||||
timeout=10)
|
||||
if new_country_ele:
|
||||
new_country = new_country_ele.text.strip()
|
||||
if new_country == country_name:
|
||||
print(f"国家切换成功:{new_country}")
|
||||
return True
|
||||
else:
|
||||
print(f"国家切换失败,当前国家:{new_country},目标国家:{country_name}")
|
||||
return False
|
||||
else:
|
||||
print("无法验证切换结果")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"切换国家时发生异常:{traceback.format_exc()}")
|
||||
return False
|
||||
|
||||
def need_login(self):
|
||||
"""
|
||||
判断是否需要登录,部分国家可能需要登录后才能切换国家
|
||||
处理流程:
|
||||
|
||||
"""
|
||||
time.sleep(3) # 等待页面可能的登录元素加载,避免跳转等等
|
||||
self.tab.wait.doc_loaded(timeout=30, raise_err=False)
|
||||
need_login_ele = self.tab.eles('xpath://h1[@class="a-spacing-small"]|//span[contains(text(),"登录")]',timeout=5)
|
||||
if len(need_login_ele) >0:
|
||||
print("检测到需要登录元素")
|
||||
return True
|
||||
return False
|
||||
|
||||
def login(self,password,username=""):
|
||||
try:
|
||||
pwd_input = self.tab.eles('xpath://input[@type="password"]',timeout=10)
|
||||
if len(pwd_input) > 0:
|
||||
pwd_input[0].input(password,clear=True)
|
||||
submit_btn = self.tab.eles('xpath://input[@id="signInSubmit"]',timeout=10)
|
||||
if len(submit_btn) > 0:
|
||||
submit_btn[0].click()
|
||||
self.tab.wait.doc_loaded()
|
||||
|
||||
opt_code_input = self.tab.ele('xpath://input[@name="otpCode"]',timeout=60)
|
||||
opt_code_input.wait.displayed(timeout=10,raise_err=False)
|
||||
for _ in range(30):
|
||||
if opt_code_input.value is not None and opt_code_input.value.strip() != "":
|
||||
print("检测到验证码输入完成")
|
||||
submit_btn = self.tab.ele('xpath://input[@id="auth-signin-button"]',timeout=10)
|
||||
submit_btn.click()
|
||||
self.tab.wait.doc_loaded()
|
||||
return True
|
||||
time.sleep(1)
|
||||
submit_btn = self.tab.ele('xpath://input[@id="auth-signin-button"]',timeout=10)
|
||||
submit_btn.click()
|
||||
except Exception as e:
|
||||
print("登录过程中发生异常:" + traceback.format_exc())
|
||||
return False
|
||||
|
||||
class AmazoneDriver(AmamzonBase):
|
||||
"""亚马逊专用驱动类,继承自ZiniaoDriver"""
|
||||
|
||||
def SwitchPage(self):
|
||||
"""
|
||||
切换至 管理所有库存页面
|
||||
1、等待 //navigation-favorites-bar[@class="hydrated"] 出现
|
||||
"""
|
||||
navigation = self.tab.ele('xpath://navigation-favorites-bar[@class="hydrated"]')
|
||||
navigation.wait.displayed(raise_err=False)
|
||||
page_btn = navigation.sr('xpath://internal-fav-bar-links[@data-internal="navigation"]').sr(
|
||||
'xpath://a[@data-page-id="ezdpc-gui-inventory-mons"]')
|
||||
page_btn.wait.displayed(raise_err=False)
|
||||
page_btn.click(timeout=5)
|
||||
|
||||
self.tab.wait.doc_loaded()
|
||||
# 等待搜索框出现
|
||||
search_region = self.tab.ele('xpath://div[@id="searchBoxContainer"]//kat-input-group')
|
||||
search_region.wait.displayed(raise_err=False)
|
||||
|
||||
def search(self, asin):
|
||||
search_region = self.tab.ele('xpath://div[@id="searchBoxContainer"]//kat-input-group')
|
||||
search_region.wait.displayed(raise_err=False)
|
||||
time.sleep(0.6)
|
||||
search_input = self.tab.ele("xpath://kat-input[contains(@class,'SearchBox-module__searchInput')]").sr(
|
||||
'xpath://span[@class="container"]//input[@part="input"]')
|
||||
search_input.input(asin,clear=True)
|
||||
sku_ls = []
|
||||
for _ in range(3):
|
||||
search_btn = self.tab.ele("xpath://kat-icon[@name='search']")
|
||||
search_btn.click()
|
||||
|
||||
load_ele = self.tab.ele("xpath://div[contains(@class,'Loader-module__loader')]")
|
||||
# load_ele.wait.hidden(timeout=3, raise_err=False)
|
||||
load_ele.wait.deleted(timeout=3, raise_err=False)
|
||||
time.sleep(0.5)
|
||||
|
||||
sku_ls = self.tab.eles("xpath://div[@data-sku]",timeout=3)
|
||||
if len(sku_ls) > 0:
|
||||
break
|
||||
return sku_ls
|
||||
|
||||
def del_action(self, sku_ele):
|
||||
dropdown = sku_ele.ele('xpath:.//kat-dropdown-button[@variant="secondary"]')
|
||||
dropdown.click()
|
||||
time.sleep(1)
|
||||
|
||||
del_btn = dropdown.sr("xpath:.//button[@role='menuitem' and @data-action='DeleteListing']")
|
||||
del_btn.wait.displayed(raise_err=False)
|
||||
del_btn.click()
|
||||
|
||||
confirm_btn = self.tab.ele('xpath://kat-modal[@data-testid="action-modal"]//kat-button[@variant="primary"]')
|
||||
confirm_btn.wait.displayed(raise_err=False)
|
||||
confirm_btn.wait.enabled()
|
||||
confirm_btn.click()
|
||||
|
||||
suc_alert = self.tab.ele("xpath://kat-alert[@variant='success']")
|
||||
suc = suc_alert.wait.displayed(raise_err=False, timeout=5)
|
||||
return suc
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 使用示例
|
||||
user_info = {
|
||||
"company": "rongchuang123",
|
||||
"username": "自动化_Robot",
|
||||
"password": "#20zsg25"
|
||||
}
|
||||
shop_name = "郭亚芳"
|
||||
kill_process("v6")
|
||||
|
||||
# 创建驱动实例
|
||||
driver = AmazoneDriver(user_info)
|
||||
|
||||
# 打开店铺并获取浏览器实例
|
||||
browser = driver.open_shop(shop_name)
|
||||
|
||||
country = "西班牙"
|
||||
asin = "Voanos"
|
||||
|
||||
sw_suc = driver.SwitchingCountries(country)
|
||||
driver.SwitchPage()
|
||||
sku_ls = driver.search(asin=asin)
|
||||
print("查询结果有:", len(sku_ls))
|
||||
|
||||
for sku in sku_ls:
|
||||
suc = driver.del_action(sku)
|
||||
print(sku, "删除结果", suc)
|
||||
|
||||
|
||||
|
||||
@@ -1,685 +0,0 @@
|
||||
import time
|
||||
import traceback
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from config import JSON_TASK_QUEUE, runing_task, runing_shop, DELETE_BRAND_API_BASE, ZN_COMPANY, ZN_USERNAME, ZN_PASSWORD
|
||||
from amazon.del_brand import AmazoneDriver, kill_process
|
||||
from amazon.approve import ApproveTask
|
||||
from amazon.match_action import MatchTak
|
||||
|
||||
|
||||
from amazon.tool import get_shop_info,show_notification
|
||||
|
||||
|
||||
class TaskMonitor:
|
||||
"""任务监控器:负责监控队列并执行品牌删除任务"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化任务监控器"""
|
||||
self.running = True
|
||||
self.user_info = {
|
||||
"company": ZN_COMPANY,
|
||||
"username": ZN_USERNAME,
|
||||
"password": ZN_PASSWORD
|
||||
}
|
||||
|
||||
self.chunk_index = 1 # 当前处理的分块索引
|
||||
self.max_workers = 5 # 线程池最大线程数
|
||||
self.executor = None # 线程池执行器
|
||||
|
||||
# 在提交新任务前杀掉旧进程(确保环境干净)
|
||||
kill_process("v6")
|
||||
kill_process("v5")
|
||||
|
||||
def log(self, message: str, level: str = "INFO"):
|
||||
"""日志输出
|
||||
|
||||
Args:
|
||||
message: 日志消息
|
||||
level: 日志级别
|
||||
"""
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
print(f"[{timestamp}] [{level}] {message}")
|
||||
|
||||
def start(self):
|
||||
"""启动任务监控(使用线程池处理任务)"""
|
||||
self.log(f"任务监控器启动,开始监听队列... (线程池大小: {self.max_workers})")
|
||||
|
||||
# 创建线程池
|
||||
self.executor = ThreadPoolExecutor(max_workers=self.max_workers)
|
||||
futures = [] # 保存所有提交的任务Future对象
|
||||
|
||||
task_type_info = {
|
||||
"product-risk-resolve-run" : "产品风险审批",
|
||||
"shop-match-run" : "匹配价格"
|
||||
}
|
||||
try:
|
||||
while self.running:
|
||||
try:
|
||||
# 使用较长超时时间等待任务,减少空等待异常
|
||||
# 超时后继续循环检查self.running状态,避免卡死
|
||||
task_data = JSON_TASK_QUEUE.get(block=True, timeout=30)
|
||||
|
||||
# 检查任务类型
|
||||
task_type = task_data.get("type", "")
|
||||
|
||||
if task_type == "delete-brand-run":
|
||||
# 提交删除品牌任务到线程池
|
||||
self.log(f"接收到【删除品牌】任务,提交到线程池处理...")
|
||||
future = self.executor.submit(self._process_task_wrapper, task_data)
|
||||
futures.append(future)
|
||||
|
||||
elif task_type in task_type_info:
|
||||
# 提交产品风险审批任务到线程池
|
||||
self.log(f"接收到任务,提交到线程池处理...")
|
||||
future = self.executor.submit(self._process_approve_task_wrapper, task_data, task_type_info[task_type])
|
||||
futures.append(future)
|
||||
|
||||
else:
|
||||
self.log(f"未知任务类型: {task_type},跳过", "WARNING")
|
||||
continue
|
||||
|
||||
# 清理已完成的future对象,避免内存累积
|
||||
futures = [f for f in futures if not f.done()]
|
||||
|
||||
except Exception as e:
|
||||
# 静默处理队列为空的超时,只记录真正的异常
|
||||
if str(e) and "Empty" not in str(e):
|
||||
self.log(f"任务监控异常: {str(e)}", "ERROR")
|
||||
# 队列为空时不需要额外等待,直接继续循环
|
||||
|
||||
finally:
|
||||
# 关闭监控时,等待所有任务完成
|
||||
self.log("正在关闭任务监控器,等待所有任务完成...")
|
||||
if self.executor:
|
||||
self.executor.shutdown(wait=True)
|
||||
self.log("所有任务已完成,监控器已关闭")
|
||||
|
||||
def _process_task_wrapper(self, task_data: Dict[str, Any]):
|
||||
"""任务处理包装器(用于线程池调用)
|
||||
|
||||
Args:
|
||||
task_data: 任务数据
|
||||
"""
|
||||
try:
|
||||
self.log(f"线程 {id(task_data)} 开始处理删除品牌任务...")
|
||||
self.process_task(task_data)
|
||||
self.log(f"线程 {id(task_data)} 删除品牌任务处理完成")
|
||||
except Exception as e:
|
||||
self.log(f"线程 {id(task_data)} 删除品牌任务处理异常: {traceback.format_exc()}", "ERROR")
|
||||
|
||||
def _process_approve_task_wrapper(self, task_data: Dict[str, Any],TASK_TYPE:str):
|
||||
"""审批任务处理包装器(用于线程池调用)
|
||||
|
||||
Args:
|
||||
task_data: 任务数据
|
||||
"""
|
||||
try:
|
||||
TASK_INFO = {
|
||||
"产品风险审批" : ApproveTask,
|
||||
"匹配价格" : MatchTak
|
||||
}
|
||||
self.log(f"线程 {id(task_data)} 开始处理产品风险审批任务...")
|
||||
# 创建ApproveTask实例并处理任务
|
||||
TASK_CLS = TASK_INFO[TASK_TYPE] # 根据任务类型选择处理类,默认为ApproveTask
|
||||
approve_task = TASK_CLS(user_info=self.user_info)
|
||||
approve_task.process_task(task_data)
|
||||
self.log(f"线程 {id(task_data)} 产品风险审批任务处理完成")
|
||||
except Exception as e:
|
||||
self.log(f"线程 {id(task_data)} 产品风险审批任务处理异常: {traceback.format_exc()}", "ERROR")
|
||||
|
||||
|
||||
def process_task(self, task_data: Dict[str, Any]):
|
||||
"""处理单个任务
|
||||
|
||||
Args:
|
||||
task_data: 任务数据
|
||||
"""
|
||||
try:
|
||||
data = task_data.get("data", {})
|
||||
task_id = data.get("taskId")
|
||||
items = data.get("items", [])
|
||||
|
||||
if not task_id:
|
||||
self.log("任务ID为空,跳过", "WARNING")
|
||||
return
|
||||
|
||||
# 初始化任务状态
|
||||
self.update_task_status(
|
||||
task_id,
|
||||
status="running",
|
||||
start_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
total_shops=len(items),
|
||||
processed_shops=0,
|
||||
total_asins=sum(shop.get("totalRows", 0) for shop in items),
|
||||
processed_asins=0,
|
||||
success_count=0,
|
||||
failed_count=0,
|
||||
stop_requested=False # 暂停请求标志
|
||||
)
|
||||
|
||||
self.log(f"开始处理任务 {task_id},共 {len(items)} 个店铺")
|
||||
|
||||
# 遍历处理每个店铺
|
||||
for idx, shop_data in enumerate(items, 1):
|
||||
shop_name = shop_data.get("shopName", "未知店铺")
|
||||
self.log(f"[{idx}/{len(items)}] 开始处理店铺: {shop_name}")
|
||||
|
||||
try:
|
||||
self.process_shop(shop_data, task_id)
|
||||
# 更新已处理店铺数
|
||||
if task_id in runing_task:
|
||||
runing_task[task_id]["processed_shops"] += 1
|
||||
except Exception as e:
|
||||
self.log(f"处理店铺 {shop_name} 失败: {str(e)}", "ERROR")
|
||||
self.log(traceback.format_exc(), "ERROR")
|
||||
|
||||
# 检查任务最终状态
|
||||
if task_id in runing_task and runing_task[task_id].get("stop_requested", False):
|
||||
self.update_task_status(task_id, status="stopped")
|
||||
self.log(f"任务 {task_id} 已被暂停!")
|
||||
else:
|
||||
self.update_task_status(task_id, status="completed")
|
||||
self.log(f"任务 {task_id} 处理完成!")
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"任务处理失败: {traceback.format_exc()}", "ERROR")
|
||||
if task_id:
|
||||
self.update_task_status(task_id, status="failed", error=str(e))
|
||||
|
||||
def process_shop(self, shop_data: Dict[str, Any], task_id: int):
|
||||
"""处理单个店铺(包含重试逻辑)
|
||||
|
||||
Args:
|
||||
shop_data: 店铺数据
|
||||
task_id: 任务ID
|
||||
"""
|
||||
shop_name = shop_data.get("shopName", "未知店铺")
|
||||
result_id = shop_data.get("resultId")
|
||||
countries = shop_data.get("countries", [])
|
||||
company_name = shop_data.get("companyName", "未知公司")
|
||||
|
||||
if not countries:
|
||||
self.log(f"店铺 {shop_name} 没有国家数据,跳过", "WARNING")
|
||||
return
|
||||
|
||||
# 更新当前处理的店铺
|
||||
self.update_task_status(task_id, current_shop=shop_name)
|
||||
|
||||
# 将店铺添加到正在执行中的店铺列表
|
||||
start_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
runing_shop[shop_name] = start_time
|
||||
self.log(f"账号:{company_name},店铺 {shop_name} 已添加到执行列表,开始时间: {start_time}")
|
||||
|
||||
# 店铺打开重试最多3次
|
||||
driver = None
|
||||
browser = None
|
||||
max_retries = 3
|
||||
|
||||
for retry in range(max_retries):
|
||||
try:
|
||||
self.log(f"尝试打开店铺 {shop_name} (第 {retry + 1}/{max_retries} 次)")
|
||||
|
||||
# 如果不是第一次尝试,先杀进程
|
||||
# if retry > 0:
|
||||
# self.log("重试前先杀掉浏览器进程...")
|
||||
# kill_process("v6")
|
||||
# kill_process("v5")
|
||||
# time.sleep(2)
|
||||
|
||||
# 创建驱动并打开店铺
|
||||
user_info = {
|
||||
**self.user_info,
|
||||
"company": company_name
|
||||
}
|
||||
driver = AmazoneDriver(user_info)
|
||||
browser = driver.open_shop(shop_name)
|
||||
|
||||
if browser and browser != "店铺不存在":
|
||||
self.log(f"成功打开店铺 {shop_name}")
|
||||
break
|
||||
else:
|
||||
self.log(f"打开店铺失败: {browser}", "WARNING")
|
||||
driver = None
|
||||
|
||||
# 判断是否需要登录
|
||||
driver.tab.wait.doc_loaded(timeout=30, raise_err=False) # 等待页面加载,避免过早判断登录状态
|
||||
need_login = driver.need_login()
|
||||
print("【是否需要登录】:",need_login)
|
||||
if need_login:
|
||||
self.log(f"店铺 {shop_name} 需要登录,正在登录...")
|
||||
# 获取店铺凭证
|
||||
response = get_shop_info(shop_name)
|
||||
print("【获取店铺凭证返回】:",response.text)
|
||||
shop_data = response.json()
|
||||
if not shop_data:
|
||||
mes = f"获取店铺凭证失败,响应数据: {shop_data.get('message', '未知错误')}"
|
||||
self.log(mes, "ERROR")
|
||||
show_notification(mes, "ERROR")
|
||||
continue
|
||||
|
||||
password = shop_data["data"]["password"]
|
||||
|
||||
login_success = driver.login(password)
|
||||
if login_success:
|
||||
self.log(f"店铺 {shop_name} 登录成功,正在重新打开店铺...")
|
||||
browser = driver.open_shop(shop_name)
|
||||
if browser and browser != "店铺不存在":
|
||||
self.log(f"成功打开店铺 {shop_name} 登录后")
|
||||
break
|
||||
else:
|
||||
self.log(f"登录后打开店铺失败: {browser}", "WARNING")
|
||||
driver = None
|
||||
else:
|
||||
self.log(f"店铺 {shop_name} 登录失败", "WARNING")
|
||||
driver = None
|
||||
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"打开店铺异常: {str(e)}", "ERROR")
|
||||
driver = None
|
||||
|
||||
# 如果还有重试机会,等待后继续
|
||||
if retry < max_retries - 1:
|
||||
time.sleep(3)
|
||||
|
||||
# 检查是否成功打开
|
||||
if not driver or not browser or browser == "店铺不存在":
|
||||
self.log(f"店铺 {shop_name} 打开失败,已重试 {max_retries} 次,跳过该店铺", "ERROR")
|
||||
return
|
||||
|
||||
try:
|
||||
# 处理每个国家
|
||||
chunk_index =1
|
||||
for country_data in countries:
|
||||
# 检查是否收到暂停请求
|
||||
if task_id in runing_task and runing_task[task_id].get("stop_requested", False):
|
||||
self.log(f"检测到任务 {task_id} 的暂停请求,停止处理国家", "WARNING")
|
||||
break # 跳出循环,进入finally关闭店铺
|
||||
|
||||
try:
|
||||
chunk_index = self.process_country(driver, country_data, task_id, result_id, shop_data,chunk_index)
|
||||
except Exception as e:
|
||||
country_name = country_data.get("country", "未知")
|
||||
self.log(f"处理国家 {country_name} 失败: {str(e)}", "ERROR")
|
||||
self.log(traceback.format_exc(), "ERROR")
|
||||
|
||||
finally:
|
||||
# 关闭店铺
|
||||
try:
|
||||
if driver:
|
||||
self.log(f"关闭店铺 {shop_name}")
|
||||
driver.close_store()
|
||||
time.sleep(2)
|
||||
except Exception as e:
|
||||
self.log(f"关闭店铺失败: {str(e)}", "WARNING")
|
||||
|
||||
# 从正在执行中的店铺列表中移除
|
||||
if shop_name in runing_shop:
|
||||
del runing_shop[shop_name]
|
||||
self.log(f"店铺 {shop_name} 已从执行列表中移除")
|
||||
|
||||
def process_country(self, driver: AmazoneDriver, country_data: Dict[str, Any],
|
||||
task_id: int, result_id: int, shop_data: Dict[str, Any],chunk_index:int):
|
||||
"""处理单个国家的所有ASIN
|
||||
|
||||
Args:
|
||||
driver: 亚马逊驱动实例
|
||||
country_data: 国家数据
|
||||
task_id: 任务ID
|
||||
result_id: 结果ID
|
||||
shop_data: 店铺数据
|
||||
"""
|
||||
country = country_data.get("country", "未知")
|
||||
items = country_data.get("items", [])
|
||||
|
||||
self.log(f"开始处理国家: {country},共 {len(items)} 个ASIN")
|
||||
self.update_task_status(task_id, current_country=country)
|
||||
|
||||
# 切换国家,最多重试3次
|
||||
max_retries = 3
|
||||
switch_success = False
|
||||
|
||||
for retry in range(max_retries):
|
||||
try:
|
||||
self.log(f"尝试切换到国家 {country} (第 {retry + 1}/{max_retries} 次)")
|
||||
|
||||
# 如果不是第一次尝试,先刷新页面
|
||||
if retry > 0:
|
||||
self.log("重试前刷新页面...")
|
||||
try:
|
||||
driver.tab.refresh()
|
||||
time.sleep(3)
|
||||
except Exception as e:
|
||||
self.log(f"刷新页面失败: {str(e)}", "WARNING")
|
||||
|
||||
switch_success = driver.SwitchingCountries(country)
|
||||
if switch_success:
|
||||
self.log(f"成功切换到国家 {country}")
|
||||
break
|
||||
else:
|
||||
self.log(f"切换到国家 {country} 失败", "WARNING")
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"切换国家 {country} 异常: {str(e)}", "ERROR")
|
||||
|
||||
# 如果还有重试机会,等待后继续
|
||||
if retry < max_retries - 1:
|
||||
time.sleep(2)
|
||||
|
||||
# 如果切换失败,回传该国家所有ASIN为失败状态
|
||||
if not switch_success:
|
||||
self.log(f"切换到国家 {country} 失败,已重试 {max_retries} 次,将所有ASIN标记为失败", "ERROR")
|
||||
chunk_index = self._report_all_asins_failed(country, items, task_id, shop_data,chunk_index)
|
||||
return chunk_index
|
||||
|
||||
# 切换到库存管理页面
|
||||
try:
|
||||
driver.SwitchPage()
|
||||
self.log(f"已切换到库存管理页面")
|
||||
except Exception as e:
|
||||
self.log(f"切换页面失败: {str(e)}", "ERROR")
|
||||
# 切换页面失败也回传所有ASIN为失败
|
||||
chunk_index = self._report_all_asins_failed(country, items, task_id, shop_data,chunk_index)
|
||||
return chunk_index
|
||||
|
||||
# 处理每个ASIN
|
||||
file_key = shop_data.get("fileKey", "")
|
||||
source_filename = shop_data.get("sourceFilename", "")
|
||||
total_rows = shop_data.get("totalRows", 0)
|
||||
|
||||
for idx, asin_item in enumerate(items, 1):
|
||||
# 检查是否收到暂停请求
|
||||
if task_id in runing_task and runing_task[task_id].get("stop_requested", False):
|
||||
self.log(f"检测到任务 {task_id} 的暂停请求,停止处理", "WARNING")
|
||||
return # 返回到上层,会触发店铺关闭
|
||||
|
||||
try:
|
||||
self.log(f"[{idx}/{len(items)}] 处理ASIN: {asin_item.get('asin', '')}")
|
||||
self.process_asin(driver, asin_item, country, task_id, result_id,
|
||||
file_key, source_filename, total_rows,chunk_index)
|
||||
except Exception as e:
|
||||
asin = asin_item.get("asin", "未知")
|
||||
self.log(f"处理ASIN {asin} 失败: {str(e)}", "ERROR")
|
||||
# 继续处理下一个ASIN
|
||||
chunk_index += 1
|
||||
return chunk_index
|
||||
|
||||
|
||||
def process_asin(self, driver: AmazoneDriver, asin_item: Dict[str, Any],
|
||||
country: str, task_id: int, result_id: int, file_key: str,
|
||||
source_filename: str, total_rows: int,chunk_index:int):
|
||||
"""处理单个ASIN并回传结果
|
||||
|
||||
Args:
|
||||
driver: 亚马逊驱动实例
|
||||
asin_item: ASIN数据项
|
||||
country: 国家名称
|
||||
task_id: 任务ID
|
||||
result_id: 结果ID
|
||||
file_key: 文件KEY
|
||||
source_filename: 源文件名
|
||||
total_rows: 总行数
|
||||
chunk_index: 当前处理索引
|
||||
"""
|
||||
asin = asin_item.get("asin", "")
|
||||
row_index = asin_item.get("rowIndex", 0)
|
||||
|
||||
# 更新当前处理的ASIN
|
||||
self.update_task_status(task_id, current_asin=asin)
|
||||
|
||||
status = "失败"
|
||||
max_retries = 3 # 最多重试3次
|
||||
|
||||
for retry in range(max_retries):
|
||||
try:
|
||||
self.log(f"处理ASIN {asin} (第 {retry + 1}/{max_retries} 次)")
|
||||
|
||||
# 如果不是第一次尝试,先刷新页面
|
||||
if retry > 0:
|
||||
self.log("重试前刷新页面...")
|
||||
try:
|
||||
driver.tab.refresh()
|
||||
time.sleep(3)
|
||||
except Exception as e:
|
||||
self.log(f"刷新页面失败: {str(e)}", "WARNING")
|
||||
|
||||
# 搜索ASIN
|
||||
sku_ls = driver.search(asin=asin)
|
||||
self.log(f"搜索到 {len(sku_ls)} 个SKU")
|
||||
|
||||
if len(sku_ls) == 0:
|
||||
status = "查询不到"
|
||||
self.log(f"ASIN {asin} 未找到商品", "WARNING")
|
||||
break # 查询不到商品,无需重试
|
||||
else:
|
||||
# 删除所有找到的SKU,如果任何一个失败则重新开始整个流程
|
||||
success_count = 0
|
||||
all_success = True # 标记是否所有SKU都删除成功
|
||||
total_sku_count = len(sku_ls)
|
||||
|
||||
for sku in sku_ls:
|
||||
try:
|
||||
suc = driver.del_action(sku)
|
||||
if suc:
|
||||
success_count += 1
|
||||
self.log(f"SKU 删除成功 ({success_count}/{total_sku_count})")
|
||||
else:
|
||||
self.log(f"SKU 删除失败", "WARNING")
|
||||
all_success = False
|
||||
break # 任何一个失败,退出循环,准备重试整个流程
|
||||
except Exception as e:
|
||||
self.log(f"删除SKU异常: {str(e)}", "ERROR")
|
||||
all_success = False
|
||||
break # 发生异常,退出循环,准备重试整个流程
|
||||
|
||||
# 判断删除结果
|
||||
if all_success and success_count == total_sku_count:
|
||||
status = "成功"
|
||||
self.log(f"ASIN {asin} 所有SKU删除成功 ({success_count}/{total_sku_count})")
|
||||
# 更新成功计数
|
||||
if task_id in runing_task:
|
||||
runing_task[task_id]["success_count"] += 1
|
||||
break # 全部成功,跳出重试循环
|
||||
else:
|
||||
# 有失败的SKU
|
||||
if retry < max_retries - 1:
|
||||
self.log(f"有SKU删除失败,准备重试整个流程... ({retry + 1}/{max_retries})")
|
||||
time.sleep(2)
|
||||
continue # 继续下一次重试
|
||||
else:
|
||||
# 所有重试都用完了
|
||||
if success_count > 0:
|
||||
status = "部分成功"
|
||||
self.log(f"ASIN {asin} 部分SKU删除成功 ({success_count}/{total_sku_count})", "WARNING")
|
||||
if task_id in runing_task:
|
||||
runing_task[task_id]["success_count"] += 1
|
||||
else:
|
||||
status = "失败"
|
||||
self.log(f"ASIN {asin} 所有SKU删除失败", "ERROR")
|
||||
if task_id in runing_task:
|
||||
runing_task[task_id]["failed_count"] += 1
|
||||
|
||||
except Exception as e:
|
||||
status = "删除异常"
|
||||
self.log(f"处理ASIN {asin} 异常: {str(e)}", "ERROR")
|
||||
|
||||
# 如果还有重试机会,继续重试
|
||||
if retry < max_retries - 1:
|
||||
self.log(f"发生异常,准备重试... ({retry + 1}/{max_retries})")
|
||||
time.sleep(2)
|
||||
else:
|
||||
# 所有重试都失败了,更新失败计数
|
||||
if task_id in runing_task:
|
||||
runing_task[task_id]["failed_count"] += 1
|
||||
|
||||
# 更新已处理ASIN计数
|
||||
if task_id in runing_task:
|
||||
runing_task[task_id]["processed_asins"] += 1
|
||||
|
||||
# 回传结果到API
|
||||
try:
|
||||
payload = {
|
||||
"submissionId": "",
|
||||
"files": [{
|
||||
"fileKey": file_key,
|
||||
"sourceFilename": source_filename,
|
||||
"chunkIndex": chunk_index,
|
||||
"chunkTotal": total_rows,
|
||||
"processedRows": chunk_index,
|
||||
"totalRows": total_rows,
|
||||
"currentCountry": country,
|
||||
"currentAsin": asin,
|
||||
"countries": [{
|
||||
"country": country,
|
||||
"items": [{
|
||||
"asin": asin,
|
||||
"status": status
|
||||
}]
|
||||
}]
|
||||
}]
|
||||
}
|
||||
|
||||
self.post_result(task_id, payload)
|
||||
self.log(f"ASIN {asin} 结果已回传,状态: {status}")
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"回传结果失败: {str(e)}", "ERROR")
|
||||
|
||||
def _report_all_asins_failed(self, country: str, items: List[Dict[str, Any]],
|
||||
task_id: int, shop_data: Dict[str, Any],chunk_index:int):
|
||||
"""将国家下所有ASIN标记为失败并回传
|
||||
|
||||
Args:
|
||||
country: 国家名称
|
||||
items: ASIN列表
|
||||
task_id: 任务ID
|
||||
shop_data: 店铺数据
|
||||
"""
|
||||
self.log(f"开始回传国家 {country} 下的 {len(items)} 个ASIN失败状态")
|
||||
|
||||
file_key = shop_data.get("fileKey", "")
|
||||
source_filename = shop_data.get("sourceFilename", "")
|
||||
total_rows = shop_data.get("totalRows", 0)
|
||||
|
||||
for asin_item in items:
|
||||
asin = asin_item.get("asin", "")
|
||||
|
||||
try:
|
||||
# 更新失败计数
|
||||
if task_id in runing_task:
|
||||
runing_task[task_id]["failed_count"] += 1
|
||||
runing_task[task_id]["processed_asins"] += 1
|
||||
|
||||
# 回传失败状态
|
||||
payload = {
|
||||
"submissionId": "",
|
||||
"files": [{
|
||||
"fileKey": file_key,
|
||||
"sourceFilename": source_filename,
|
||||
"chunkIndex": chunk_index,
|
||||
"chunkTotal": total_rows,
|
||||
"processedRows": chunk_index,
|
||||
"totalRows": total_rows,
|
||||
"currentCountry": country,
|
||||
"currentAsin": asin,
|
||||
"countries": [{
|
||||
"country": country,
|
||||
"items": [{
|
||||
"asin": asin,
|
||||
"status": "失败"
|
||||
}]
|
||||
}]
|
||||
}]
|
||||
}
|
||||
|
||||
self.post_result(task_id, payload)
|
||||
chunk_index += 1
|
||||
self.log(f"ASIN {asin} 失败状态已回传")
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"回传ASIN {asin} 失败状态时出错: {str(e)}", "ERROR")
|
||||
|
||||
return chunk_index
|
||||
|
||||
def post_result(self, task_id: int, payload: Dict[str, Any]):
|
||||
"""回传结果到API(带重试机制)
|
||||
|
||||
Args:
|
||||
task_id: 任务ID
|
||||
payload: 结果数据
|
||||
"""
|
||||
url = f"{DELETE_BRAND_API_BASE}/api/delete-brand/tasks/{task_id}/result"
|
||||
max_retries = 3 # 最多重试3次
|
||||
|
||||
for retry in range(max_retries):
|
||||
try:
|
||||
self.log(f"尝试回传结果 (第 {retry + 1}/{max_retries} 次)")
|
||||
|
||||
response = requests.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=30,
|
||||
verify=False # 忽略SSL证书验证
|
||||
)
|
||||
print("【结果提交】:",payload)
|
||||
print("【结果提交返回】:",response.text)
|
||||
|
||||
if response.status_code == 200:
|
||||
self.log(f"结果回传成功: {url}")
|
||||
return # 成功后直接返回,不再重试
|
||||
else:
|
||||
self.log(f"结果回传失败,状态码: {response.status_code}", "WARNING")
|
||||
# 如果还有重试机会,继续重试
|
||||
if retry < max_retries - 1:
|
||||
self.log(f"准备重试... ({retry + 1}/{max_retries})")
|
||||
time.sleep(2) # 等待2秒后重试
|
||||
else:
|
||||
self.log(f"已达到最大重试次数,结果回传最终失败", "ERROR")
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"调用API异常: {str(e)}", "ERROR")
|
||||
# 如果还有重试机会,继续重试
|
||||
if retry < max_retries - 1:
|
||||
self.log(f"发生异常,准备重试... ({retry + 1}/{max_retries})")
|
||||
time.sleep(2) # 等待2秒后重试
|
||||
else:
|
||||
self.log(f"已达到最大重试次数,结果回传最终失败", "ERROR")
|
||||
|
||||
def update_task_status(self, task_id: int, **kwargs):
|
||||
"""更新任务状态
|
||||
|
||||
Args:
|
||||
task_id: 任务ID
|
||||
**kwargs: 要更新的字段
|
||||
"""
|
||||
if task_id not in runing_task:
|
||||
runing_task[task_id] = {}
|
||||
|
||||
runing_task[task_id].update(kwargs)
|
||||
|
||||
def stop(self):
|
||||
"""停止监控"""
|
||||
self.running = False
|
||||
self.log("任务监控器已停止")
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数:启动任务监控"""
|
||||
# 创建并启动任务监控器
|
||||
monitor = TaskMonitor()
|
||||
|
||||
try:
|
||||
monitor.start()
|
||||
except KeyboardInterrupt:
|
||||
monitor.log("接收到中断信号,正在停止...")
|
||||
monitor.stop()
|
||||
except Exception:
|
||||
monitor.log(f"监控器异常退出: {traceback.format_exc()}", "ERROR")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,717 +0,0 @@
|
||||
import time
|
||||
import re
|
||||
import traceback
|
||||
from DrissionPage._pages.chromium_tab import ChromiumTab
|
||||
|
||||
from config import runing_task, runing_shop
|
||||
from datetime import datetime
|
||||
from amazon.del_brand import AmamzonBase, kill_process
|
||||
|
||||
from amazon.tool import show_notification,get_shop_info
|
||||
|
||||
|
||||
class AmzoneMatchAction(AmamzonBase):
|
||||
mark_name = "匹配价格"
|
||||
|
||||
def SwitchPage(self):
|
||||
"""
|
||||
切换至 管理所有库存页面
|
||||
1、等待 //navigation-favorites-bar[@class="hydrated"] 出现
|
||||
"""
|
||||
navigation = self.tab.ele('xpath://navigation-favorites-bar[@class="hydrated"]')
|
||||
navigation.wait.displayed(raise_err=False)
|
||||
page_btn = navigation.sr('xpath://internal-fav-bar-links[@data-internal="navigation"]').sr(
|
||||
'xpath://a[@data-page-id="ezdpc-gui-inventory-mons"]')
|
||||
page_btn.wait.displayed(raise_err=False)
|
||||
page_btn.click(timeout=5)
|
||||
|
||||
self.tab.wait.doc_loaded()
|
||||
# 等待搜索框出现
|
||||
search_region = self.tab.ele('xpath://div[@id="searchBoxContainer"]//kat-input-group')
|
||||
search_region.wait.displayed(raise_err=False)
|
||||
|
||||
def search(self,filter_type="ApprovalRequired"):
|
||||
sku_ls = []
|
||||
|
||||
load_ele = self.tab.eles("xpath://div[contains(@class,'Loader-module__loader')]",timeout=5)
|
||||
if len(load_ele) > 0:
|
||||
load_ele[0].wait.deleted(timeout=3, raise_err=False)
|
||||
time.sleep(0.5)
|
||||
|
||||
drop_down = self.tab.ele('xpath://div[contains(@class,"VolusListingStatusDropDown-module__verticalContainer")]//kat-dropdown')
|
||||
drop_down.wait.displayed(raise_err=False)
|
||||
drop_down.wait.enabled(raise_err=False)
|
||||
time.sleep(0.6)
|
||||
drop_down.click()
|
||||
# //kat-option[@value="SearchSuppressed"]
|
||||
xp = f'xpath://kat-option[@value="{filter_type}"]'
|
||||
print(f"【{self.mark_name}】正在寻找筛选条件 {filter_type},xpath: {xp}")
|
||||
approval_required = self.tab.eles(xp,timeout=5)
|
||||
if len(approval_required) == 0:
|
||||
print(f"【{self.mark_name}】没有需要{filter_type}选项】没有需要{filter_type}的商品了")
|
||||
return sku_ls # "没有需要审批的商品了"
|
||||
else:
|
||||
approval_required = approval_required[0]
|
||||
approval_required.wait.displayed(raise_err=False)
|
||||
approval_required.click()
|
||||
|
||||
approval_required_text = approval_required.text
|
||||
print(f"【{self.mark_name}】已选择筛选条件: {approval_required_text}")
|
||||
|
||||
count = re.findall(r'\d+', approval_required_text)
|
||||
if count:
|
||||
count = int(count[0])
|
||||
print(f"【{self.mark_name}】待审批的商品数量: {count}")
|
||||
if count <= 0:
|
||||
print(f"【{self.mark_name}】没有需要{filter_type}的商品了")
|
||||
return sku_ls #"没有需要审批的商品了"
|
||||
for _ in range(3):
|
||||
# 等待加载完成
|
||||
load_ele = self.tab.eles("xpath://div[contains(@class,'Loader-module__loader')]")
|
||||
if len(load_ele) > 0:
|
||||
load_ele[0].wait.deleted(timeout=3, raise_err=False)
|
||||
time.sleep(0.5)
|
||||
|
||||
sku_ls = self.tab.eles("xpath://div[@data-sku]",timeout=3)
|
||||
if len(sku_ls) > 0:
|
||||
break
|
||||
approval_required.click()
|
||||
return sku_ls
|
||||
|
||||
def wait_loaded(self):
|
||||
# 等待加载完成
|
||||
try:
|
||||
load_ele = self.tab.ele(
|
||||
'xpath://kat-panel[@data-testid="kat-panel-ActionPanelContent"]//div[contains(@class,"Loader-module__loader")]|//kat-panel[@data-testid="kat-panel-ActionPanelContent"]/div[@data-f1-component]//div[contains(@class,"==")]/div[contains(@class,"==")]/span',
|
||||
timeout=3)
|
||||
load_ele.wait.deleted(timeout=5, raise_err=False)
|
||||
except Exception as e:
|
||||
print(f"【{self.mark_name}】等待加载中消失出错", e)
|
||||
|
||||
def run_page_action(self):
|
||||
print(f"【{self.mark_name}】开始执行")
|
||||
num = 0
|
||||
retry_num = 0
|
||||
already_asin = set()
|
||||
|
||||
while retry_num < 3: # 最多重试3次
|
||||
# if num > 3: #测试
|
||||
# return
|
||||
# 等待加载完成
|
||||
try:
|
||||
load_ele = self.tab.eles("xpath://div[contains(@class,'Loader-module__loader')]",timeout=5)
|
||||
if len(load_ele) > 0:
|
||||
load_ele[0].wait.deleted(timeout=3, raise_err=False)
|
||||
time.sleep(0.5)
|
||||
|
||||
# 获取当前页码
|
||||
try:
|
||||
page_pamel = self.tab.eles('xpath://kat-pagination',timeout=5)
|
||||
if len(page_pamel) > 0:
|
||||
current_page = page_pamel[0].sr('xpath:.//ul[@class="pages"]//li[@aria-current="true"]').text
|
||||
|
||||
# 总页数
|
||||
total_page = page_pamel[0].sr.eles('xpath:.//ul[@class="pages"]//span[@class="page__inner"][last()]')
|
||||
if len(total_page) > 0:
|
||||
total_page = total_page[-1].text
|
||||
else:
|
||||
total_page = 0
|
||||
print(f"【{self.mark_name}】当前页码: {current_page} / 总页数: {total_page}")
|
||||
except Exception as e:
|
||||
print(f"【{self.mark_name}】获取页码失败", e)
|
||||
|
||||
|
||||
sku_ls = self.tab.eles("xpath://div[@data-sku]",timeout=10)
|
||||
print(f"【{self.mark_name}】获取到 {len(sku_ls)}")
|
||||
# for sku_ele in sku_ls[0:2]:
|
||||
for sku_ele in sku_ls:
|
||||
# solve_problem = sku_ele.eles('xpath:.//kat-link[@label="解决商品信息问题"]')
|
||||
|
||||
asin = sku_ele.ele('xpath:.//div[contains(@class,"JanusSplitBox-module__container")]//div[contains(@class,"JanusSplitBox-module__panel--") and contains(string(.),"ASIN")]/..//div[last()]',timeout=10).text
|
||||
print(f"【{self.mark_name}】ASIN {asin} 找到....")
|
||||
if asin in already_asin:
|
||||
print(f"【{self.mark_name}】{asin} 已经处理过了,跳过")
|
||||
continue
|
||||
|
||||
price_match = sku_ele.eles('xpath:.//div[@data-test-id="FeaturedOfferPrice"]//a[text()="匹配"]',timeout=5)
|
||||
if len(price_match) == 0:
|
||||
print(f"【{self.mark_name}】{asin},没有推荐价格匹配按钮")
|
||||
yield (asin,"无需处理")
|
||||
already_asin.add(asin)
|
||||
continue
|
||||
try:
|
||||
price_match[0].click()
|
||||
|
||||
save_all_btn = self.tab.ele('xpath://kat-button[@label="保存所有"]',timeout=10)
|
||||
save_all_btn.wait.displayed(timeout=10, raise_err=False)
|
||||
save_all_btn.click()
|
||||
|
||||
save_all_btn.wait.deleted(timeout=10, raise_err=False)
|
||||
|
||||
yield (asin,"处理完成")
|
||||
already_asin.add(asin)
|
||||
except Exception as e:
|
||||
print(f"{asin} 处理失败", e)
|
||||
yield (asin,"处理失败")
|
||||
already_asin.add(asin)
|
||||
continue
|
||||
|
||||
|
||||
# 判断是否存在需要翻页的情况
|
||||
page_pamel = self.tab.eles('xpath://kat-pagination',timeout=5)
|
||||
if len(page_pamel) == 0:
|
||||
break
|
||||
next_page_btn = page_pamel[0].sr('xpath:.//span[@part="pagination-nav-right"]')
|
||||
class_str = next_page_btn.attr('class')
|
||||
if "end" in class_str:
|
||||
break
|
||||
next_page_btn.click()
|
||||
num += 1
|
||||
print(f"【{self.mark_name}】【程序计算】正在翻页,已翻 {num} 页...")
|
||||
|
||||
already_asin = set()
|
||||
|
||||
except Exception as e:
|
||||
print(f"【{self.mark_name}】处理匹配操作异常", e)
|
||||
traceback.print_exc()
|
||||
retry_num += 1
|
||||
self.tab.refresh()
|
||||
self.tab.wait.doc_loaded(raise_err=False,timeout=120)
|
||||
|
||||
|
||||
|
||||
class MatchTak:
|
||||
|
||||
country_info = {
|
||||
"DE": "德国",
|
||||
"FR": "法国",
|
||||
"ES": "西班牙",
|
||||
"IT": "意大利",
|
||||
"UK": "英国"
|
||||
}
|
||||
|
||||
|
||||
def __init__(self, user_info: dict = None):
|
||||
"""初始化审批任务处理器
|
||||
|
||||
Args:
|
||||
user_info: 用户信息字典,包含 company, username, password
|
||||
"""
|
||||
self.user_info = user_info or {}
|
||||
self.running = True
|
||||
|
||||
def log(self, message: str, level: str = "INFO"):
|
||||
"""日志输出
|
||||
|
||||
Args:
|
||||
message: 日志消息
|
||||
level: 日志级别
|
||||
"""
|
||||
from datetime import datetime
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
if level == "ERROR":
|
||||
show_notification(message, "error")
|
||||
print(f"[{timestamp}] [MatchTak] [{level}] {message}")
|
||||
|
||||
|
||||
def process_task(self, task_data: dict):
|
||||
"""处理审批任务主入口
|
||||
|
||||
Args:
|
||||
task_data: 任务数据
|
||||
"""
|
||||
try:
|
||||
data = task_data.get("data", {})
|
||||
task_id = data.get("taskId")
|
||||
items = data.get("items", [])
|
||||
country_codes = data.get("country_codes", [])
|
||||
risk_listing_filter = data.get("risk_listing_filter", "All")
|
||||
user_id = data.get("user_id")
|
||||
stage_index = data.get("stage_index")
|
||||
final_stage = bool(data.get("final_stage", True))
|
||||
|
||||
if not task_id:
|
||||
self.log("任务ID为空,跳过", "WARNING")
|
||||
return
|
||||
|
||||
if not items:
|
||||
self.log("店铺列表为空,跳过", "WARNING")
|
||||
return
|
||||
|
||||
if not country_codes:
|
||||
self.log("国家列表为空,跳过", "WARNING")
|
||||
return
|
||||
|
||||
self.log(f"开始处理审批任务 {task_id},共 {len(items)} 个店铺,{len(country_codes)} 个国家")
|
||||
|
||||
from config import runing_task
|
||||
runing_task[task_id] = {
|
||||
"status": "running",
|
||||
"start_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"total_shops": len(items),
|
||||
"processed_shops": 0,
|
||||
"total_countries": len(country_codes) * len(items),
|
||||
"processed_countries": 0,
|
||||
"total_asins": 0,
|
||||
"processed_asins": 0,
|
||||
"success_count": 0,
|
||||
"failed_count": 0,
|
||||
"stop_requested": False
|
||||
}
|
||||
|
||||
# 遍历处理每个店铺
|
||||
for idx, shop_item in enumerate(items, 1):
|
||||
# 检查是否收到暂停请求
|
||||
if task_id in runing_task and runing_task[task_id].get("stop_requested", False):
|
||||
self.log(f"检测到任务 {task_id} 的暂停请求,停止处理", "WARNING")
|
||||
runing_task[task_id]["status"] = "stopped"
|
||||
return
|
||||
|
||||
shop_name = shop_item.get("shopName", "未知店铺")
|
||||
self.log(f"[{idx}/{len(items)}] 开始处理店铺: {shop_name}")
|
||||
show_notification(f"开始处理店铺: {shop_name}", "info")
|
||||
|
||||
try:
|
||||
self.process_shop(shop_item, country_codes, task_id, risk_listing_filter, user_id, stage_index, final_stage)
|
||||
|
||||
# self.process_shop(shop_item, country_codes, task_id,risk_listing_filter)
|
||||
# 更新已处理店铺数
|
||||
if task_id in runing_task:
|
||||
runing_task[task_id]["processed_shops"] += 1
|
||||
except Exception as e:
|
||||
import traceback
|
||||
self.log(f"处理店铺 {shop_name} 失败: {str(e)}", "ERROR")
|
||||
self.log(traceback.format_exc(), "ERROR")
|
||||
|
||||
# 更新任务状态
|
||||
if task_id in runing_task:
|
||||
if runing_task[task_id].get("stop_requested", False):
|
||||
runing_task[task_id]["status"] = "stopped"
|
||||
self.log(f"任务 {task_id} 已被暂停!")
|
||||
else:
|
||||
runing_task[task_id]["status"] = "completed"
|
||||
self.log(f"任务 {task_id} 处理完成!")
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
self.log(f"任务处理失败: {traceback.format_exc()}", "ERROR")
|
||||
if task_id:
|
||||
from config import runing_task
|
||||
if task_id in runing_task:
|
||||
runing_task[task_id]["status"] = "failed"
|
||||
runing_task[task_id]["error"] = str(e)
|
||||
|
||||
# def process_shop(self, shop_item: dict, country_codes: list, task_id: int, risk_listing_filter: str):
|
||||
def process_shop(self, shop_item: dict, country_codes: list, task_id: int, risk_listing_filter: str, user_id=None, stage_index=None, final_stage: bool = True):
|
||||
"""处理单个店铺
|
||||
|
||||
Args:
|
||||
shop_item: 店铺信息
|
||||
country_codes: 国家代码列表
|
||||
task_id: 任务ID
|
||||
risk_listing_filter: 风险商品筛选条件
|
||||
"""
|
||||
shop_name = shop_item.get("shopName", "未知店铺")
|
||||
company_name = shop_item.get("companyName", "")
|
||||
|
||||
if not company_name:
|
||||
self.log(f"店铺 {shop_name} 的公司名称为空,跳过", "WARNING")
|
||||
return
|
||||
|
||||
|
||||
if task_id in runing_task:
|
||||
runing_task[task_id]["current_shop"] = shop_name
|
||||
|
||||
# 将店铺添加到正在执行中的店铺列表
|
||||
start_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
runing_shop[shop_name] = start_time
|
||||
self.log(f"店铺 {shop_name} 已添加到执行列表,账号: {company_name},开始时间: {start_time}")
|
||||
|
||||
# 店铺打开重试最多3次
|
||||
driver = None
|
||||
max_retries = 3
|
||||
|
||||
error_info = ""
|
||||
for retry in range(max_retries):
|
||||
try:
|
||||
self.log(f"尝试打开店铺 {shop_name} (第 {retry + 1}/{max_retries} 次)")
|
||||
|
||||
# 如果不是第一次尝试,先杀进程
|
||||
# if retry > 0:
|
||||
# self.log("重试前先杀掉浏览器进程...")
|
||||
# kill_process("v6")
|
||||
# kill_process("v5")
|
||||
# time.sleep(2)
|
||||
|
||||
# 组装用户信息并创建驱动
|
||||
user_info = {
|
||||
**self.user_info,
|
||||
"company": company_name
|
||||
}
|
||||
driver = AmzoneMatchAction(user_info)
|
||||
browser = driver.open_shop(shop_name)
|
||||
|
||||
if browser and browser != "店铺不存在":
|
||||
self.log(f"成功打开店铺 {shop_name}")
|
||||
else:
|
||||
self.log(f"打开店铺失败: {browser}", "WARNING")
|
||||
driver = None
|
||||
continue
|
||||
|
||||
# 判断是否需要登录
|
||||
need_login = driver.need_login()
|
||||
print("【是否需要登录】:",need_login)
|
||||
if need_login:
|
||||
self.log(f"店铺 {shop_name} 需要登录,正在登录...")
|
||||
# 获取店铺凭证
|
||||
response = get_shop_info(shop_name)
|
||||
print("【获取店铺凭证返回】:",response.text)
|
||||
shop_data = response.json()
|
||||
if not shop_data:
|
||||
mes = f"获取店铺凭证失败,响应数据: {shop_data.get('message', '未知错误')}"
|
||||
self.log(mes, "ERROR")
|
||||
show_notification(mes, "ERROR")
|
||||
continue
|
||||
|
||||
password = shop_data["data"]["password"]
|
||||
|
||||
login_success = driver.login(password)
|
||||
if login_success:
|
||||
self.log(f"店铺 {shop_name} 登录成功,正在重新打开店铺...")
|
||||
browser = driver.open_shop(shop_name)
|
||||
if browser and browser != "店铺不存在":
|
||||
self.log(f"成功打开店铺 {shop_name} 登录后")
|
||||
break
|
||||
else:
|
||||
self.log(f"登录后打开店铺失败: {browser}", "WARNING")
|
||||
driver = None
|
||||
else:
|
||||
self.log(f"店铺 {shop_name} 登录失败", "WARNING")
|
||||
driver = None
|
||||
else:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
self.log(f"打开店铺异常: {traceback.format_exc()}", "INFO")
|
||||
driver = None
|
||||
error_info = str(e)
|
||||
time.sleep(10)
|
||||
|
||||
# 如果还有重试机会,等待后继续
|
||||
if retry < max_retries - 1:
|
||||
time.sleep(3)
|
||||
|
||||
# 检查是否成功打开
|
||||
if not driver or not browser or browser == "店铺不存在":
|
||||
error_msg = f"店铺 {shop_name} 打开失败,已重试 {max_retries} 次,跳过该店铺,{error_info}"
|
||||
self.log(error_msg, "ERROR")
|
||||
# 从执行列表中移除
|
||||
if shop_name in runing_shop:
|
||||
del runing_shop[shop_name]
|
||||
return
|
||||
|
||||
try:
|
||||
# 处理每个国家
|
||||
for country_code in country_codes:
|
||||
# 检查是否收到暂停请求
|
||||
if task_id in runing_task and runing_task[task_id].get("stop_requested", False):
|
||||
self.log(f"检测到任务 {task_id} 的暂停请求,停止处理国家", "WARNING")
|
||||
break
|
||||
|
||||
try:
|
||||
self.process_country(driver, country_code, task_id, shop_name,risk_listing_filter)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
self.log(f"处理国家 {country_code} 失败: {str(e)}", "ERROR")
|
||||
self.log(traceback.format_exc(), "ERROR")
|
||||
# 最后回传,标记完成
|
||||
try:
|
||||
# self.post_result(task_id, shop_name, country_code, "", "", is_done=True)
|
||||
if final_stage:
|
||||
self.post_result(task_id, shop_name, country_code, "", "", is_done=True)
|
||||
else:
|
||||
self.post_stage_finished(task_id, user_id, stage_index)
|
||||
except Exception as e:
|
||||
self.log(f"回传结果失败: {str(e)}", "ERROR")
|
||||
finally:
|
||||
# 关闭店铺
|
||||
try:
|
||||
if driver:
|
||||
self.log(f"关闭店铺 {shop_name}")
|
||||
driver.close_store()
|
||||
time.sleep(2)
|
||||
except Exception as e:
|
||||
self.log(f"关闭店铺失败: {str(e)}", "WARNING")
|
||||
|
||||
# 从正在执行中的店铺列表中移除
|
||||
if shop_name in runing_shop:
|
||||
del runing_shop[shop_name]
|
||||
self.log(f"店铺 {shop_name} 已从执行列表中移除")
|
||||
|
||||
def process_country(self, driver: AmzoneMatchAction, country_code: str, task_id: int, shop_name: str, risk_listing_filter: str):
|
||||
"""处理单个国家的审批任务
|
||||
|
||||
Args:
|
||||
driver: AmzoneApprove驱动实例
|
||||
country_code: 国家代码(如 UK, DE, FR 等)
|
||||
task_id: 任务ID
|
||||
shop_name: 店铺名称
|
||||
risk_listing_filter: 风险商品筛选条件
|
||||
"""
|
||||
from config import runing_task
|
||||
|
||||
# 转换国家代码为中文名称
|
||||
country_name = self.country_info.get(country_code, country_code)
|
||||
info_mes = f"开始处理国家: {country_name} ({country_code})"
|
||||
self.log(info_mes)
|
||||
show_notification(info_mes, "info")
|
||||
|
||||
# 更新当前处理的国家
|
||||
if task_id in runing_task:
|
||||
runing_task[task_id]["current_country"] = country_name
|
||||
|
||||
# 切换国家,最多重试3次
|
||||
max_retries = 3
|
||||
switch_success = False
|
||||
|
||||
for retry in range(max_retries):
|
||||
try:
|
||||
self.log(f"尝试切换到国家 {country_name} (第 {retry + 1}/{max_retries} 次)")
|
||||
|
||||
# 如果不是第一次尝试,先刷新页面
|
||||
if retry > 0:
|
||||
self.log("重试前刷新页面...")
|
||||
try:
|
||||
driver.tab.refresh()
|
||||
time.sleep(3)
|
||||
except Exception as e:
|
||||
self.log(f"刷新页面失败: {str(e)}", "WARNING")
|
||||
|
||||
switch_success = driver.SwitchingCountries(country_name)
|
||||
if switch_success:
|
||||
self.log(f"成功切换到国家 {country_name}")
|
||||
break
|
||||
else:
|
||||
self.log(f"切换到国家 {country_name} 失败", "WARNING")
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
self.log(f"切换国家 {country_name} 异常: {str(e)}", "ERROR")
|
||||
self.log(traceback.format_exc(), "ERROR")
|
||||
|
||||
# 如果还有重试机会,等待后继续
|
||||
if retry < max_retries - 1:
|
||||
time.sleep(2)
|
||||
|
||||
# 如果切换失败,直接返回
|
||||
if not switch_success:
|
||||
error_message = f"切换到国家 {country_name} 失败,已重试 {max_retries} 次,跳过该国家"
|
||||
self.log(error_message, "ERROR")
|
||||
if task_id in runing_task:
|
||||
runing_task[task_id]["processed_countries"] += 1
|
||||
return
|
||||
|
||||
# 切换到库存管理页面
|
||||
try:
|
||||
driver.SwitchPage()
|
||||
self.log(f"已切换到库存管理页面")
|
||||
except Exception as e:
|
||||
import traceback
|
||||
self.log(f"切换页面失败: {str(e)}", "ERROR")
|
||||
self.log(traceback.format_exc(), "ERROR")
|
||||
if task_id in runing_task:
|
||||
runing_task[task_id]["processed_countries"] += 1
|
||||
return
|
||||
|
||||
# 搜索需要审批的商品,最多重试3次
|
||||
sku_ls = []
|
||||
for retry in range(max_retries):
|
||||
try:
|
||||
self.log(f"尝试搜索匹配操作商品 (第 {retry + 1}/{max_retries} 次)")
|
||||
sku_ls = driver.search(filter_type=risk_listing_filter)
|
||||
break
|
||||
except Exception as e:
|
||||
self.log(f"搜索商品异常: {str(e)}", "ERROR")
|
||||
if retry < max_retries - 1:
|
||||
try:
|
||||
driver.tab.refresh()
|
||||
time.sleep(3)
|
||||
except Exception as refresh_error:
|
||||
self.log(f"刷新页面失败: {str(refresh_error)}", "WARNING")
|
||||
|
||||
# 如果没有需要审批的商品,直接返回
|
||||
if len(sku_ls) == 0:
|
||||
self.log(f"国家 {country_name} 没有搜索出的商品")
|
||||
if task_id in runing_task:
|
||||
runing_task[task_id]["processed_countries"] += 1
|
||||
return
|
||||
|
||||
self.log(f"国家 {country_name} 搜索出 {len(sku_ls)} 商品,开始处理...")
|
||||
|
||||
# 处理所有需要审批的商品(通过yield获取结果)
|
||||
try:
|
||||
for asin, status in driver.run_page_action():
|
||||
# 检查是否收到暂停请求
|
||||
if task_id in runing_task and runing_task[task_id].get("stop_requested", False):
|
||||
self.log(f"检测到任务 {task_id} 的暂停请求,停止处理ASIN", "WARNING")
|
||||
break
|
||||
|
||||
self.log(f"ASIN {asin} 处理结果: {status}")
|
||||
|
||||
# 更新任务状态
|
||||
if task_id in runing_task:
|
||||
runing_task[task_id]["current_asin"] = asin
|
||||
runing_task[task_id]["processed_asins"] += 1
|
||||
|
||||
runing_task[task_id]["failed_count"] += 1
|
||||
|
||||
# 回传结果到API
|
||||
try:
|
||||
self.post_result(task_id, shop_name, country_code, asin, status)
|
||||
except Exception as e:
|
||||
self.log(f"回传结果失败: {str(e)}", "ERROR")
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
self.log(f"处理审批商品异常: {str(e)}", "ERROR")
|
||||
self.log(traceback.format_exc(), "ERROR")
|
||||
|
||||
# 更新已处理国家数
|
||||
if task_id in runing_task:
|
||||
runing_task[task_id]["processed_countries"] += 1
|
||||
|
||||
self.log(f"国家 {country_name} 处理完成")
|
||||
def post_stage_finished(self, task_id: int, user_id, stage_index):
|
||||
import requests
|
||||
from config import DELETE_BRAND_API_BASE
|
||||
|
||||
if user_id in (None, "", 0):
|
||||
raise ValueError("user_id is required for stage completion callback")
|
||||
if stage_index is None:
|
||||
raise ValueError("stage_index is required for stage completion callback")
|
||||
|
||||
url = f"{DELETE_BRAND_API_BASE}/api/shop-match/tasks/{task_id}/stage-finished"
|
||||
payload = {"stage_index": stage_index}
|
||||
params = {"user_id": user_id}
|
||||
|
||||
max_retries = 3
|
||||
for retry in range(max_retries):
|
||||
try:
|
||||
self.log(f"Attempting stage completion callback ({retry + 1}/{max_retries})")
|
||||
response = requests.post(
|
||||
url,
|
||||
params=params,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=30,
|
||||
verify=False,
|
||||
)
|
||||
self.log(f"Stage completion callback response: {response.text}")
|
||||
data = response.json() if response.text else {}
|
||||
if response.status_code == 200 and isinstance(data, dict) and data.get("success"):
|
||||
self.log(f"Stage completion callback succeeded: task={task_id}, stage={stage_index}")
|
||||
return
|
||||
self.log(f"Stage completion callback failed, status={response.status_code}", "WARNING")
|
||||
except Exception as e:
|
||||
self.log(f"Stage completion callback exception: {str(e)}", "ERROR")
|
||||
if retry < max_retries - 1:
|
||||
time.sleep(2)
|
||||
|
||||
raise RuntimeError(f"Stage completion callback failed after retries: task={task_id}, stage={stage_index}")
|
||||
|
||||
def post_result(self, task_id: int, shop_name: str, country_code: str, asin: str, status: str,is_done: bool = False):
|
||||
"""回传处理结果到API
|
||||
|
||||
Args:
|
||||
task_id: 任务ID
|
||||
shop_name: 店铺名称
|
||||
country_code: 国家代码
|
||||
asin: ASIN
|
||||
status: 处理状态
|
||||
"""
|
||||
import requests
|
||||
from config import DELETE_BRAND_API_BASE
|
||||
|
||||
url = f"{DELETE_BRAND_API_BASE}/api/shop-match/tasks/{task_id}/result"
|
||||
|
||||
payload = {
|
||||
"shops": [
|
||||
{
|
||||
"error": "",
|
||||
"countries": {
|
||||
country_code: [
|
||||
{
|
||||
"asin": asin,
|
||||
"status": status,
|
||||
"done": is_done
|
||||
}
|
||||
]
|
||||
},
|
||||
"shopName": shop_name
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
max_retries = 3
|
||||
for retry in range(max_retries):
|
||||
try:
|
||||
print("================【匹配】=====================")
|
||||
self.log(f"尝试回传结果 (第 {retry + 1}/{max_retries} 次)")
|
||||
self.log(f"回传URL: {url}")
|
||||
self.log(f"回传数据: {payload}")
|
||||
response = requests.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=30,
|
||||
verify=False
|
||||
)
|
||||
self.log(f"回传结果: {response.text}")
|
||||
data = response.json() if response.text else {}
|
||||
if response.status_code == 200 and isinstance(data, dict) and data.get("success"):
|
||||
self.log(f"结果回传成功: {asin} - {status}")
|
||||
return
|
||||
else:
|
||||
self.log(f"结果回传失败,状态码: {response.status_code}", "WARNING")
|
||||
print("=====================================")
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"调用API异常: {str(e)}", "ERROR")
|
||||
print("=====================================")
|
||||
|
||||
# 如果还有重试机会,等待后继续
|
||||
if retry < max_retries - 1:
|
||||
time.sleep(2)
|
||||
|
||||
self.log(f"已达到最大重试次数,结果回传最终失败", "ERROR")
|
||||
raise RuntimeError("已达到最大重试次数,结果回传最终失败")
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
user_info = {
|
||||
"company": "rongchuang123",
|
||||
"username": "自动化_Robot",
|
||||
"password": "#20zsg25"
|
||||
}
|
||||
shop_name = "魏振峰"
|
||||
country = "德国"
|
||||
kill_process('v6')
|
||||
driver = AmzoneMatchAction(user_info)
|
||||
browser = driver.open_shop(shop_name)
|
||||
sw_suc = driver.SwitchingCountries(country)
|
||||
driver.SwitchPage()
|
||||
risk_listing_filter = "All"
|
||||
for _ in range(3):
|
||||
try:
|
||||
sku_ls = driver.search(filter_type=risk_listing_filter)
|
||||
break
|
||||
except Exception as e:
|
||||
print(e)
|
||||
driver.tab.refresh()
|
||||
if len(sku_ls) > 0:
|
||||
print("有数据,开始操作")
|
||||
for asin, status in driver.run_page_action():
|
||||
print(f"ASIN {asin} 的处理结果: {status}")
|
||||
print("已完成操作")
|
||||
@@ -1,116 +0,0 @@
|
||||
# 导入 webview 用于前端通知
|
||||
try:
|
||||
import webview
|
||||
except ImportError:
|
||||
webview = None
|
||||
|
||||
|
||||
import requests
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
def show_notification(message: str, message_type: str = "error"):
|
||||
"""显示 pywebview 顶层通知(5秒后自动消失)
|
||||
|
||||
Args:
|
||||
message: 通知消息
|
||||
message_type: 消息类型 (success/warning/error/info)
|
||||
"""
|
||||
if webview and webview.windows:
|
||||
try:
|
||||
# 转义单引号,防止 JavaScript 语法错误
|
||||
safe_message = message.replace("'", "\\'").replace('"', '\\"').replace('\n', '\\n')
|
||||
|
||||
# 设置通知样式颜色
|
||||
color_map = {
|
||||
'success': '#67C23A',
|
||||
'warning': '#E6A23C',
|
||||
'error': '#F56C6C',
|
||||
'info': '#909399'
|
||||
}
|
||||
bg_color = color_map.get(message_type, '#F56C6C')
|
||||
|
||||
# 构建前端通知的 JavaScript 代码 - 创建原生 HTML 通知
|
||||
js_code = f"""
|
||||
(function() {{
|
||||
// 移除已存在的通知
|
||||
var existingNotif = document.getElementById('pywebview-notification');
|
||||
if (existingNotif) {{
|
||||
existingNotif.remove();
|
||||
}}
|
||||
|
||||
// 创建通知容器
|
||||
var notif = document.createElement('div');
|
||||
notif.id = 'pywebview-notification';
|
||||
notif.style.cssText = 'position: fixed; top: 20px; left: 50%; transform: translateX(-50%); ' +
|
||||
'background: {bg_color}; color: white; padding: 12px 20px; border-radius: 4px; ' +
|
||||
'box-shadow: 0 2px 12px rgba(0,0,0,0.3); z-index: 99999; font-size: 14px; ' +
|
||||
'max-width: 600px; word-wrap: break-word; display: flex; align-items: center; gap: 10px;';
|
||||
|
||||
// 添加消息内容
|
||||
var msgSpan = document.createElement('span');
|
||||
msgSpan.textContent = '{safe_message}';
|
||||
notif.appendChild(msgSpan);
|
||||
|
||||
// 添加关闭按钮
|
||||
var closeBtn = document.createElement('span');
|
||||
closeBtn.innerHTML = '×';
|
||||
closeBtn.style.cssText = 'cursor: pointer; font-size: 18px; font-weight: bold; margin-left: 10px;';
|
||||
closeBtn.onclick = function() {{ notif.remove(); }};
|
||||
notif.appendChild(closeBtn);
|
||||
|
||||
// 添加到页面
|
||||
document.body.appendChild(notif);
|
||||
|
||||
// 5秒后自动消失
|
||||
setTimeout(function() {{
|
||||
if (notif && notif.parentNode) {{
|
||||
notif.style.transition = 'opacity 0.3s';
|
||||
notif.style.opacity = '0';
|
||||
setTimeout(function() {{ notif.remove(); }}, 300);
|
||||
}}
|
||||
}}, 5000);
|
||||
}})();
|
||||
"""
|
||||
# 在第一个窗口中执行 JavaScript
|
||||
webview.windows[0].evaluate_js(js_code)
|
||||
except Exception as e:
|
||||
print(f"显示通知失败: {str(e)}")
|
||||
|
||||
|
||||
|
||||
|
||||
def get_shop_info(shop_name: str, base_url: str = "http://8.136.19.173:18080") -> requests.Response:
|
||||
"""
|
||||
调用商铺凭证接口
|
||||
:param shop_name: 商铺名称,将自动进行 URL 编码
|
||||
:param base_url: API 基础地址,默认从 curl 中提取
|
||||
:return: requests.Response 对象
|
||||
"""
|
||||
url = f"{base_url}/api/admin/shop-manages/credential"
|
||||
params = {"shopName": shop_name}
|
||||
|
||||
headers = {
|
||||
"Accept": "*/*",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"Pragma": "no-cache",
|
||||
"Referer": "http://8.136.19.173:18080/doc.html",
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
|
||||
"X-Internal-Token": "59c6691199917a2095827b5188502029",
|
||||
}
|
||||
|
||||
response = requests.get(
|
||||
url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
verify=False,
|
||||
timeout=30
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
57
app/app.py
57
app/app.py
@@ -1,57 +0,0 @@
|
||||
"""
|
||||
卖相AI - Flask 后端
|
||||
按功能拆分为蓝图:认证(auth)、主页面(main)、管理员(admin)、图片(image)、品牌(brand)
|
||||
"""
|
||||
import os
|
||||
import secrets
|
||||
from datetime import timedelta
|
||||
|
||||
from flask import Flask
|
||||
from flask_cors import CORS
|
||||
|
||||
from app_common import init_db, BASE_DIR
|
||||
from blueprints.auth import auth_bp
|
||||
from blueprints.main import main_bp
|
||||
from blueprints.admin import admin_bp
|
||||
from blueprints.image import image_bp
|
||||
from blueprints.brand import brand_bp
|
||||
from blueprints.communication import communication_bp
|
||||
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__, template_folder=BASE_DIR, static_folder=BASE_DIR)
|
||||
frontend_origin = os.environ.get('FRONTEND_ORIGIN', '*')
|
||||
cors_origins = frontend_origin if frontend_origin != '*' else '*'
|
||||
CORS(
|
||||
app,
|
||||
supports_credentials=True,
|
||||
resources={r"/api/*": {"origins": cors_origins}, r"/login": {"origins": cors_origins}},
|
||||
)
|
||||
# 生产环境必须通过环境变量注入固定 SECRET_KEY;否则服务重启会使旧会话失效。
|
||||
app.secret_key = os.environ.get('SECRET_KEY', 'dev-secret-key-change-me')
|
||||
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7)
|
||||
app.config['SESSION_COOKIE_HTTPONLY'] = True
|
||||
app.config['SESSION_COOKIE_SAMESITE'] = os.environ.get('SESSION_COOKIE_SAMESITE', 'Lax')
|
||||
app.config['SESSION_COOKIE_SECURE'] = os.environ.get('SESSION_COOKIE_SECURE', '0') == '1'
|
||||
|
||||
# 注册蓝图(不设 url_prefix,保持原有 URL 路径不变,前端无需改动)
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
app.register_blueprint(image_bp)
|
||||
app.register_blueprint(brand_bp)
|
||||
app.register_blueprint(communication_bp)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
def run_app(host='127.0.0.1', port=5123):
|
||||
# init_db()
|
||||
app.run(host=host, port=port, threaded=True, use_reloader=False)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run_app()
|
||||
@@ -1,278 +0,0 @@
|
||||
"""
|
||||
公共模块:数据库连接、初始化、会话校验、装饰器、模板渲染
|
||||
供各蓝图复用
|
||||
"""
|
||||
import os
|
||||
import secrets
|
||||
from datetime import timedelta
|
||||
from functools import wraps
|
||||
|
||||
import pymysql
|
||||
from flask import request, redirect, url_for, session, jsonify, render_template, render_template_string
|
||||
|
||||
from config import mysql_host, mysql_user, mysql_password, mysql_database
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
STATIC_DIR = os.path.join(BASE_DIR, 'static')
|
||||
ASSETS_DIR = os.path.join(BASE_DIR, 'assets')
|
||||
|
||||
|
||||
def get_db():
|
||||
return pymysql.connect(
|
||||
host=mysql_host,
|
||||
user=mysql_user,
|
||||
password=mysql_password,
|
||||
database=mysql_database,
|
||||
charset='utf8mb4',
|
||||
cursorclass=pymysql.cursors.DictCursor
|
||||
)
|
||||
|
||||
|
||||
def _render_html(template_name: str, **context):
|
||||
"""读取 HTML 模板:若为加密文件则先解密,再渲染。未加密或解密失败时按明文渲染。"""
|
||||
path = os.path.join(BASE_DIR, "web_source", template_name)
|
||||
if not os.path.isfile(path):
|
||||
return render_template(template_name, **context)
|
||||
with open(path, "rb") as f:
|
||||
raw = f.read()
|
||||
try:
|
||||
from html_crypto import decrypt
|
||||
content = decrypt(raw).decode("utf-8")
|
||||
except Exception:
|
||||
content = raw.decode("utf-8", errors="replace")
|
||||
return render_template_string(content, **context)
|
||||
|
||||
|
||||
def _render_html_new(template_name: str, **context):
|
||||
"""读取 HTML 模板:若为加密文件则先解密,再渲染。未加密或解密失败时按明文渲染。"""
|
||||
path = os.path.join(BASE_DIR, "web_source", template_name)
|
||||
if not os.path.isfile(path):
|
||||
return render_template(template_name, **context)
|
||||
with open(path, "rb") as f:
|
||||
raw = f.read()
|
||||
try:
|
||||
from html_crypto import decrypt
|
||||
content = decrypt(raw).decode("utf-8")
|
||||
except Exception:
|
||||
content = raw.decode("utf-8", errors="replace")
|
||||
return render_template_string(content, **context)
|
||||
|
||||
|
||||
|
||||
def _is_session_user_valid():
|
||||
"""校验 session 中的 user_id 是否在数据库中仍存在;不存在则清除 session 并返回 False"""
|
||||
uid = session.get('user_id')
|
||||
if not uid:
|
||||
return False
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT id FROM users WHERE id = %s", (uid,))
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
session.clear()
|
||||
return False
|
||||
return True
|
||||
except Exception:
|
||||
session.clear()
|
||||
return False
|
||||
|
||||
|
||||
def _get_current_admin_role():
|
||||
"""获取当前登录用户的管理角色:super_admin / admin / None(非管理员)"""
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id, username, is_admin, role, created_by_id FROM users WHERE id = %s",
|
||||
(session['user_id'],)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
if not row or not row.get('is_admin'):
|
||||
return None, None
|
||||
return row.get('role') or ('super_admin' if row.get('created_by_id') is None else 'admin'), row
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
|
||||
def login_required(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
if not session.get('user_id') or not _is_session_user_valid():
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return jsonify({'success': False, 'error': '未登录'}), 401
|
||||
return redirect(url_for('auth.login'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
|
||||
def admin_required(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
if not session.get('user_id'):
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return jsonify({'success': False, 'error': '未登录'}), 401
|
||||
return redirect(url_for('auth.login'))
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT is_admin, role FROM users WHERE id = %s", (session['user_id'],))
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
if not row or not row.get('is_admin'):
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
|
||||
return redirect(url_for('main.home'))
|
||||
except Exception as e:
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
return redirect(url_for('main.home'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
|
||||
def init_db():
|
||||
"""初始化数据库表,若不存在则创建"""
|
||||
from werkzeug.security import generate_password_hash
|
||||
conn = pymysql.connect(
|
||||
host=mysql_host,
|
||||
user=mysql_user,
|
||||
password=mysql_password,
|
||||
charset='utf8mb4'
|
||||
)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"CREATE DATABASE IF NOT EXISTS `{mysql_database}` DEFAULT CHARSET utf8mb4")
|
||||
cur.execute(f"USE `{mysql_database}`")
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(64) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(256) NOT NULL,
|
||||
is_admin TINYINT(1) DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS image_history (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
panel_type VARCHAR(64) DEFAULT '',
|
||||
original_urls JSON,
|
||||
params JSON,
|
||||
result_urls JSON,
|
||||
long_image_url VARCHAR(1024) DEFAULT NULL,
|
||||
INDEX idx_user_created (user_id, created_at DESC)
|
||||
)
|
||||
""")
|
||||
try:
|
||||
cur.execute("ALTER TABLE image_history ADD COLUMN long_image_url VARCHAR(1024) DEFAULT NULL")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
cur.execute("ALTER TABLE users ADD COLUMN machine VARCHAR(64) DEFAULT NULL")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
cur.execute("ALTER TABLE users ADD COLUMN role VARCHAR(20) DEFAULT 'normal'")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
cur.execute("ALTER TABLE users ADD COLUMN created_by_id INT NULL")
|
||||
except Exception:
|
||||
pass
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS brand_crawl_tasks (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
file_paths JSON NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
task_type TINYINT DEFAULT 1 COMMENT '1=立即执行,2=添加任务执行',
|
||||
result_paths JSON NULL,
|
||||
error_message TEXT NULL,
|
||||
progress_current INT DEFAULT 0,
|
||||
progress_total INT DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_user_status (user_id, status)
|
||||
)
|
||||
""")
|
||||
try:
|
||||
cur.execute("ALTER TABLE brand_crawl_tasks ADD COLUMN progress_current INT DEFAULT 0")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
cur.execute("ALTER TABLE brand_crawl_tasks ADD COLUMN progress_total INT DEFAULT 0")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
cur.execute("ALTER TABLE brand_crawl_tasks ADD COLUMN `desc` VARCHAR(500) NULL COMMENT '上传文件名描述'")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
cur.execute("ALTER TABLE brand_crawl_tasks ADD COLUMN strategy VARCHAR(20) DEFAULT 'Terms' COMMENT '品牌匹配方式: Terms/Simple'")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
cur.execute("ALTER TABLE brand_crawl_tasks ADD COLUMN task_type TINYINT DEFAULT 1 COMMENT '1=立即执行,2=添加任务执行'")
|
||||
except Exception:
|
||||
pass
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS columns (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(128) NOT NULL COMMENT '栏目名',
|
||||
column_key VARCHAR(64) NOT NULL COMMENT '栏目标识',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_column_key (column_key)
|
||||
)
|
||||
""")
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS user_column_permission (
|
||||
user_id INT NOT NULL,
|
||||
column_id INT NOT NULL,
|
||||
PRIMARY KEY (user_id, column_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (column_id) REFERENCES columns(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
try:
|
||||
cur.execute("UPDATE users SET role = 'normal' WHERE (role IS NULL OR role = '') AND (is_admin = 0 OR is_admin IS NULL)")
|
||||
cur.execute("SELECT MIN(id) AS mid FROM users WHERE is_admin = 1")
|
||||
row = cur.fetchone()
|
||||
if row and row.get('mid'):
|
||||
mid = row['mid']
|
||||
cur.execute("UPDATE users SET role = 'super_admin' WHERE id = %s", (mid,))
|
||||
cur.execute("UPDATE users SET role = 'admin' WHERE is_admin = 1 AND id != %s", (mid,))
|
||||
cur.execute("UPDATE users SET created_by_id = %s WHERE role = 'admin' AND (created_by_id IS NULL)", (mid,))
|
||||
except Exception:
|
||||
pass
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
_create_initial_admin()
|
||||
|
||||
|
||||
def _create_initial_admin():
|
||||
"""若没有任何管理员,则创建默认超级管理员(首次启动时,仅一个)"""
|
||||
from werkzeug.security import generate_password_hash
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT id FROM users WHERE role = 'super_admin' LIMIT 1")
|
||||
if cur.fetchone():
|
||||
conn.close()
|
||||
return
|
||||
admin_user = os.environ.get('ADMIN_USER', 'admin')
|
||||
admin_pwd = os.environ.get('ADMIN_PASSWORD', 'admin123')
|
||||
pwd_hash = generate_password_hash(admin_pwd, method='pbkdf2:sha256')
|
||||
cur.execute(
|
||||
"INSERT INTO users (username, password_hash, is_admin, role) VALUES (%s, %s, 1, 'super_admin')",
|
||||
(admin_user, pwd_hash)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
# 蓝图包
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,362 +0,0 @@
|
||||
"""
|
||||
管理员蓝图:用户管理(列表/创建/更新/删除)、生成历史、管理页
|
||||
"""
|
||||
import json
|
||||
import pymysql
|
||||
from flask import Blueprint, request, jsonify
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from app_common import get_db, _render_html, _get_current_admin_role, admin_required, login_required
|
||||
from flask import session
|
||||
|
||||
admin_bp = Blueprint('admin', __name__)
|
||||
|
||||
|
||||
def _parse_json(val, default=None):
|
||||
if val is None:
|
||||
return default if default is not None else []
|
||||
if isinstance(val, (list, dict)):
|
||||
return val
|
||||
try:
|
||||
return json.loads(val)
|
||||
except Exception:
|
||||
return default if default is not None else []
|
||||
|
||||
|
||||
@admin_bp.route('/admin')
|
||||
@login_required
|
||||
@admin_required
|
||||
def admin_page():
|
||||
return _render_html('admin.html')
|
||||
|
||||
|
||||
@admin_bp.route('/api/admin/users')
|
||||
@admin_required
|
||||
def admin_list_users():
|
||||
"""分页获取用户列表;支持用户名模糊搜索、指定管理员所属普通用户筛选"""
|
||||
role, current_row = _get_current_admin_role()
|
||||
if not role:
|
||||
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
|
||||
page = max(1, int(request.args.get('page', 1)))
|
||||
page_size = min(50, max(5, int(request.args.get('page_size', 15))))
|
||||
offset = (page - 1) * page_size
|
||||
search_username = (request.args.get('username') or request.args.get('search') or '').strip()
|
||||
created_by_id_arg = request.args.get('created_by_id') or request.args.get('admin_id')
|
||||
created_by_id = int(created_by_id_arg) if created_by_id_arg and str(created_by_id_arg).isdigit() else None
|
||||
if role != 'super_admin':
|
||||
created_by_id = None
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
if role == 'super_admin':
|
||||
where_parts = ["1=1"]
|
||||
params = []
|
||||
if search_username:
|
||||
where_parts.append("u.username LIKE %s")
|
||||
params.append("%" + search_username + "%")
|
||||
if created_by_id is not None:
|
||||
where_parts.append("u.created_by_id = %s")
|
||||
params.append(created_by_id)
|
||||
where_sql = " AND ".join(where_parts)
|
||||
cur.execute(
|
||||
"""SELECT u.id, u.username, u.is_admin, u.role, u.created_at, u.created_by_id,
|
||||
creator.username AS creator_username
|
||||
FROM users u
|
||||
LEFT JOIN users creator ON creator.id = u.created_by_id
|
||||
WHERE """ + where_sql + """ ORDER BY u.id LIMIT %s OFFSET %s""",
|
||||
tuple(params) + (page_size, offset),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cur.execute("SELECT COUNT(*) as total FROM users u WHERE " + where_sql, tuple(params))
|
||||
total = cur.fetchone()['total']
|
||||
cur.execute("SELECT id, username FROM users WHERE role = 'admin' ORDER BY id")
|
||||
admins = [{'id': r['id'], 'username': r['username']} for r in cur.fetchall()]
|
||||
else:
|
||||
admin_id = current_row['id']
|
||||
where_parts = ["(u.id = %s OR (u.role = 'normal' AND u.created_by_id = %s))"]
|
||||
params = [admin_id, admin_id]
|
||||
if search_username:
|
||||
where_parts.append("u.username LIKE %s")
|
||||
params.append("%" + search_username + "%")
|
||||
where_sql = " AND ".join(where_parts)
|
||||
cur.execute(
|
||||
"""SELECT u.id, u.username, u.is_admin, u.role, u.created_at, u.created_by_id,
|
||||
creator.username AS creator_username
|
||||
FROM users u
|
||||
LEFT JOIN users creator ON creator.id = u.created_by_id
|
||||
WHERE """ + where_sql + """ ORDER BY u.id LIMIT %s OFFSET %s""",
|
||||
tuple(params) + (page_size, offset),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) as total FROM users u WHERE " + where_sql,
|
||||
tuple(params),
|
||||
)
|
||||
total = cur.fetchone()['total']
|
||||
admins = []
|
||||
items = [
|
||||
{
|
||||
'id': r['id'],
|
||||
'username': r['username'],
|
||||
'is_admin': bool(r.get('is_admin')),
|
||||
'role': r.get('role') or 'normal',
|
||||
'created_by_id': r.get('created_by_id'),
|
||||
'creator_username': r.get('creator_username') or '',
|
||||
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r.get('created_at') else '',
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
conn.close()
|
||||
payload = {
|
||||
'success': True,
|
||||
'items': items,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'current_user_role': role,
|
||||
'admins': admins,
|
||||
}
|
||||
return jsonify(payload)
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
|
||||
|
||||
@admin_bp.route('/api/admin/user', methods=['POST'])
|
||||
@admin_required
|
||||
def admin_create_user():
|
||||
data = request.get_json() or {}
|
||||
username = (data.get('username') or '').strip()
|
||||
password = data.get('password') or ''
|
||||
role, current_row = _get_current_admin_role()
|
||||
if not role:
|
||||
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
|
||||
want_role = (data.get('role') or 'normal').strip() or 'normal'
|
||||
if want_role not in ('admin', 'normal'):
|
||||
want_role = 'normal'
|
||||
if role == 'admin' and want_role == 'admin':
|
||||
return jsonify({'success': False, 'error': '仅超级管理员可创建管理员'})
|
||||
if want_role == 'admin':
|
||||
want_created_by = current_row['id']
|
||||
elif role == 'super_admin':
|
||||
want_created_by = data.get('created_by_id')
|
||||
else:
|
||||
want_created_by = current_row['id']
|
||||
if not username or not password:
|
||||
return jsonify({'success': False, 'error': '用户名和密码不能为空'})
|
||||
if len(username) < 2:
|
||||
return jsonify({'success': False, 'error': '用户名至少2个字符'})
|
||||
if len(password) < 6:
|
||||
return jsonify({'success': False, 'error': '密码至少6个字符'})
|
||||
if want_role == 'normal' and role == 'super_admin' and want_created_by is None:
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1")
|
||||
r = cur.fetchone()
|
||||
conn.close()
|
||||
want_created_by = r['id'] if r else current_row['id']
|
||||
except Exception:
|
||||
want_created_by = current_row['id']
|
||||
if want_role == 'normal' and want_created_by is None:
|
||||
want_created_by = current_row['id']
|
||||
is_admin = 1 if want_role in ('super_admin', 'admin') else 0
|
||||
pwd_hash = generate_password_hash(password, method='pbkdf2:sha256')
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO users (username, password_hash, is_admin, role, created_by_id) VALUES (%s, %s, %s, %s, %s)",
|
||||
(username, pwd_hash, is_admin, want_role, want_created_by),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'success': True, 'msg': '用户创建成功'})
|
||||
except pymysql.IntegrityError:
|
||||
return jsonify({'success': False, 'error': '用户名已存在'})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
|
||||
|
||||
@admin_bp.route('/api/admin/user/<int:uid>', methods=['PUT'])
|
||||
@admin_required
|
||||
def admin_update_user(uid):
|
||||
data = request.get_json() or {}
|
||||
password = data.get('password')
|
||||
want_role = (data.get('role') or '').strip() or data.get('role')
|
||||
role, current_row = _get_current_admin_role()
|
||||
if not role:
|
||||
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
|
||||
if want_role is None and not password:
|
||||
return jsonify({'success': False, 'error': '请提供要修改的内容'})
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT id, role, created_by_id FROM users WHERE id = %s", (uid,))
|
||||
target = cur.fetchone()
|
||||
if not target:
|
||||
conn.close()
|
||||
return jsonify({'success': False, 'error': '用户不存在'})
|
||||
if role == 'admin':
|
||||
if target['role'] != 'normal' or target.get('created_by_id') != current_row['id']:
|
||||
conn.close()
|
||||
return jsonify({'success': False, 'error': '只能编辑自己创建的普通用户'}), 403
|
||||
want_role = None
|
||||
else:
|
||||
if target.get('role') == 'super_admin':
|
||||
conn.close()
|
||||
return jsonify({'success': False, 'error': '不能修改超级管理员'})
|
||||
if want_role == 'super_admin':
|
||||
return jsonify({'success': False, 'error': '不能将用户设为超级管理员'})
|
||||
if want_role not in ('admin', 'normal', None, ''):
|
||||
want_role = None
|
||||
if password:
|
||||
if len(password) < 6:
|
||||
conn.close()
|
||||
return jsonify({'success': False, 'error': '密码至少6个字符'})
|
||||
pwd_hash = generate_password_hash(password, method='pbkdf2:sha256')
|
||||
cur.execute("UPDATE users SET password_hash = %s WHERE id = %s", (pwd_hash, uid))
|
||||
if want_role is not None and want_role != '':
|
||||
is_admin = 1 if want_role == 'admin' else 0
|
||||
cur.execute(
|
||||
"UPDATE users SET is_admin = %s, role = %s WHERE id = %s",
|
||||
(is_admin, want_role, uid),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'success': True, 'msg': '更新成功'})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
|
||||
|
||||
@admin_bp.route('/api/admin/user/<int:uid>', methods=['DELETE'])
|
||||
@admin_required
|
||||
def admin_delete_user(uid):
|
||||
from flask import session
|
||||
if session.get('user_id') == uid:
|
||||
return jsonify({'success': False, 'error': '不能删除当前登录账号'})
|
||||
role, current_row = _get_current_admin_role()
|
||||
if not role:
|
||||
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT id, role, created_by_id FROM users WHERE id = %s", (uid,))
|
||||
target = cur.fetchone()
|
||||
if not target:
|
||||
conn.close()
|
||||
return jsonify({'success': False, 'error': '用户不存在'})
|
||||
if target.get('role') == 'super_admin':
|
||||
conn.close()
|
||||
return jsonify({'success': False, 'error': '不能删除超级管理员'})
|
||||
if role == 'admin':
|
||||
if target.get('role') != 'normal' or target.get('created_by_id') != current_row['id']:
|
||||
conn.close()
|
||||
return jsonify({'success': False, 'error': '只能删除自己创建的普通用户'}), 403
|
||||
cur.execute("DELETE FROM users WHERE id = %s", (uid,))
|
||||
affected = cur.rowcount
|
||||
conn.commit()
|
||||
conn.close()
|
||||
if affected == 0:
|
||||
return jsonify({'success': False, 'error': '用户不存在'})
|
||||
return jsonify({'success': True, 'msg': '删除成功'})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
|
||||
|
||||
@admin_bp.route('/api/admin/user/<int:uid>/column-permissions')
|
||||
@login_required
|
||||
def admin_user_column_permissions(uid):
|
||||
"""获取指定用户的栏目权限列表:当前用户只能查自己,管理员可查任意用户。超级管理员返回全部栏目。"""
|
||||
current_uid = session.get('user_id')
|
||||
if current_uid != uid:
|
||||
role, _ = _get_current_admin_role()
|
||||
if not role:
|
||||
return jsonify({'success': False, 'error': '无权查看该用户的栏目权限'}), 403
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT role FROM users WHERE id = %s", (uid,))
|
||||
user_row = cur.fetchone()
|
||||
if user_row and (user_row.get('role') or '').strip() == 'super_admin':
|
||||
cur.execute("""
|
||||
SELECT id, name, column_key, created_at FROM columns ORDER BY id
|
||||
""")
|
||||
rows = cur.fetchall()
|
||||
else:
|
||||
cur.execute("""
|
||||
SELECT c.id, c.name, c.column_key, c.created_at
|
||||
FROM columns c
|
||||
INNER JOIN user_column_permission ucp ON ucp.column_id = c.id
|
||||
WHERE ucp.user_id = %s
|
||||
ORDER BY c.id
|
||||
""", (uid,))
|
||||
rows = cur.fetchall()
|
||||
conn.close()
|
||||
items = [
|
||||
{
|
||||
'id': r['id'],
|
||||
'name': r['name'],
|
||||
'column_key': r['column_key'],
|
||||
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r.get('created_at') else '',
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return jsonify({'success': True, 'items': items})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
|
||||
|
||||
@admin_bp.route('/api/admin/history')
|
||||
@admin_required
|
||||
def admin_history():
|
||||
"""管理员分页获取所有生成记录,支持按用户和时间筛选"""
|
||||
page = max(1, int(request.args.get('page', 1)))
|
||||
page_size = min(50, max(10, int(request.args.get('page_size', 15))))
|
||||
offset = (page - 1) * page_size
|
||||
user_id = request.args.get('user_id', type=int)
|
||||
time_start = (request.args.get('time_start') or '').strip()
|
||||
time_end = (request.args.get('time_end') or '').strip()
|
||||
conditions, params = [], []
|
||||
if user_id:
|
||||
conditions.append("h.user_id = %s")
|
||||
params.append(user_id)
|
||||
if time_start:
|
||||
conditions.append("h.created_at >= %s")
|
||||
params.append(time_start)
|
||||
if time_end:
|
||||
conditions.append("h.created_at <= %s")
|
||||
params.append(time_end + ' 23:59:59' if len(time_end) <= 10 else time_end)
|
||||
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
||||
params_count = params[:]
|
||||
params.extend([page_size, offset])
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""SELECT h.id, h.user_id, h.created_at, h.panel_type, h.original_urls, h.params, h.result_urls,
|
||||
h.long_image_url, u.username
|
||||
FROM image_history h
|
||||
LEFT JOIN users u ON h.user_id = u.id
|
||||
WHERE """ + where_clause + """ ORDER BY h.created_at DESC LIMIT %s OFFSET %s""",
|
||||
params,
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cur.execute("SELECT COUNT(*) as total FROM image_history h WHERE " + where_clause, params_count)
|
||||
total = cur.fetchone()['total']
|
||||
conn.close()
|
||||
items = []
|
||||
for r in rows:
|
||||
items.append({
|
||||
'id': r['id'],
|
||||
'user_id': r['user_id'],
|
||||
'username': r.get('username') or '-',
|
||||
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r['created_at'] else '',
|
||||
'panel_type': r['panel_type'] or '',
|
||||
'original_urls': _parse_json(r['original_urls'], []),
|
||||
'params': _parse_json(r['params'], {}),
|
||||
'result_urls': _parse_json(r['result_urls'], []),
|
||||
'long_image_url': (r.get('long_image_url') or '').strip() or None,
|
||||
})
|
||||
return jsonify({'success': True, 'items': items, 'total': total, 'page': page, 'page_size': page_size})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
@@ -1,107 +0,0 @@
|
||||
"""
|
||||
认证蓝图:登录、登出、登录状态校验
|
||||
"""
|
||||
from flask import Blueprint, request, redirect, url_for, session, jsonify
|
||||
from werkzeug.security import check_password_hash
|
||||
|
||||
from app_common import (
|
||||
get_db,
|
||||
_render_html,
|
||||
_is_session_user_valid,
|
||||
login_required,
|
||||
BASE_DIR,
|
||||
)
|
||||
from tool.devices import DeviceIDGenerator
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if session.get('user_id') and _is_session_user_valid():
|
||||
return redirect(url_for('main.home'))
|
||||
if request.method == 'POST':
|
||||
data = request.get_json() if request.is_json else request.form
|
||||
username = (data.get('username') or '').strip()
|
||||
password = data.get('password') or ''
|
||||
if not username or not password:
|
||||
if request.is_json:
|
||||
return jsonify({'success': False, 'error': '请输入用户名和密码'})
|
||||
return _render_html('login.html', error='请输入用户名和密码')
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id, password_hash, machine, is_admin FROM users WHERE username = %s",
|
||||
(username,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row and check_password_hash(row['password_hash'], password):
|
||||
current_machine = DeviceIDGenerator().get_device_id()
|
||||
stored_machine = (row.get('machine') or '').strip()
|
||||
if not stored_machine:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("UPDATE users SET machine = %s WHERE id = %s", (current_machine, row['id']))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
session.permanent = True
|
||||
session['user_id'] = row['id']
|
||||
session['username'] = username
|
||||
if request.is_json:
|
||||
return jsonify({'success': True, 'redirect': url_for('main.home')})
|
||||
return redirect(url_for('main.home'))
|
||||
print("验证设备",stored_machine)
|
||||
print("当前设备",current_machine)
|
||||
if stored_machine != current_machine and row.get("is_admin") != 1:
|
||||
conn.close()
|
||||
err_msg = '当前设备与首次登录设备不一致,请在原设备上登录'
|
||||
if request.is_json:
|
||||
return jsonify({'success': False, 'error': err_msg})
|
||||
return _render_html('login.html', error=err_msg)
|
||||
conn.close()
|
||||
session.permanent = True
|
||||
session['user_id'] = row['id']
|
||||
session['username'] = username
|
||||
if request.is_json:
|
||||
return jsonify({'success': True, 'redirect': url_for('main.home')})
|
||||
return redirect(url_for('main.home'))
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
if request.is_json:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
return _render_html('login.html', error='登录失败,请稍后重试')
|
||||
if request.is_json:
|
||||
return jsonify({'success': False, 'error': '用户名或密码错误'})
|
||||
return _render_html('login.html', error='用户名或密码错误')
|
||||
return _render_html('login.html')
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/check')
|
||||
@login_required
|
||||
def api_auth_check():
|
||||
"""校验登录状态,用于页面加载时判断是否已登录;同时校验机器码是否与首次登录设备一致"""
|
||||
if not session.get('user_id'):
|
||||
return jsonify({'logged_in': False})
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT machine, is_admin FROM users WHERE id = %s", (session['user_id'],))
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return jsonify({'logged_in': False})
|
||||
stored_machine = (row.get('machine') or '').strip()
|
||||
if stored_machine:
|
||||
current_machine = DeviceIDGenerator().get_device_id()
|
||||
if stored_machine != current_machine and row.get("is_admin") != 1:
|
||||
session.clear()
|
||||
return jsonify({'logged_in': False, 'error': '当前设备与首次登录设备不一致'})
|
||||
except Exception:
|
||||
return jsonify({'logged_in': False})
|
||||
return jsonify({'logged_in': True, 'redirect': url_for('main.home')})
|
||||
|
||||
|
||||
@auth_bp.route('/logout')
|
||||
def logout():
|
||||
session.clear()
|
||||
return redirect(url_for('auth.login'))
|
||||
@@ -1,728 +0,0 @@
|
||||
"""
|
||||
品牌爬虫蓝图:展开文件夹、运行任务、任务列表/详情、下载结果
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import threading
|
||||
import traceback
|
||||
import zipfile
|
||||
import io
|
||||
import time
|
||||
import requests
|
||||
from queue import Queue, Empty
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import Blueprint, request, jsonify, session, send_file, redirect, Response
|
||||
|
||||
from app_common import get_db, login_required, BASE_DIR
|
||||
from config import bucket_path,JAVA_API_BASE
|
||||
from brand_spider.main import single_file_handle, TaskCancelledError
|
||||
|
||||
brand_bp = Blueprint('brand', __name__)
|
||||
BRAND_OUTPUT_DIR = os.path.join(BASE_DIR, 'brand_output')
|
||||
OSS_PREFIX = "brand_results"
|
||||
|
||||
# 任务完成时推送给前端的 SSE 队列:task_id -> [Queue, ...]
|
||||
_task_event_queues = {}
|
||||
_task_event_lock = threading.Lock()
|
||||
|
||||
# 任务行级进度(当前处理文件的行数/总行数),仅存内存,不落库:
|
||||
# task_id -> {
|
||||
# 'file_index': int, # 当前处理第几个文件
|
||||
# 'file_total': int, # 总文件数
|
||||
# 'file_path': str, # 当前文件路径
|
||||
# 'current_line': int, # 当前行号(或当前品牌序号)
|
||||
# 'total_lines': int, # 总行数(或总品牌数)
|
||||
# }
|
||||
_task_line_progress = {}
|
||||
_task_line_progress_lock = threading.Lock()
|
||||
_cancelled_brand_tasks = set()
|
||||
_cancelled_brand_tasks_lock = threading.Lock()
|
||||
|
||||
|
||||
def _mark_task_cancelled(task_id):
|
||||
with _cancelled_brand_tasks_lock:
|
||||
_cancelled_brand_tasks.add(task_id)
|
||||
|
||||
|
||||
def _is_task_cancelled(task_id):
|
||||
with _cancelled_brand_tasks_lock:
|
||||
return task_id in _cancelled_brand_tasks
|
||||
|
||||
|
||||
def _get_uid_from_request_headers():
|
||||
"""
|
||||
从请求头读取 uid。
|
||||
前端会在 headers 里携带 uid,用于确定本次请求的归属用户。
|
||||
"""
|
||||
uid = request.headers.get('uid')
|
||||
if uid is None:
|
||||
return None
|
||||
uid = str(uid).strip()
|
||||
if not uid:
|
||||
return None
|
||||
try:
|
||||
return int(uid)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_user_id():
|
||||
"""
|
||||
优先使用请求头 uid;没有请求头 uid 时回退到 session['user_id']。
|
||||
若二者同时存在但不一致,则视为无效请求并返回 None。
|
||||
"""
|
||||
req_uid = _get_uid_from_request_headers()
|
||||
if req_uid is not None:
|
||||
sess_uid = session.get('user_id')
|
||||
if sess_uid is not None:
|
||||
try:
|
||||
if int(sess_uid) != int(req_uid):
|
||||
return None
|
||||
except Exception:
|
||||
if str(sess_uid) != str(req_uid):
|
||||
return None
|
||||
return req_uid
|
||||
return session.get('user_id')
|
||||
|
||||
|
||||
def _get_user_id_or_error():
|
||||
user_id = _resolve_user_id()
|
||||
if not user_id:
|
||||
return None, (jsonify({'success': False, 'error': '未登录或缺少uid'}), 401)
|
||||
try:
|
||||
return int(user_id), None
|
||||
except Exception:
|
||||
return None, (jsonify({'success': False, 'error': 'uid无效'}), 400)
|
||||
|
||||
|
||||
def _push_task_event(task_id, event):
|
||||
"""向订阅了该任务的所有 SSE 连接推送事件,并移除该任务的队列列表。
|
||||
同时清理内存中的行级进度,不通过数据库中转行级进度。"""
|
||||
with _task_event_lock:
|
||||
queues = _task_event_queues.pop(task_id, [])
|
||||
for q in queues:
|
||||
try:
|
||||
q.put_nowait(event)
|
||||
except Exception:
|
||||
pass
|
||||
# 任务进入终态时,顺便清理行级进度缓存
|
||||
with _task_line_progress_lock:
|
||||
_task_line_progress.pop(task_id, None)
|
||||
status = (event or {}).get('status') if isinstance(event, dict) else None
|
||||
if status in ('success', 'failed', 'cancelled'):
|
||||
with _cancelled_brand_tasks_lock:
|
||||
_cancelled_brand_tasks.discard(task_id)
|
||||
|
||||
|
||||
def _expand_folder_xlsx(folder_path):
|
||||
"""返回文件夹下所有 .xlsx 文件的绝对路径列表"""
|
||||
if not folder_path or not os.path.isdir(folder_path):
|
||||
return []
|
||||
paths = []
|
||||
for name in os.listdir(folder_path):
|
||||
if name.endswith('.xlsx') or name.endswith('.XLSX'):
|
||||
paths.append(os.path.normpath(os.path.join(folder_path, name)))
|
||||
return paths
|
||||
|
||||
|
||||
def _upload_local_xlsx_to_oss(local_path, user_id):
|
||||
"""将本地 xlsx 文件上传到 OSS,返回 (url, None) 或 (None, error_message)。"""
|
||||
if not local_path or not os.path.isfile(local_path):
|
||||
return None, "文件不存在"
|
||||
try:
|
||||
from ali_oss import upload_file as oss_upload_file
|
||||
except ImportError:
|
||||
return None, "OSS 模块未配置"
|
||||
base = os.path.basename(local_path)
|
||||
safe_base = "".join(c if c.isalnum() or c in '-_.' else '_' for c in base)
|
||||
if not safe_base.endswith('.xlsx'):
|
||||
safe_base = safe_base + '.xlsx'
|
||||
key = f"{bucket_path}brand_input/{user_id}/{int(time.time() * 1000)}/{safe_base}"
|
||||
try:
|
||||
with open(local_path, "rb") as f:
|
||||
content = f.read()
|
||||
url = oss_upload_file(content, key)
|
||||
return url, None
|
||||
except Exception as e:
|
||||
return None, str(e)
|
||||
|
||||
|
||||
def _run_brand_single(taskid,brand_ls, fileUrl,strategy,totalLines,chunkTotal,chunkIndex):
|
||||
""""""
|
||||
invalidBrands = [] # 不符合品牌的数据
|
||||
queryFailedBrands = [] # 查询失败的数据
|
||||
keptRows = []
|
||||
for brand in brand_ls:
|
||||
if _is_task_cancelled(taskid):
|
||||
raise TaskCancelledError("任务已取消")
|
||||
faild_data,query_faild_data = single_file_handle(brand,strategy)
|
||||
invalidBrands.extend(faild_data)
|
||||
queryFailedBrands.extend(query_faild_data)
|
||||
if len(faild_data) == 0 and len(query_faild_data) == 0:
|
||||
keptRows.append(brand)
|
||||
|
||||
data = { "strategy": strategy ,
|
||||
"files": [
|
||||
{
|
||||
"fileUrl": fileUrl,
|
||||
"originalFilename": "",
|
||||
"relativePath": "",
|
||||
"mainSheetName": "",
|
||||
"chunkIndex": chunkIndex,
|
||||
"chunkTotal": chunkTotal,
|
||||
"totalLines": totalLines,
|
||||
"keptRows": keptRows,
|
||||
"invalidBrands": invalidBrands,
|
||||
"queryFailedBrands": queryFailedBrands
|
||||
}
|
||||
]
|
||||
}
|
||||
resp = requests.post(f"{JAVA_API_BASE}/api/brand/tasks/{taskid}/result",headers={"accept":"application/json"},
|
||||
# json={ "strategy": strategy ,
|
||||
# "files": [
|
||||
# { "fileUrl": fileUrl,"originalFilename": "","relativePath": "",
|
||||
# "mainSheetName": "","columns": [],"keptRows": [],
|
||||
# "invalidBrands": invalidBrands,"queryFailedBrands": queryFailedBrands
|
||||
# }]})
|
||||
json=data)
|
||||
# print(data)
|
||||
# print(taskid,brand_ls, fileUrl)
|
||||
print("提交结果",data,"\n-->",resp.text)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
def _background_brand_task(task_id,data):
|
||||
"""
|
||||
后台执行爬虫任务
|
||||
参数示例:
|
||||
data : {
|
||||
"taskId": 348,
|
||||
"strategy": "Terms",
|
||||
"files": [
|
||||
{
|
||||
"fileIndex": 1,
|
||||
"fileUrl": "ab8cce4878754bfd89b4cd3ed20e1395",
|
||||
"originalFilename": "品牌样例.xlsx",
|
||||
"relativePath": "店铺A/品牌样例.xlsx",
|
||||
"sheetName": "Sheet1",
|
||||
"columns": [
|
||||
"品牌",
|
||||
"ASIH",
|
||||
"状态",
|
||||
"时间"
|
||||
],
|
||||
"rows": [
|
||||
{
|
||||
"品牌": "YQAUTEC",
|
||||
"ASIH": "B0F28NZ752",
|
||||
"状态": "",
|
||||
"时间": "",
|
||||
"__rowIndex": 2
|
||||
}
|
||||
],
|
||||
"uniqueBrands": [
|
||||
"YQAUTEC"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
file_ls = data.get("files")
|
||||
strategy = data.get("strategy")
|
||||
if not isinstance(file_ls, list):
|
||||
file_ls = []
|
||||
tasks = []
|
||||
for file_data in file_ls:
|
||||
rows = file_data.get("rows") or []
|
||||
brand_ls = [i.get("品牌") for i in rows if i.get("品牌")]
|
||||
fileUrl = file_data.get("fileUrl")
|
||||
if not brand_ls:
|
||||
continue
|
||||
file_chunks = [brand_ls[i:i + 5] for i in range(0, len(brand_ls), 5)]
|
||||
chunk_total = len(file_chunks)
|
||||
total_lines = len(brand_ls)
|
||||
for chunk_index, chunk in enumerate(file_chunks, start=1):
|
||||
tasks.append((chunk, fileUrl, total_lines, chunk_total, chunk_index))
|
||||
|
||||
if tasks:
|
||||
max_workers = min(8, len(tasks))
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
if _is_task_cancelled(task_id):
|
||||
_push_task_event(task_id, {'status': 'cancelled'})
|
||||
return
|
||||
futures = [
|
||||
executor.submit(_run_brand_single, task_id, chunk, file_url, strategy, total_lines, chunk_total, chunk_index)
|
||||
for chunk, file_url, total_lines, chunk_total, chunk_index in tasks
|
||||
]
|
||||
for future in as_completed(futures):
|
||||
if _is_task_cancelled(task_id):
|
||||
for f in futures:
|
||||
f.cancel()
|
||||
_push_task_event(task_id, {'status': 'cancelled'})
|
||||
return
|
||||
future.result()
|
||||
_push_task_event(task_id, {'status': 'success'})
|
||||
except Exception as e:
|
||||
print("执行出错",traceback.format_exc())
|
||||
_push_task_event(task_id, {'status': 'failed', 'error_message': str(e)})
|
||||
print("执行完成")
|
||||
|
||||
|
||||
def _parse_json(val, default=None):
|
||||
if val is None:
|
||||
return default if default is not None else []
|
||||
if isinstance(val, (list, dict)):
|
||||
return val
|
||||
try:
|
||||
return json.loads(val)
|
||||
except Exception:
|
||||
return default if default is not None else []
|
||||
|
||||
|
||||
def _is_url(s):
|
||||
if not isinstance(s, str) or not s.strip():
|
||||
return False
|
||||
return s.strip().startswith('http://') or s.strip().startswith('https://')
|
||||
|
||||
|
||||
def _normalize_result_paths(raw):
|
||||
"""
|
||||
将 result_paths 规范化为 (url_list, zip_url)。
|
||||
支持旧格式 list 或新格式 dict {"urls": [...], "zip_url": "..."}。
|
||||
"""
|
||||
if not raw:
|
||||
return [], None
|
||||
if isinstance(raw, dict):
|
||||
urls = raw.get('urls') or []
|
||||
if not isinstance(urls, list):
|
||||
urls = []
|
||||
zip_url = raw.get('zip_url')
|
||||
if zip_url and not isinstance(zip_url, str):
|
||||
zip_url = None
|
||||
return urls, zip_url
|
||||
if isinstance(raw, list):
|
||||
return raw, None
|
||||
return [], None
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/expand-folder', methods=['POST'])
|
||||
@login_required
|
||||
def api_brand_expand_folder():
|
||||
"""展开文件夹,返回其下所有 .xlsx 文件路径列表"""
|
||||
try:
|
||||
_, err = _get_user_id_or_error()
|
||||
if err:
|
||||
return err
|
||||
data = request.get_json() or {}
|
||||
folder = (data.get('folder') or '').strip()
|
||||
if not folder:
|
||||
return jsonify({'success': False, 'error': '请提供 folder 路径'}), 400
|
||||
paths = _expand_folder_xlsx(folder)
|
||||
return jsonify({'success': True, 'paths': paths})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
def _expand_folder_xlsx_recursive_items(folder_path):
|
||||
"""返回文件夹下所有 .xlsx 文件的绝对路径和相对路径列表(递归)"""
|
||||
if not folder_path or not os.path.isdir(folder_path):
|
||||
return []
|
||||
items = []
|
||||
root = os.path.normpath(folder_path)
|
||||
for current_root, _, filenames in os.walk(root):
|
||||
for name in filenames:
|
||||
if not (name.endswith('.xlsx') or name.endswith('.XLSX')):
|
||||
continue
|
||||
absolute_path = os.path.normpath(os.path.join(current_root, name))
|
||||
relative_path = os.path.relpath(absolute_path, root).replace('\\', '/')
|
||||
items.append({
|
||||
'absolutePath': absolute_path,
|
||||
'relativePath': relative_path,
|
||||
})
|
||||
items.sort(key=lambda item: item['relativePath'])
|
||||
return items
|
||||
|
||||
@brand_bp.route('/api/brand/expand-folder-recursive', methods=['POST'])
|
||||
@login_required
|
||||
def api_brand_expand_folder_recursive():
|
||||
"""递归展开文件夹,返回 .xlsx 文件绝对路径及相对路径列表"""
|
||||
try:
|
||||
_, err = _get_user_id_or_error()
|
||||
if err:
|
||||
return err
|
||||
data = request.get_json() or {}
|
||||
folder = (data.get('folder') or '').strip()
|
||||
if not folder:
|
||||
return jsonify({'success': False, 'error': '请提供 folder 路径'}), 400
|
||||
items = _expand_folder_xlsx_recursive_items(folder)
|
||||
return jsonify({'success': True, 'items': items})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/run', methods=['POST'])
|
||||
# @login_required
|
||||
def api_brand_run():
|
||||
"""立即运行:创建任务并后台执行,立即返回 task_id,前端可轮询进度与取消"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
paths = data.get('paths') or []
|
||||
# task_type: 1=立即执行,2=添加任务;此接口默认 1
|
||||
try:
|
||||
task_type = int(data.get('task_type') or 1)
|
||||
except Exception:
|
||||
task_type = 1
|
||||
if task_type not in (1, 2):
|
||||
task_type = 1
|
||||
strategy = (data.get('strategy') or 'Terms').strip()
|
||||
if strategy not in ('Terms', 'Simple'):
|
||||
strategy = 'Terms'
|
||||
if not isinstance(paths, list):
|
||||
paths = []
|
||||
paths = [p.strip() for p in paths if p and isinstance(p, str) and os.path.isfile(p.strip())]
|
||||
if not paths:
|
||||
return jsonify({'success': False, 'error': '没有有效的 xlsx 文件路径'}), 400
|
||||
# 先上传到 OSS,获取链接再入库
|
||||
user_id, err = _get_user_id_or_error()
|
||||
if err:
|
||||
return err
|
||||
urls = []
|
||||
for p in paths:
|
||||
url, err = _upload_local_xlsx_to_oss(p, user_id)
|
||||
if err:
|
||||
return jsonify({'success': False, 'error': f'上传文件失败: {os.path.basename(p)} - {err}'}), 500
|
||||
base_name = os.path.basename(p)
|
||||
urls.append({"fileUrl": url,"originalFilename": base_name,"relativePath": p })
|
||||
|
||||
# 请求提交
|
||||
resp = requests.post(f"{JAVA_API_BASE}/api/brand/tasks?userId={user_id}",headers={
|
||||
"content-type":"application/json",
|
||||
},data=json.dumps({ "files": urls, "strategy": strategy,"taskType": 1,"archiveName": ""}))
|
||||
print({ "files": urls, "strategy": strategy,"taskType": 1,"archiveName": ""})
|
||||
print(f"{JAVA_API_BASE}/api/brand/tasks?userId={user_id}")
|
||||
resp_data = resp.json()
|
||||
# print(f"{JAVA_API_BASE}/api/brand/tasks?userId={user_id} 返回:",resp_data,"状态:",resp.status_code)
|
||||
if resp_data.get("success"):
|
||||
task_id = resp_data.get("data").get("taskId")
|
||||
data = resp_data.get("data")
|
||||
threading.Thread(target=_background_brand_task, args=(task_id,data), daemon=True).start()
|
||||
return jsonify({'success': True, 'task_id': task_id})
|
||||
else:
|
||||
print(resp_data)
|
||||
return jsonify({'success': False, 'error': "请求接口失败"}), 500
|
||||
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/tasks', methods=['GET', 'POST'])
|
||||
# @login_required
|
||||
def api_brand_tasks():
|
||||
"""GET: 获取当前用户的任务列表;POST: 添加任务(后台执行)"""
|
||||
if request.method == 'GET':
|
||||
try:
|
||||
user_id, err = _get_user_id_or_error()
|
||||
if err:
|
||||
return err
|
||||
resp = requests.get(f"{JAVA_API_BASE}/api/brand/tasks", params={
|
||||
"userId": user_id
|
||||
})
|
||||
resp_data = resp.json()
|
||||
print("获取列表",resp.text)
|
||||
if resp_data.get("success"):
|
||||
items = resp_data.get("data").get("items")
|
||||
return jsonify({'success': True, 'items': items})
|
||||
return jsonify({'success': True, 'items': []})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
print("获取列表失败",e)
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
# POST
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
paths = data.get('paths') or []
|
||||
# task_type: 1=立即执行,2=添加任务;此接口默认 2
|
||||
try:
|
||||
task_type = int(data.get('task_type') or 2)
|
||||
except Exception:
|
||||
task_type = 2
|
||||
if task_type not in (1, 2):
|
||||
task_type = 2
|
||||
strategy = (data.get('strategy') or 'Terms').strip()
|
||||
if strategy not in ('Terms', 'Simple'):
|
||||
strategy = 'Terms'
|
||||
if not isinstance(paths, list):
|
||||
paths = []
|
||||
paths = [p.strip() for p in paths if p and isinstance(p, str)]
|
||||
if not paths:
|
||||
return jsonify({'success': False, 'error': '请提供至少一个文件路径或链接'}), 400
|
||||
user_id, err = _get_user_id_or_error()
|
||||
if err:
|
||||
return err
|
||||
urls = []
|
||||
for p in paths:
|
||||
url, err = _upload_local_xlsx_to_oss(p, user_id)
|
||||
if err:
|
||||
return jsonify({'success': False, 'error': f'上传文件失败: {os.path.basename(p)} - {err}'}), 500
|
||||
base_name = os.path.basename(p)
|
||||
urls.append({"fileUrl": url, "originalFilename": base_name, "relativePath": p})
|
||||
params = {
|
||||
"userId": user_id
|
||||
}
|
||||
# resp = requests.post(f"{JAVA_API_BASE}/api/brand/tasks?userId={user_id}",headers={
|
||||
# "content-type":"application/json",
|
||||
# },data=json.dumps({ "files": urls, "strategy": strategy,"taskType": 1,"archiveName": ""}))
|
||||
req_data = {"files": urls, "strategy": strategy, "taskType": 2, "archiveName": ""}
|
||||
resp = requests.post(f"{JAVA_API_BASE}/api/brand/tasks?userId={user_id}", headers={"content-type": "application/json"},
|
||||
data=json.dumps(req_data))
|
||||
print(req_data)
|
||||
# print(f"{JAVA_API_BASE}/api/brand/tasks?userId={user_id},返回", resp.text)
|
||||
resp_data = resp.json()
|
||||
if resp_data.get("success"):
|
||||
task_id = resp_data.get("data").get("taskId")
|
||||
data = resp_data.get("data")
|
||||
return jsonify({'success': True, 'task_id': task_id})
|
||||
return jsonify({'success': False, 'task_id': "请求异常"})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/tasks/<int:task_id>')
|
||||
# @login_required
|
||||
def api_brand_task_detail(task_id):
|
||||
"""获取单个任务详情(用于轮询状态)"""
|
||||
try:
|
||||
resp = requests.get(f"{JAVA_API_BASE}/api/brand/tasks/{task_id}")
|
||||
resp_data = resp.json()
|
||||
if resp_data.get("success"):
|
||||
task = resp_data["data"]["task"]
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'task': task
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/tasks/<int:task_id>/line-progress')
|
||||
# @login_required
|
||||
def api_brand_task_line_progress(task_id):
|
||||
"""
|
||||
获取任务的“当前文件行级进度”(当前行数/总行数)。
|
||||
该信息仅存放在内存字典 _task_line_progress 中,不写入数据库。
|
||||
"""
|
||||
try:
|
||||
resp = requests.get(f"{JAVA_API_BASE}/api/brand/tasks/{task_id}")
|
||||
resp_data = resp.json()
|
||||
if resp_data.get("success"):
|
||||
if resp_data["data"]["line_progress"]["has_progress"]:
|
||||
info = resp_data["data"]["line_progress"]["info"]
|
||||
resp = {
|
||||
'file_index': int(info.get('file_index') or 0),
|
||||
'file_total': int(info.get('file_total') or 0),
|
||||
'file_name': info.get("file_name",""),
|
||||
'current_line': int(info.get('current_line') or 0),
|
||||
'total_lines': int(info.get('total_lines') or 0),
|
||||
}
|
||||
return jsonify({'success': True, 'has_progress': True, 'info': resp})
|
||||
raise RuntimeError("获取进度失败")
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/tasks/<int:task_id>/events')
|
||||
# @login_required
|
||||
def api_brand_task_events(task_id):
|
||||
"""SSE:任务完成时后端主动推送事件,前端监听后隐藏进度条与取消按钮"""
|
||||
user_id, err = _get_user_id_or_error()
|
||||
if err:
|
||||
return err
|
||||
|
||||
def _task_status_event():
|
||||
conn = get_db()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT status, error_message FROM brand_crawl_tasks WHERE id = %s AND user_id = %s",
|
||||
(task_id, user_id)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if not row:
|
||||
return None
|
||||
st = (row.get('status') or '').lower()
|
||||
if st in ('success', 'failed', 'cancelled'):
|
||||
return {'status': st, 'error_message': (row.get('error_message') or '').strip() or None}
|
||||
return None
|
||||
|
||||
# 若任务已处于终态,直接返回一条事件后结束
|
||||
ev = _task_status_event()
|
||||
if ev is not None:
|
||||
def _one_shot():
|
||||
yield "data: " + json.dumps(ev, ensure_ascii=False) + "\n\n"
|
||||
return Response(
|
||||
_one_shot(),
|
||||
mimetype='text/event-stream',
|
||||
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}
|
||||
)
|
||||
|
||||
# 否则注册队列,等待后台任务完成时推送
|
||||
q = Queue()
|
||||
with _task_event_lock:
|
||||
_task_event_queues.setdefault(task_id, []).append(q)
|
||||
|
||||
def _stream():
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
event = q.get(timeout=20)
|
||||
yield "data: " + json.dumps(event, ensure_ascii=False) + "\n\n"
|
||||
return
|
||||
except Empty:
|
||||
yield ": keepalive\n\n"
|
||||
finally:
|
||||
with _task_event_lock:
|
||||
lst = _task_event_queues.get(task_id, [])
|
||||
if q in lst:
|
||||
lst.remove(q)
|
||||
if not lst:
|
||||
_task_event_queues.pop(task_id, None)
|
||||
|
||||
return Response(
|
||||
_stream(),
|
||||
mimetype='text/event-stream',
|
||||
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}
|
||||
)
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/tasks/<int:task_id>/cancel', methods=['POST'])
|
||||
# @login_required
|
||||
def api_brand_task_cancel(task_id):
|
||||
"""取消正在执行或等待中的任务(pending/running 均可取消)"""
|
||||
try:
|
||||
user_id, err = _get_user_id_or_error()
|
||||
if err:
|
||||
return err
|
||||
|
||||
|
||||
resp = requests.post(
|
||||
f"{JAVA_API_BASE}/api/brand/tasks/{task_id}/cancel",
|
||||
headers={"accept": "application/json"},
|
||||
timeout=20
|
||||
)
|
||||
try:
|
||||
resp_data = resp.json()
|
||||
except Exception:
|
||||
return jsonify({'success': False, 'error': '取消接口返回非 JSON'}), 500
|
||||
if not resp_data.get('success'):
|
||||
return jsonify({'success': False, 'error': '第三方取消任务失败'}), 400
|
||||
|
||||
_mark_task_cancelled(task_id)
|
||||
_push_task_event(task_id, {'status': 'cancelled'})
|
||||
return jsonify({'success': True})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/tasks/<int:task_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def api_brand_task_delete(task_id):
|
||||
"""删除任务(仅限非 running 状态)"""
|
||||
try:
|
||||
user_id, err = _get_user_id_or_error()
|
||||
if err:
|
||||
return err
|
||||
|
||||
resp = requests.delete(
|
||||
f"{JAVA_API_BASE}/api/brand/tasks/{task_id}",
|
||||
headers={"accept": "application/json"},
|
||||
timeout=20
|
||||
)
|
||||
try:
|
||||
resp_data = resp.json()
|
||||
except Exception:
|
||||
return jsonify({'success': False, 'error': '取消接口返回非 JSON'}), 500
|
||||
if not resp_data.get('success'):
|
||||
return jsonify({'success': False, 'error': '第三方取消任务失败'}), 400
|
||||
return jsonify({'success': True})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/download/<int:task_id>')
|
||||
@login_required
|
||||
def api_brand_download(task_id):
|
||||
"""下载任务结果:优先使用 OSS zip_url 重定向;否则本地/单链接/多链接按原逻辑处理"""
|
||||
try:
|
||||
user_id, err = _get_user_id_or_error()
|
||||
if err:
|
||||
return err
|
||||
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT result_paths FROM brand_crawl_tasks WHERE id = %s AND user_id = %s",
|
||||
(task_id, user_id)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
if not row or not row.get('result_paths'):
|
||||
return jsonify({'success': False, 'error': '无结果可下载'}), 404
|
||||
raw = row['result_paths']
|
||||
if isinstance(raw, str):
|
||||
raw = _parse_json(raw)
|
||||
url_list, zip_url = _normalize_result_paths(raw)
|
||||
# 新格式:有 zip_url 直接重定向到 OSS
|
||||
if zip_url and _is_url(zip_url):
|
||||
return redirect(zip_url, code=302)
|
||||
|
||||
# 兼容旧格式:result_paths 可能为 list(本地路径或 url)
|
||||
if not url_list and isinstance(raw, list):
|
||||
url_list = raw
|
||||
local_paths = [p for p in url_list if isinstance(p, str) and not _is_url(p) and os.path.isfile(p)]
|
||||
url_paths = [p.strip() for p in url_list if isinstance(p, str) and _is_url(p)]
|
||||
|
||||
if len(url_paths) == 1 and not local_paths:
|
||||
return redirect(url_paths[0], code=302)
|
||||
|
||||
if len(local_paths) == 1 and not url_paths:
|
||||
return send_file(
|
||||
local_paths[0],
|
||||
as_attachment=True,
|
||||
download_name=os.path.basename(local_paths[0])
|
||||
)
|
||||
|
||||
if local_paths or url_paths:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for p in local_paths:
|
||||
zf.write(p, os.path.basename(p))
|
||||
for url in url_paths:
|
||||
try:
|
||||
import requests as req
|
||||
r = req.get(url, timeout=30, stream=True)
|
||||
r.raise_for_status()
|
||||
name = os.path.basename(urlparse(url).path) or ('file_%s' % (url_paths.index(url)))
|
||||
if not name or name == 'file_%s' % url_paths.index(url):
|
||||
name = 'download_%s' % url_paths.index(url)
|
||||
zf.writestr(name, r.content)
|
||||
except Exception:
|
||||
pass
|
||||
buf.seek(0)
|
||||
return send_file(
|
||||
buf,
|
||||
mimetype='application/zip',
|
||||
as_attachment=True,
|
||||
download_name='brand_task_%s.zip' % task_id
|
||||
)
|
||||
|
||||
return jsonify({'success': False, 'error': '结果文件不存在或链接不可用'}), 404
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@@ -1,726 +0,0 @@
|
||||
"""
|
||||
品牌爬虫蓝图:展开文件夹、运行任务、任务列表/详情、下载结果
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import threading
|
||||
import traceback
|
||||
import zipfile
|
||||
import io
|
||||
import time
|
||||
import requests
|
||||
from queue import Queue, Empty
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import Blueprint, request, jsonify, session, send_file, redirect, Response
|
||||
|
||||
from app_common import get_db, login_required, BASE_DIR
|
||||
from config import bucket_path,JAVA_API_BASE
|
||||
from brand_spider.main import single_file_handle, TaskCancelledError
|
||||
|
||||
brand_bp = Blueprint('brand', __name__)
|
||||
BRAND_OUTPUT_DIR = os.path.join(BASE_DIR, 'brand_output')
|
||||
OSS_PREFIX = "brand_results"
|
||||
|
||||
# 任务完成时推送给前端的 SSE 队列:task_id -> [Queue, ...]
|
||||
_task_event_queues = {}
|
||||
_task_event_lock = threading.Lock()
|
||||
|
||||
# 任务行级进度(当前处理文件的行数/总行数),仅存内存,不落库:
|
||||
# task_id -> {
|
||||
# 'file_index': int, # 当前处理第几个文件
|
||||
# 'file_total': int, # 总文件数
|
||||
# 'file_path': str, # 当前文件路径
|
||||
# 'current_line': int, # 当前行号(或当前品牌序号)
|
||||
# 'total_lines': int, # 总行数(或总品牌数)
|
||||
# }
|
||||
_task_line_progress = {}
|
||||
_task_line_progress_lock = threading.Lock()
|
||||
_cancelled_brand_tasks = set()
|
||||
_cancelled_brand_tasks_lock = threading.Lock()
|
||||
|
||||
|
||||
def _mark_task_cancelled(task_id):
|
||||
with _cancelled_brand_tasks_lock:
|
||||
_cancelled_brand_tasks.add(task_id)
|
||||
|
||||
|
||||
def _is_task_cancelled(task_id):
|
||||
with _cancelled_brand_tasks_lock:
|
||||
return task_id in _cancelled_brand_tasks
|
||||
|
||||
|
||||
def _get_uid_from_request_headers():
|
||||
"""
|
||||
从请求头读取 uid。
|
||||
前端会在 headers 里携带 uid,用于确定本次请求的归属用户。
|
||||
"""
|
||||
uid = request.headers.get('uid')
|
||||
if uid is None:
|
||||
return None
|
||||
uid = str(uid).strip()
|
||||
if not uid:
|
||||
return None
|
||||
try:
|
||||
return int(uid)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_user_id():
|
||||
"""
|
||||
优先使用请求头 uid;没有请求头 uid 时回退到 session['user_id']。
|
||||
若二者同时存在但不一致,则视为无效请求并返回 None。
|
||||
"""
|
||||
req_uid = _get_uid_from_request_headers()
|
||||
if req_uid is not None:
|
||||
sess_uid = session.get('user_id')
|
||||
if sess_uid is not None:
|
||||
try:
|
||||
if int(sess_uid) != int(req_uid):
|
||||
return None
|
||||
except Exception:
|
||||
if str(sess_uid) != str(req_uid):
|
||||
return None
|
||||
return req_uid
|
||||
return session.get('user_id')
|
||||
|
||||
|
||||
def _get_user_id_or_error():
|
||||
user_id = _resolve_user_id()
|
||||
if not user_id:
|
||||
return None, (jsonify({'success': False, 'error': '未登录或缺少uid'}), 401)
|
||||
try:
|
||||
return int(user_id), None
|
||||
except Exception:
|
||||
return None, (jsonify({'success': False, 'error': 'uid无效'}), 400)
|
||||
|
||||
|
||||
def _push_task_event(task_id, event):
|
||||
"""向订阅了该任务的所有 SSE 连接推送事件,并移除该任务的队列列表。
|
||||
同时清理内存中的行级进度,不通过数据库中转行级进度。"""
|
||||
with _task_event_lock:
|
||||
queues = _task_event_queues.pop(task_id, [])
|
||||
for q in queues:
|
||||
try:
|
||||
q.put_nowait(event)
|
||||
except Exception:
|
||||
pass
|
||||
# 任务进入终态时,顺便清理行级进度缓存
|
||||
with _task_line_progress_lock:
|
||||
_task_line_progress.pop(task_id, None)
|
||||
status = (event or {}).get('status') if isinstance(event, dict) else None
|
||||
if status in ('success', 'failed', 'cancelled'):
|
||||
with _cancelled_brand_tasks_lock:
|
||||
_cancelled_brand_tasks.discard(task_id)
|
||||
|
||||
|
||||
def _expand_folder_xlsx(folder_path):
|
||||
"""返回文件夹下所有 .xlsx 文件的绝对路径列表"""
|
||||
if not folder_path or not os.path.isdir(folder_path):
|
||||
return []
|
||||
paths = []
|
||||
for name in os.listdir(folder_path):
|
||||
if name.endswith('.xlsx') or name.endswith('.XLSX'):
|
||||
paths.append(os.path.normpath(os.path.join(folder_path, name)))
|
||||
return paths
|
||||
|
||||
|
||||
def _upload_local_xlsx_to_oss(local_path, user_id):
|
||||
"""将本地 xlsx 文件上传到 OSS,返回 (url, None) 或 (None, error_message)。"""
|
||||
if not local_path or not os.path.isfile(local_path):
|
||||
return None, "文件不存在"
|
||||
try:
|
||||
from ali_oss import upload_file as oss_upload_file
|
||||
except ImportError:
|
||||
return None, "OSS 模块未配置"
|
||||
base = os.path.basename(local_path)
|
||||
safe_base = "".join(c if c.isalnum() or c in '-_.' else '_' for c in base)
|
||||
if not safe_base.endswith('.xlsx'):
|
||||
safe_base = safe_base + '.xlsx'
|
||||
key = f"{bucket_path}brand_input/{user_id}/{int(time.time() * 1000)}/{safe_base}"
|
||||
try:
|
||||
with open(local_path, "rb") as f:
|
||||
content = f.read()
|
||||
url = oss_upload_file(content, key)
|
||||
return url, None
|
||||
except Exception as e:
|
||||
return None, str(e)
|
||||
|
||||
|
||||
def _run_brand_single(taskid,brand_ls, fileUrl,strategy,totalLines,chunkTotal,chunkIndex):
|
||||
""""""
|
||||
invalidBrands = [] # 不符合品牌的数据
|
||||
queryFailedBrands = [] # 查询失败的数据
|
||||
keptRows = []
|
||||
for brand in brand_ls:
|
||||
if _is_task_cancelled(taskid):
|
||||
raise TaskCancelledError("任务已取消")
|
||||
faild_data,query_faild_data = single_file_handle(brand,strategy)
|
||||
invalidBrands.extend(faild_data)
|
||||
queryFailedBrands.extend(query_faild_data)
|
||||
if len(faild_data) == 0 and len(query_faild_data) == 0:
|
||||
keptRows.append(brand)
|
||||
|
||||
data = { "strategy": strategy ,
|
||||
"files": [
|
||||
{
|
||||
"fileUrl": fileUrl,
|
||||
"originalFilename": "",
|
||||
"relativePath": "",
|
||||
"mainSheetName": "",
|
||||
"chunkIndex": chunkIndex,
|
||||
"chunkTotal": chunkTotal,
|
||||
"totalLines": totalLines,
|
||||
"keptRows": keptRows,
|
||||
"invalidBrands": invalidBrands,
|
||||
"queryFailedBrands": queryFailedBrands
|
||||
}
|
||||
]
|
||||
}
|
||||
resp = requests.post(f"{JAVA_API_BASE}/api/brand/tasks/{taskid}/result",headers={"accept":"application/json"},
|
||||
# json={ "strategy": strategy ,
|
||||
# "files": [
|
||||
# { "fileUrl": fileUrl,"originalFilename": "","relativePath": "",
|
||||
# "mainSheetName": "","columns": [],"keptRows": [],
|
||||
# "invalidBrands": invalidBrands,"queryFailedBrands": queryFailedBrands
|
||||
# }]})
|
||||
json=data)
|
||||
# print(data)
|
||||
# print(taskid,brand_ls, fileUrl)
|
||||
print("提交结果",data,"\n-->",resp.text)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
def _background_brand_task(task_id,data):
|
||||
"""
|
||||
后台执行爬虫任务
|
||||
参数示例:
|
||||
data : {
|
||||
"taskId": 348,
|
||||
"strategy": "Terms",
|
||||
"files": [
|
||||
{
|
||||
"fileIndex": 1,
|
||||
"fileUrl": "ab8cce4878754bfd89b4cd3ed20e1395",
|
||||
"originalFilename": "品牌样例.xlsx",
|
||||
"relativePath": "店铺A/品牌样例.xlsx",
|
||||
"sheetName": "Sheet1",
|
||||
"columns": [
|
||||
"品牌",
|
||||
"ASIH",
|
||||
"状态",
|
||||
"时间"
|
||||
],
|
||||
"rows": [
|
||||
{
|
||||
"品牌": "YQAUTEC",
|
||||
"ASIH": "B0F28NZ752",
|
||||
"状态": "",
|
||||
"时间": "",
|
||||
"__rowIndex": 2
|
||||
}
|
||||
],
|
||||
"uniqueBrands": [
|
||||
"YQAUTEC"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
file_ls = data.get("files")
|
||||
strategy = data.get("strategy")
|
||||
if not isinstance(file_ls, list):
|
||||
file_ls = []
|
||||
tasks = []
|
||||
for file_data in file_ls:
|
||||
rows = file_data.get("rows") or []
|
||||
brand_ls = [i.get("品牌") for i in rows if i.get("品牌")]
|
||||
fileUrl = file_data.get("fileUrl")
|
||||
if not brand_ls:
|
||||
continue
|
||||
for i in range(0, len(brand_ls), 5):
|
||||
chunk = brand_ls[i:i + 5]
|
||||
tasks.append((chunk, fileUrl))
|
||||
|
||||
if tasks:
|
||||
max_workers = min(8, len(tasks))
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
if _is_task_cancelled(task_id):
|
||||
_push_task_event(task_id, {'status': 'cancelled'})
|
||||
return
|
||||
futures = [
|
||||
executor.submit(_run_brand_single, task_id, chunk, file_url, strategy,len(brand_ls),len(tasks),chunkIndex+1)
|
||||
for chunkIndex,(chunk, file_url) in enumerate(tasks)
|
||||
]
|
||||
for future in as_completed(futures):
|
||||
if _is_task_cancelled(task_id):
|
||||
for f in futures:
|
||||
f.cancel()
|
||||
_push_task_event(task_id, {'status': 'cancelled'})
|
||||
return
|
||||
future.result()
|
||||
_push_task_event(task_id, {'status': 'success'})
|
||||
except Exception as e:
|
||||
print("执行出错",traceback.format_exc())
|
||||
_push_task_event(task_id, {'status': 'failed', 'error_message': str(e)})
|
||||
print("执行完成")
|
||||
|
||||
|
||||
def _parse_json(val, default=None):
|
||||
if val is None:
|
||||
return default if default is not None else []
|
||||
if isinstance(val, (list, dict)):
|
||||
return val
|
||||
try:
|
||||
return json.loads(val)
|
||||
except Exception:
|
||||
return default if default is not None else []
|
||||
|
||||
|
||||
def _is_url(s):
|
||||
if not isinstance(s, str) or not s.strip():
|
||||
return False
|
||||
return s.strip().startswith('http://') or s.strip().startswith('https://')
|
||||
|
||||
|
||||
def _normalize_result_paths(raw):
|
||||
"""
|
||||
将 result_paths 规范化为 (url_list, zip_url)。
|
||||
支持旧格式 list 或新格式 dict {"urls": [...], "zip_url": "..."}。
|
||||
"""
|
||||
if not raw:
|
||||
return [], None
|
||||
if isinstance(raw, dict):
|
||||
urls = raw.get('urls') or []
|
||||
if not isinstance(urls, list):
|
||||
urls = []
|
||||
zip_url = raw.get('zip_url')
|
||||
if zip_url and not isinstance(zip_url, str):
|
||||
zip_url = None
|
||||
return urls, zip_url
|
||||
if isinstance(raw, list):
|
||||
return raw, None
|
||||
return [], None
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/expand-folder', methods=['POST'])
|
||||
@login_required
|
||||
def api_brand_expand_folder():
|
||||
"""展开文件夹,返回其下所有 .xlsx 文件路径列表"""
|
||||
try:
|
||||
_, err = _get_user_id_or_error()
|
||||
if err:
|
||||
return err
|
||||
data = request.get_json() or {}
|
||||
folder = (data.get('folder') or '').strip()
|
||||
if not folder:
|
||||
return jsonify({'success': False, 'error': '请提供 folder 路径'}), 400
|
||||
paths = _expand_folder_xlsx(folder)
|
||||
return jsonify({'success': True, 'paths': paths})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
def _expand_folder_xlsx_recursive_items(folder_path):
|
||||
"""返回文件夹下所有 .xlsx 文件的绝对路径和相对路径列表(递归)"""
|
||||
if not folder_path or not os.path.isdir(folder_path):
|
||||
return []
|
||||
items = []
|
||||
root = os.path.normpath(folder_path)
|
||||
for current_root, _, filenames in os.walk(root):
|
||||
for name in filenames:
|
||||
if not (name.endswith('.xlsx') or name.endswith('.XLSX')):
|
||||
continue
|
||||
absolute_path = os.path.normpath(os.path.join(current_root, name))
|
||||
relative_path = os.path.relpath(absolute_path, root).replace('\\', '/')
|
||||
items.append({
|
||||
'absolutePath': absolute_path,
|
||||
'relativePath': relative_path,
|
||||
})
|
||||
items.sort(key=lambda item: item['relativePath'])
|
||||
return items
|
||||
|
||||
@brand_bp.route('/api/brand/expand-folder-recursive', methods=['POST'])
|
||||
@login_required
|
||||
def api_brand_expand_folder_recursive():
|
||||
"""递归展开文件夹,返回 .xlsx 文件绝对路径及相对路径列表"""
|
||||
try:
|
||||
_, err = _get_user_id_or_error()
|
||||
if err:
|
||||
return err
|
||||
data = request.get_json() or {}
|
||||
folder = (data.get('folder') or '').strip()
|
||||
if not folder:
|
||||
return jsonify({'success': False, 'error': '请提供 folder 路径'}), 400
|
||||
items = _expand_folder_xlsx_recursive_items(folder)
|
||||
return jsonify({'success': True, 'items': items})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/run', methods=['POST'])
|
||||
# @login_required
|
||||
def api_brand_run():
|
||||
"""立即运行:创建任务并后台执行,立即返回 task_id,前端可轮询进度与取消"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
paths = data.get('paths') or []
|
||||
# task_type: 1=立即执行,2=添加任务;此接口默认 1
|
||||
try:
|
||||
task_type = int(data.get('task_type') or 1)
|
||||
except Exception:
|
||||
task_type = 1
|
||||
if task_type not in (1, 2):
|
||||
task_type = 1
|
||||
strategy = (data.get('strategy') or 'Terms').strip()
|
||||
if strategy not in ('Terms', 'Simple'):
|
||||
strategy = 'Terms'
|
||||
if not isinstance(paths, list):
|
||||
paths = []
|
||||
paths = [p.strip() for p in paths if p and isinstance(p, str) and os.path.isfile(p.strip())]
|
||||
if not paths:
|
||||
return jsonify({'success': False, 'error': '没有有效的 xlsx 文件路径'}), 400
|
||||
# 先上传到 OSS,获取链接再入库
|
||||
user_id, err = _get_user_id_or_error()
|
||||
if err:
|
||||
return err
|
||||
urls = []
|
||||
for p in paths:
|
||||
url, err = _upload_local_xlsx_to_oss(p, user_id)
|
||||
if err:
|
||||
return jsonify({'success': False, 'error': f'上传文件失败: {os.path.basename(p)} - {err}'}), 500
|
||||
base_name = os.path.basename(p)
|
||||
urls.append({"fileUrl": url,"originalFilename": base_name,"relativePath": p })
|
||||
|
||||
# 请求提交
|
||||
resp = requests.post(f"{JAVA_API_BASE}/api/brand/tasks?userId={user_id}",headers={
|
||||
"content-type":"application/json",
|
||||
},data=json.dumps({ "files": urls, "strategy": strategy,"taskType": 1,"archiveName": ""}))
|
||||
print({ "files": urls, "strategy": strategy,"taskType": 1,"archiveName": ""})
|
||||
print(f"{JAVA_API_BASE}/api/brand/tasks?userId={user_id}")
|
||||
resp_data = resp.json()
|
||||
# print(f"{JAVA_API_BASE}/api/brand/tasks?userId={user_id} 返回:",resp_data,"状态:",resp.status_code)
|
||||
if resp_data.get("success"):
|
||||
task_id = resp_data.get("data").get("taskId")
|
||||
data = resp_data.get("data")
|
||||
threading.Thread(target=_background_brand_task, args=(task_id,data), daemon=True).start()
|
||||
return jsonify({'success': True, 'task_id': task_id})
|
||||
else:
|
||||
print(resp_data)
|
||||
return jsonify({'success': False, 'error': "请求接口失败"}), 500
|
||||
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/tasks', methods=['GET', 'POST'])
|
||||
# @login_required
|
||||
def api_brand_tasks():
|
||||
"""GET: 获取当前用户的任务列表;POST: 添加任务(后台执行)"""
|
||||
if request.method == 'GET':
|
||||
try:
|
||||
user_id, err = _get_user_id_or_error()
|
||||
if err:
|
||||
return err
|
||||
resp = requests.get(f"{JAVA_API_BASE}/api/brand/tasks", params={
|
||||
"userId": user_id
|
||||
})
|
||||
resp_data = resp.json()
|
||||
print("获取列表",resp.text)
|
||||
if resp_data.get("success"):
|
||||
items = resp_data.get("data").get("items")
|
||||
return jsonify({'success': True, 'items': items})
|
||||
return jsonify({'success': True, 'items': []})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
print("获取列表失败",e)
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
# POST
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
paths = data.get('paths') or []
|
||||
# task_type: 1=立即执行,2=添加任务;此接口默认 2
|
||||
try:
|
||||
task_type = int(data.get('task_type') or 2)
|
||||
except Exception:
|
||||
task_type = 2
|
||||
if task_type not in (1, 2):
|
||||
task_type = 2
|
||||
strategy = (data.get('strategy') or 'Terms').strip()
|
||||
if strategy not in ('Terms', 'Simple'):
|
||||
strategy = 'Terms'
|
||||
if not isinstance(paths, list):
|
||||
paths = []
|
||||
paths = [p.strip() for p in paths if p and isinstance(p, str)]
|
||||
if not paths:
|
||||
return jsonify({'success': False, 'error': '请提供至少一个文件路径或链接'}), 400
|
||||
user_id, err = _get_user_id_or_error()
|
||||
if err:
|
||||
return err
|
||||
urls = []
|
||||
for p in paths:
|
||||
url, err = _upload_local_xlsx_to_oss(p, user_id)
|
||||
if err:
|
||||
return jsonify({'success': False, 'error': f'上传文件失败: {os.path.basename(p)} - {err}'}), 500
|
||||
base_name = os.path.basename(p)
|
||||
urls.append({"fileUrl": url, "originalFilename": base_name, "relativePath": p})
|
||||
params = {
|
||||
"userId": user_id
|
||||
}
|
||||
# resp = requests.post(f"{JAVA_API_BASE}/api/brand/tasks?userId={user_id}",headers={
|
||||
# "content-type":"application/json",
|
||||
# },data=json.dumps({ "files": urls, "strategy": strategy,"taskType": 1,"archiveName": ""}))
|
||||
req_data = {"files": urls, "strategy": strategy, "taskType": 2, "archiveName": ""}
|
||||
resp = requests.post(f"{JAVA_API_BASE}/api/brand/tasks?userId={user_id}", headers={"content-type": "application/json"},
|
||||
data=json.dumps(req_data))
|
||||
print(req_data)
|
||||
# print(f"{JAVA_API_BASE}/api/brand/tasks?userId={user_id},返回", resp.text)
|
||||
resp_data = resp.json()
|
||||
if resp_data.get("success"):
|
||||
task_id = resp_data.get("data").get("taskId")
|
||||
data = resp_data.get("data")
|
||||
return jsonify({'success': True, 'task_id': task_id})
|
||||
return jsonify({'success': False, 'task_id': "请求异常"})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/tasks/<int:task_id>')
|
||||
# @login_required
|
||||
def api_brand_task_detail(task_id):
|
||||
"""获取单个任务详情(用于轮询状态)"""
|
||||
try:
|
||||
resp = requests.get(f"{JAVA_API_BASE}/api/brand/tasks/{task_id}")
|
||||
resp_data = resp.json()
|
||||
if resp_data.get("success"):
|
||||
task = resp_data["data"]["task"]
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'task': task
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/tasks/<int:task_id>/line-progress')
|
||||
# @login_required
|
||||
def api_brand_task_line_progress(task_id):
|
||||
"""
|
||||
获取任务的“当前文件行级进度”(当前行数/总行数)。
|
||||
该信息仅存放在内存字典 _task_line_progress 中,不写入数据库。
|
||||
"""
|
||||
try:
|
||||
resp = requests.get(f"{JAVA_API_BASE}/api/brand/tasks/{task_id}")
|
||||
resp_data = resp.json()
|
||||
if resp_data.get("success"):
|
||||
if resp_data["data"]["line_progress"]["has_progress"]:
|
||||
info = resp_data["data"]["line_progress"]["info"]
|
||||
resp = {
|
||||
'file_index': int(info.get('file_index') or 0),
|
||||
'file_total': int(info.get('file_total') or 0),
|
||||
'file_name': info.get("file_name",""),
|
||||
'current_line': int(info.get('current_line') or 0),
|
||||
'total_lines': int(info.get('total_lines') or 0),
|
||||
}
|
||||
return jsonify({'success': True, 'has_progress': True, 'info': resp})
|
||||
raise RuntimeError("获取进度失败")
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/tasks/<int:task_id>/events')
|
||||
# @login_required
|
||||
def api_brand_task_events(task_id):
|
||||
"""SSE:任务完成时后端主动推送事件,前端监听后隐藏进度条与取消按钮"""
|
||||
user_id, err = _get_user_id_or_error()
|
||||
if err:
|
||||
return err
|
||||
|
||||
def _task_status_event():
|
||||
conn = get_db()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT status, error_message FROM brand_crawl_tasks WHERE id = %s AND user_id = %s",
|
||||
(task_id, user_id)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if not row:
|
||||
return None
|
||||
st = (row.get('status') or '').lower()
|
||||
if st in ('success', 'failed', 'cancelled'):
|
||||
return {'status': st, 'error_message': (row.get('error_message') or '').strip() or None}
|
||||
return None
|
||||
|
||||
# 若任务已处于终态,直接返回一条事件后结束
|
||||
ev = _task_status_event()
|
||||
if ev is not None:
|
||||
def _one_shot():
|
||||
yield "data: " + json.dumps(ev, ensure_ascii=False) + "\n\n"
|
||||
return Response(
|
||||
_one_shot(),
|
||||
mimetype='text/event-stream',
|
||||
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}
|
||||
)
|
||||
|
||||
# 否则注册队列,等待后台任务完成时推送
|
||||
q = Queue()
|
||||
with _task_event_lock:
|
||||
_task_event_queues.setdefault(task_id, []).append(q)
|
||||
|
||||
def _stream():
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
event = q.get(timeout=20)
|
||||
yield "data: " + json.dumps(event, ensure_ascii=False) + "\n\n"
|
||||
return
|
||||
except Empty:
|
||||
yield ": keepalive\n\n"
|
||||
finally:
|
||||
with _task_event_lock:
|
||||
lst = _task_event_queues.get(task_id, [])
|
||||
if q in lst:
|
||||
lst.remove(q)
|
||||
if not lst:
|
||||
_task_event_queues.pop(task_id, None)
|
||||
|
||||
return Response(
|
||||
_stream(),
|
||||
mimetype='text/event-stream',
|
||||
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}
|
||||
)
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/tasks/<int:task_id>/cancel', methods=['POST'])
|
||||
# @login_required
|
||||
def api_brand_task_cancel(task_id):
|
||||
"""取消正在执行或等待中的任务(pending/running 均可取消)"""
|
||||
try:
|
||||
user_id, err = _get_user_id_or_error()
|
||||
if err:
|
||||
return err
|
||||
|
||||
|
||||
resp = requests.post(
|
||||
f"{JAVA_API_BASE}/api/brand/tasks/{task_id}/cancel",
|
||||
headers={"accept": "application/json"},
|
||||
timeout=20
|
||||
)
|
||||
try:
|
||||
resp_data = resp.json()
|
||||
except Exception:
|
||||
return jsonify({'success': False, 'error': '取消接口返回非 JSON'}), 500
|
||||
if not resp_data.get('success'):
|
||||
return jsonify({'success': False, 'error': '第三方取消任务失败'}), 400
|
||||
|
||||
_mark_task_cancelled(task_id)
|
||||
_push_task_event(task_id, {'status': 'cancelled'})
|
||||
return jsonify({'success': True})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/tasks/<int:task_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def api_brand_task_delete(task_id):
|
||||
"""删除任务(仅限非 running 状态)"""
|
||||
try:
|
||||
user_id, err = _get_user_id_or_error()
|
||||
if err:
|
||||
return err
|
||||
|
||||
resp = requests.delete(
|
||||
f"{JAVA_API_BASE}/api/brand/tasks/{task_id}",
|
||||
headers={"accept": "application/json"},
|
||||
timeout=20
|
||||
)
|
||||
try:
|
||||
resp_data = resp.json()
|
||||
except Exception:
|
||||
return jsonify({'success': False, 'error': '取消接口返回非 JSON'}), 500
|
||||
if not resp_data.get('success'):
|
||||
return jsonify({'success': False, 'error': '第三方取消任务失败'}), 400
|
||||
return jsonify({'success': True})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/download/<int:task_id>')
|
||||
@login_required
|
||||
def api_brand_download(task_id):
|
||||
"""下载任务结果:优先使用 OSS zip_url 重定向;否则本地/单链接/多链接按原逻辑处理"""
|
||||
try:
|
||||
user_id, err = _get_user_id_or_error()
|
||||
if err:
|
||||
return err
|
||||
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT result_paths FROM brand_crawl_tasks WHERE id = %s AND user_id = %s",
|
||||
(task_id, user_id)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
if not row or not row.get('result_paths'):
|
||||
return jsonify({'success': False, 'error': '无结果可下载'}), 404
|
||||
raw = row['result_paths']
|
||||
if isinstance(raw, str):
|
||||
raw = _parse_json(raw)
|
||||
url_list, zip_url = _normalize_result_paths(raw)
|
||||
# 新格式:有 zip_url 直接重定向到 OSS
|
||||
if zip_url and _is_url(zip_url):
|
||||
return redirect(zip_url, code=302)
|
||||
|
||||
# 兼容旧格式:result_paths 可能为 list(本地路径或 url)
|
||||
if not url_list and isinstance(raw, list):
|
||||
url_list = raw
|
||||
local_paths = [p for p in url_list if isinstance(p, str) and not _is_url(p) and os.path.isfile(p)]
|
||||
url_paths = [p.strip() for p in url_list if isinstance(p, str) and _is_url(p)]
|
||||
|
||||
if len(url_paths) == 1 and not local_paths:
|
||||
return redirect(url_paths[0], code=302)
|
||||
|
||||
if len(local_paths) == 1 and not url_paths:
|
||||
return send_file(
|
||||
local_paths[0],
|
||||
as_attachment=True,
|
||||
download_name=os.path.basename(local_paths[0])
|
||||
)
|
||||
|
||||
if local_paths or url_paths:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for p in local_paths:
|
||||
zf.write(p, os.path.basename(p))
|
||||
for url in url_paths:
|
||||
try:
|
||||
import requests as req
|
||||
r = req.get(url, timeout=30, stream=True)
|
||||
r.raise_for_status()
|
||||
name = os.path.basename(urlparse(url).path) or ('file_%s' % (url_paths.index(url)))
|
||||
if not name or name == 'file_%s' % url_paths.index(url):
|
||||
name = 'download_%s' % url_paths.index(url)
|
||||
zf.writestr(name, r.content)
|
||||
except Exception:
|
||||
pass
|
||||
buf.seek(0)
|
||||
return send_file(
|
||||
buf,
|
||||
mimetype='application/zip',
|
||||
as_attachment=True,
|
||||
download_name='brand_task_%s.zip' % task_id
|
||||
)
|
||||
|
||||
return jsonify({'success': False, 'error': '结果文件不存在或链接不可用'}), 404
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import threading
|
||||
import time
|
||||
from queue import Empty, Full
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from config import JSON_TASK_QUEUE,runing_task
|
||||
|
||||
|
||||
communication_bp = Blueprint('communication', __name__)
|
||||
|
||||
_del_brand_cache = {}
|
||||
_del_brand_cache_lock = threading.Lock()
|
||||
_latest_del_brand_item = None
|
||||
_latest_del_brand_ts = 0.0
|
||||
|
||||
|
||||
def _resolve_request_id():
|
||||
rid = request.args.get('id')
|
||||
if rid is not None and str(rid).strip() != '':
|
||||
return str(rid)
|
||||
if request.method == 'POST':
|
||||
body = request.get_json(silent=True) or {}
|
||||
rid = body.get('id')
|
||||
if rid is not None and str(rid).strip() != '':
|
||||
return str(rid)
|
||||
return None
|
||||
|
||||
|
||||
@communication_bp.route('/api/amazon/del_brand', methods=['GET', 'POST'])
|
||||
def del_brand():
|
||||
"""返回队列中的一个元素;同一 id 重复请求返回已缓存的同一元素;2分钟内不同 id 也返回最近元素。"""
|
||||
global _latest_del_brand_item, _latest_del_brand_ts
|
||||
request_id = _resolve_request_id()
|
||||
if not request_id:
|
||||
return jsonify({'success': False, 'message': '缺少参数 id', 'data': None}), 400
|
||||
|
||||
with _del_brand_cache_lock:
|
||||
if request_id in _del_brand_cache:
|
||||
item = _del_brand_cache[request_id]
|
||||
print("【删除品牌】缓存命中", request_id, item)
|
||||
return jsonify({'success': True, 'data': item})
|
||||
if _latest_del_brand_item is not None and (time.time() - _latest_del_brand_ts) <= 120:
|
||||
_del_brand_cache[request_id] = _latest_del_brand_item
|
||||
print("【删除品牌】2分钟窗口命中", request_id, _latest_del_brand_item)
|
||||
return jsonify({'success': True, 'data': _latest_del_brand_item})
|
||||
|
||||
try:
|
||||
item = JSON_TASK_QUEUE.get_nowait()
|
||||
except Empty:
|
||||
print("队列为空")
|
||||
return jsonify({'success': False, 'message': '队列为空', 'data': None})
|
||||
|
||||
with _del_brand_cache_lock:
|
||||
if request_id in _del_brand_cache:
|
||||
try:
|
||||
JSON_TASK_QUEUE.put_nowait(item)
|
||||
except Full:
|
||||
print("【删除品牌】队列已满,无法归还重复拉取的任务")
|
||||
item = _del_brand_cache[request_id]
|
||||
print("【删除品牌】并发归并", request_id, item)
|
||||
return jsonify({'success': True, 'data': item})
|
||||
_del_brand_cache[request_id] = item
|
||||
_latest_del_brand_item = item
|
||||
_latest_del_brand_ts = time.time()
|
||||
print("【删除品牌】返回任务数据", item)
|
||||
return jsonify({'success': True, 'data': item})
|
||||
|
||||
|
||||
@communication_bp.route('/api/amazon/get_all_detail', methods=['GET', 'POST'])
|
||||
def get_all_detail():
|
||||
"""获取正在执行的删除品牌任务详情"""
|
||||
request_id = _resolve_request_id()
|
||||
if not request_id: #如果没有提供 id 参数,直接返回所有正在执行的任务详情
|
||||
return jsonify({'success': True, 'data': runing_task})
|
||||
task_info = runing_task.get(request_id)
|
||||
if not task_info:
|
||||
return jsonify({'success': False, 'message': '任务不存在', 'data': None})
|
||||
return jsonify({'success': True, 'data': task_info})
|
||||
|
||||
|
||||
|
||||
@@ -1,411 +0,0 @@
|
||||
"""
|
||||
图片生成蓝图:生成、历史、下载、拼接、版本
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import re
|
||||
import io
|
||||
import base64
|
||||
import tempfile
|
||||
import threading
|
||||
import subprocess
|
||||
import requests
|
||||
from urllib.parse import urlparse, quote
|
||||
|
||||
from flask import Blueprint, request, jsonify, Response, session, send_file
|
||||
from PIL import Image
|
||||
|
||||
from app_common import get_db, login_required, BASE_DIR
|
||||
from config import STITCH_WORKFLOW_ID,client_name
|
||||
|
||||
image_bp = Blueprint('image', __name__)
|
||||
|
||||
|
||||
def _load_image_from_url_or_data(url_or_data):
|
||||
"""从 http(s) URL 或 data URL 加载为 PIL Image,失败返回 None"""
|
||||
if not url_or_data or not isinstance(url_or_data, str):
|
||||
return None
|
||||
try:
|
||||
if url_or_data.startswith('data:'):
|
||||
m = re.match(r'data:image/[^;]+;base64,(.+)', url_or_data, re.DOTALL)
|
||||
if not m:
|
||||
return None
|
||||
raw = base64.b64decode(m.group(1).strip())
|
||||
img = Image.open(io.BytesIO(raw))
|
||||
elif url_or_data.startswith(('http://', 'https://')):
|
||||
resp = requests.get(url_or_data, timeout=15)
|
||||
resp.raise_for_status()
|
||||
img = Image.open(io.BytesIO(resp.content))
|
||||
else:
|
||||
return None
|
||||
if img.mode != 'RGB':
|
||||
img = img.convert('RGB')
|
||||
return img
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _stitch_and_upload_long_image(urls):
|
||||
"""将多张图片 URL 先上传获取 file_id,再调用 workflow_run 拼接长图,返回 data.merged_image_url;失败返回 None。"""
|
||||
if not urls or not isinstance(urls, (list, tuple)):
|
||||
return None
|
||||
from coze import upload_file as coze_upload_file, workflow_run
|
||||
file_ids = []
|
||||
temp_paths = []
|
||||
try:
|
||||
for u in urls:
|
||||
img = _load_image_from_url_or_data(u)
|
||||
if img is None:
|
||||
continue
|
||||
fd, path = tempfile.mkstemp(suffix='.png')
|
||||
try:
|
||||
os.close(fd)
|
||||
img.save(path)
|
||||
temp_paths.append(path)
|
||||
resp = coze_upload_file(path)
|
||||
if resp.get('code') == 0 and resp.get('data', {}).get('id'):
|
||||
file_ids.append(resp['data']['id'])
|
||||
except Exception:
|
||||
pass
|
||||
if not file_ids:
|
||||
return None
|
||||
parameters = {"images": [{"file_id": fid} for fid in file_ids]}
|
||||
resp = workflow_run(STITCH_WORKFLOW_ID, parameters, is_async=False)
|
||||
if resp.get('code') != 0:
|
||||
return None
|
||||
data = resp.get('data') or {}
|
||||
data = json.loads(data)
|
||||
merged_image_url = data.get('merged_image_url')
|
||||
if merged_image_url:
|
||||
return merged_image_url
|
||||
output_str = data.get('output') or ''
|
||||
if output_str:
|
||||
try:
|
||||
outer = json.loads(output_str)
|
||||
inner_str = outer.get('Output', '{}')
|
||||
inner = json.loads(inner_str)
|
||||
data_str = inner.get('data', '[]')
|
||||
inner_data = json.loads(data_str)
|
||||
merged_image_url = inner_data.get('merged_image_url')
|
||||
return merged_image_url
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
for p in temp_paths:
|
||||
try:
|
||||
os.unlink(p)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _sanitize_params_for_history(params):
|
||||
"""移除 base64 大字段及敏感字段,仅保留可存储的请求参数"""
|
||||
exclude = ('ref_images', 'proc_images', 'layout_image')
|
||||
out = {}
|
||||
for k, v in (params or {}).items():
|
||||
if k == 'api_key':
|
||||
continue
|
||||
if k in exclude:
|
||||
if isinstance(v, list):
|
||||
out[f'{k}_count'] = len(v)
|
||||
else:
|
||||
out[f'{k}_count'] = 1 if v else 0
|
||||
elif isinstance(v, (str, int, float, bool, type(None))):
|
||||
out[k] = v
|
||||
elif isinstance(v, list) and not v:
|
||||
out[k] = []
|
||||
elif isinstance(v, list) and isinstance(v[0], (str, int, float, bool)):
|
||||
out[k] = v
|
||||
else:
|
||||
out[k] = str(v)[:200] if v else None
|
||||
return out
|
||||
|
||||
|
||||
@image_bp.route('/api/generate', methods=['POST'])
|
||||
@login_required
|
||||
def api_generate():
|
||||
"""生成图片:调用 generate_api,上传原图到 OSS,保存历史记录"""
|
||||
try:
|
||||
params = request.get_json() or {}
|
||||
from generate_api import generate
|
||||
result = generate(params)
|
||||
if result.get('success') and result.get('urls'):
|
||||
long_image_url = result.get("long_image_url")
|
||||
result["long_image_url"] = long_image_url
|
||||
import json as _json
|
||||
history_id = None
|
||||
try:
|
||||
hid = params.get('history_id')
|
||||
if hid is not None:
|
||||
try:
|
||||
hid = int(hid)
|
||||
except (TypeError, ValueError):
|
||||
hid = None
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
if hid is not None and hid > 0:
|
||||
cur.execute(
|
||||
"SELECT result_urls FROM image_history WHERE id=%s AND user_id=%s",
|
||||
(hid, session['user_id']),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
existing_urls = []
|
||||
if row and row.get('result_urls'):
|
||||
try:
|
||||
existing_urls = _json.loads(row['result_urls'])
|
||||
except Exception:
|
||||
existing_urls = []
|
||||
new_urls = result.get('urls') or []
|
||||
new_url = new_urls[0] if new_urls else None
|
||||
idx = params.get('history_index', 0)
|
||||
try:
|
||||
idx = int(idx)
|
||||
except (TypeError, ValueError):
|
||||
idx = 0
|
||||
if new_url:
|
||||
if not isinstance(existing_urls, list):
|
||||
existing_urls = []
|
||||
while len(existing_urls) <= idx:
|
||||
existing_urls.append(existing_urls[-1] if existing_urls else new_url)
|
||||
existing_urls[idx] = new_url
|
||||
merged_result_urls = existing_urls or new_urls
|
||||
cur.execute(
|
||||
"""UPDATE image_history SET panel_type=%s, original_urls=%s, params=%s, result_urls=%s
|
||||
WHERE id=%s AND user_id=%s""",
|
||||
(
|
||||
params.get('panel_type', ''),
|
||||
_json.dumps(result.get('original_urls') or []),
|
||||
_json.dumps(_sanitize_params_for_history(params)),
|
||||
_json.dumps(merged_result_urls),
|
||||
hid,
|
||||
session['user_id'],
|
||||
),
|
||||
)
|
||||
if cur.rowcount > 0:
|
||||
history_id = hid
|
||||
else:
|
||||
cur.execute(
|
||||
"""INSERT INTO image_history (user_id, panel_type, original_urls, params, result_urls, long_image_url)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)""",
|
||||
(
|
||||
session['user_id'],
|
||||
params.get('panel_type', ''),
|
||||
_json.dumps(result.get('original_urls') or []),
|
||||
_json.dumps(_sanitize_params_for_history(params)),
|
||||
_json.dumps(result.get('urls') or []),
|
||||
long_image_url,
|
||||
),
|
||||
)
|
||||
history_id = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
if history_id is not None:
|
||||
result['history_id'] = history_id
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'success': False, 'urls': [], 'error': str(e)})
|
||||
|
||||
|
||||
@image_bp.route('/api/version')
|
||||
def api_version():
|
||||
"""检测更新:请求 APP_UPDATE_URL 获取最新版本信息,与当前版本比较"""
|
||||
current_version = (os.environ.get('APP_VERSION', '1.0.0') or '1.0.0').strip()
|
||||
update_url = (os.environ.get('APP_UPDATE_URL', '') or '').strip()
|
||||
result = {
|
||||
'version': current_version,
|
||||
'desc': '',
|
||||
'url': '',
|
||||
'has_update': False,
|
||||
'latest_version': current_version,
|
||||
'file_url': '',
|
||||
}
|
||||
if not update_url:
|
||||
return jsonify(result)
|
||||
try:
|
||||
resp = requests.get(update_url, timeout=10)
|
||||
resp.raise_for_status()
|
||||
data = resp.json() or {}
|
||||
latest_version = (data.get('version') or '').strip()
|
||||
file_url = (data.get('file_url') or '').strip()
|
||||
result['latest_version'] = latest_version
|
||||
result['file_url'] = file_url
|
||||
result['url'] = file_url
|
||||
# 版本不一致则视为有更新
|
||||
if latest_version and latest_version != current_version:
|
||||
result['has_update'] = True
|
||||
except Exception:
|
||||
pass
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
def _run_update_and_exit(zip_path, target_dir):
|
||||
"""在后台延迟后启动 update.exe(脱离当前进程),然后退出当前程序"""
|
||||
def _do():
|
||||
import time
|
||||
time.sleep(1.5) # 确保 HTTP 响应已发送
|
||||
# exe_dir = target_dir
|
||||
# exe_dir = "D:\\pack\\nanri\\update_exe\\main.dist"
|
||||
exe_dir = os.path.join(BASE_DIR,"update")
|
||||
update_exe = os.path.join(exe_dir, 'update.exe')
|
||||
if not os.path.isfile(update_exe):
|
||||
return
|
||||
try:
|
||||
creationflags = 0
|
||||
if sys.platform == 'win32':
|
||||
creationflags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
subprocess.Popen(
|
||||
[update_exe, '--zip', zip_path, '--target', target_dir, '--process',client_name],
|
||||
cwd=exe_dir,
|
||||
creationflags=creationflags,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
close_fds=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
os._exit(0)
|
||||
t = threading.Thread(target=_do, daemon=False)
|
||||
t.start()
|
||||
|
||||
|
||||
@image_bp.route('/api/update/do', methods=['POST'])
|
||||
def api_update_do():
|
||||
"""执行更新:下载 zip 到 tmp,启动 update.exe 后退出程序"""
|
||||
data = request.get_json() or {}
|
||||
file_url = (data.get('file_url') or '').strip()
|
||||
if not file_url:
|
||||
return jsonify({'success': False, 'error': '缺少 file_url'}), 400
|
||||
parsed = urlparse(file_url)
|
||||
if parsed.scheme not in ('http', 'https'):
|
||||
return jsonify({'success': False, 'error': '无效的下载地址'}), 400
|
||||
tmp_dir = os.path.join(BASE_DIR, 'tmp')
|
||||
try:
|
||||
os.makedirs(tmp_dir, exist_ok=True)
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': f'创建 tmp 目录失败: {e}'}), 500
|
||||
# 使用 URL 中的文件名或默认版本名
|
||||
filename = os.path.basename(parsed.path) or 'update.zip'
|
||||
zip_path = os.path.join(tmp_dir, filename)
|
||||
try:
|
||||
resp = requests.get(file_url, timeout=300, stream=True)
|
||||
resp.raise_for_status()
|
||||
with open(zip_path, 'wb') as f:
|
||||
for chunk in resp.iter_content(chunk_size=65536):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
except requests.RequestException as e:
|
||||
return jsonify({'success': False, 'error': f'下载失败: {e}'}), 502
|
||||
_run_update_and_exit(zip_path, BASE_DIR)
|
||||
return jsonify({'success': True, 'message': '更新已启动,程序即将退出'})
|
||||
|
||||
|
||||
@image_bp.route('/api/download')
|
||||
@login_required
|
||||
def api_download():
|
||||
"""代理下载图片,解决跨域 fetch 无法下载的问题"""
|
||||
url = request.args.get('url', '').strip()
|
||||
filename = request.args.get('filename', 'image.png')
|
||||
if not url:
|
||||
return jsonify({'success': False, 'error': '缺少 url 参数'}), 400
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ('http', 'https'):
|
||||
return jsonify({'success': False, 'error': '仅支持 http/https 链接'}), 400
|
||||
try:
|
||||
resp = requests.get(url, timeout=30, stream=True)
|
||||
resp.raise_for_status()
|
||||
content_type = resp.headers.get('Content-Type', 'image/png')
|
||||
encoded = quote(filename, safe='')
|
||||
disposition = f"attachment; filename*=UTF-8''{encoded}"
|
||||
return Response(
|
||||
resp.iter_content(chunk_size=8192),
|
||||
mimetype=content_type,
|
||||
headers={'Content-Disposition': disposition}
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 502
|
||||
|
||||
|
||||
@image_bp.route('/api/stitch/save', methods=['POST'])
|
||||
@login_required
|
||||
def api_stitch_save():
|
||||
"""手动拼接:接收图片 URL 列表(支持 http 或 data URL),拼接并上传,返回长图 URL"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
urls = data.get('urls')
|
||||
if not urls or not isinstance(urls, list):
|
||||
return jsonify({'success': False, 'error': '请提供 urls 数组'}), 400
|
||||
urls = [u for u in urls if u and isinstance(u, str)]
|
||||
if not urls:
|
||||
return jsonify({'success': False, 'error': '没有有效的图片'}), 400
|
||||
long_image_url = _stitch_and_upload_long_image(urls)
|
||||
if not long_image_url:
|
||||
return jsonify({'success': False, 'error': '拼接或上传失败'}), 500
|
||||
return jsonify({'success': True, 'long_image_url': long_image_url})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@image_bp.route('/api/history')
|
||||
@login_required
|
||||
def api_history():
|
||||
"""分页获取当前用户的历史图库,支持按 panel_type 栏目筛选"""
|
||||
page = max(1, int(request.args.get('page', 1)))
|
||||
page_size = min(50, max(10, int(request.args.get('page_size', 20))))
|
||||
panel_type = (request.args.get('panel_type') or '').strip()
|
||||
offset = (page - 1) * page_size
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
where_user = "user_id = %s"
|
||||
params_where = [session['user_id']]
|
||||
if panel_type:
|
||||
where_user += " AND panel_type = %s"
|
||||
params_where.append(panel_type)
|
||||
cur.execute(
|
||||
"""SELECT id, created_at, panel_type, original_urls, params, result_urls, long_image_url
|
||||
FROM image_history WHERE """ + where_user + """ ORDER BY created_at DESC LIMIT %s OFFSET %s""",
|
||||
params_where + [page_size, offset],
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cur.execute("SELECT COUNT(*) as total FROM image_history WHERE " + where_user, params_where)
|
||||
total = cur.fetchone()['total']
|
||||
conn.close()
|
||||
|
||||
def _parse_json(val, default=None):
|
||||
if val is None:
|
||||
return default if default is not None else []
|
||||
if isinstance(val, (list, dict)):
|
||||
return val
|
||||
try:
|
||||
return json.loads(val)
|
||||
except Exception:
|
||||
return default if default is not None else []
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
items.append({
|
||||
'id': r['id'],
|
||||
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r['created_at'] else '',
|
||||
'panel_type': r['panel_type'] or '',
|
||||
'original_urls': _parse_json(r['original_urls'], []),
|
||||
'params': _parse_json(r['params'], {}),
|
||||
'result_urls': _parse_json(r['result_urls'], []),
|
||||
'long_image_url': (r.get('long_image_url') or '').strip() or None,
|
||||
})
|
||||
return jsonify({'success': True, 'items': items, 'total': total, 'page': page, 'page_size': page_size})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
"""
|
||||
主页面蓝图:首页、home、图片工作台、品牌页、静态文件、Logo
|
||||
"""
|
||||
import os
|
||||
from flask import Blueprint, send_file
|
||||
|
||||
from app_common import (
|
||||
get_db,
|
||||
_render_html,
|
||||
_is_session_user_valid,
|
||||
login_required,
|
||||
admin_required,
|
||||
STATIC_DIR,
|
||||
BASE_DIR,
|
||||
ASSETS_DIR
|
||||
)
|
||||
from flask import redirect, url_for, session
|
||||
from flask import Flask, request, Response, stream_with_context
|
||||
import requests
|
||||
|
||||
from config import base_url,version,JAVA_API_BASE
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
|
||||
@main_bp.route('/')
|
||||
def index():
|
||||
if session.get('user_id') and _is_session_user_valid():
|
||||
return redirect(url_for('main.home'))
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
|
||||
@main_bp.route('/home')
|
||||
@login_required
|
||||
def home():
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT username, is_admin FROM users WHERE id = %s", (session['user_id'],))
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
return _render_html('home.html', username=row.get('username', ''), is_admin=bool(row.get('is_admin')), user_id=session.get('user_id'),baseUrl=base_url,version=version)
|
||||
except Exception:
|
||||
return _render_html('home.html', username=session.get('username', ''), is_admin=False, user_id=session.get('user_id'),baseUrl=base_url,version=version)
|
||||
|
||||
|
||||
@main_bp.route('/image')
|
||||
@login_required
|
||||
def wb():
|
||||
return _render_html('index.html')
|
||||
|
||||
|
||||
@main_bp.route('/brand')
|
||||
@login_required
|
||||
def brand_page():
|
||||
return _render_html('brand.html')
|
||||
|
||||
|
||||
|
||||
|
||||
@main_bp.route('/static/<path:filename>')
|
||||
def serve_static(filename):
|
||||
"""提供 static 目录及子目录下的静态文件访问。"""
|
||||
filepath = os.path.normpath(os.path.join(STATIC_DIR, filename))
|
||||
static_abs = os.path.abspath(STATIC_DIR)
|
||||
file_abs = os.path.abspath(filepath)
|
||||
if not file_abs.startswith(static_abs) or not os.path.isfile(file_abs):
|
||||
return '', 404
|
||||
return send_file(file_abs, as_attachment=False)
|
||||
|
||||
|
||||
@main_bp.route('/assets/<path:filename>')
|
||||
def serve_assets(filename):
|
||||
"""提供 static 目录及子目录下的静态文件访问。"""
|
||||
filepath = os.path.normpath(os.path.join(ASSETS_DIR, filename))
|
||||
static_abs = os.path.abspath(ASSETS_DIR)
|
||||
file_abs = os.path.abspath(filepath)
|
||||
if not file_abs.startswith(static_abs) or not os.path.isfile(file_abs):
|
||||
return '', 404
|
||||
return send_file(file_abs, as_attachment=False)
|
||||
|
||||
|
||||
@main_bp.route('/new_web_source/<path:filename>')
|
||||
def serve_new_web_source(filename):
|
||||
"""提供 static 目录及子目录下的静态文件访问。"""
|
||||
filepath = os.path.normpath(os.path.join("new_web_source", filename))
|
||||
static_abs = os.path.abspath("new_web_source")
|
||||
file_abs = os.path.abspath(filepath)
|
||||
if not file_abs.startswith(static_abs) or not os.path.isfile(file_abs):
|
||||
return '', 404
|
||||
return send_file(file_abs, as_attachment=False)
|
||||
|
||||
|
||||
|
||||
@main_bp.route('/logo.jpg', methods=['GET'])
|
||||
def get_logo_image():
|
||||
return send_file(os.path.join(BASE_DIR, "logo.jpg"), mimetype='image/jpeg')
|
||||
|
||||
|
||||
@main_bp.route('/newApi/<path:path>', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'])
|
||||
def proxy(path):
|
||||
target_url = f"{JAVA_API_BASE}/{path}"
|
||||
|
||||
# 复制请求参数
|
||||
params = request.args.to_dict()
|
||||
|
||||
# 处理请求头
|
||||
headers = {}
|
||||
for key, value in request.headers:
|
||||
if key.lower() in ['host', 'content-length', 'connection']:
|
||||
continue
|
||||
headers[key] = value
|
||||
ignore_url = [f"{JAVA_API_BASE}/api/delete-brand/tasks/batch",
|
||||
f"{JAVA_API_BASE}/api/delete-brand/history",
|
||||
f"{JAVA_API_BASE}/api/product-risk-resolve/tasks/batch",
|
||||
]
|
||||
if target_url not in ignore_url:
|
||||
try:
|
||||
print("=============================")
|
||||
print("target_url:",target_url)
|
||||
print("params:",params)
|
||||
print("data:",request.get_data())
|
||||
print("=============================")
|
||||
except Exception as e:
|
||||
print("打印失败",e)
|
||||
|
||||
try:
|
||||
# 使用流式请求
|
||||
req = requests.request(
|
||||
method=request.method,
|
||||
url=target_url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
data=request.get_data() if request.get_data() else None,
|
||||
cookies=request.cookies,
|
||||
stream=True, # 启用流式传输
|
||||
timeout=30
|
||||
)
|
||||
|
||||
# 流式响应
|
||||
def generate():
|
||||
for chunk in req.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
yield chunk
|
||||
|
||||
# 构建响应
|
||||
response = Response(stream_with_context(generate()), status=req.status_code)
|
||||
|
||||
# 复制响应头
|
||||
for key, value in req.headers.items():
|
||||
if key.lower() not in ['content-encoding', 'content-length', 'transfer-encoding', 'connection']:
|
||||
response.headers[key] = value
|
||||
|
||||
return response
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
print("后端服务超时")
|
||||
return Response("后端服务超时", status=504)
|
||||
except requests.exceptions.ConnectionError:
|
||||
print("无法连接到后端服务")
|
||||
return Response("无法连接到后端服务", status=502)
|
||||
except Exception as e:
|
||||
print(f"代理请求失败: {e}")
|
||||
return Response(f"代理错误: {str(e)}", status=500)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,604 +0,0 @@
|
||||
import json
|
||||
import re
|
||||
import random
|
||||
import requests
|
||||
import time
|
||||
import unicodedata
|
||||
|
||||
from brand_spider.web_dec import decrypt_via_service, get_guid
|
||||
|
||||
try:
|
||||
from config import proxy_url as CONFIG_PROXY_URL, proxy_mode as CONFIG_PROXY_MODE
|
||||
except ImportError:
|
||||
CONFIG_PROXY_URL = None
|
||||
CONFIG_PROXY_MODE = 1
|
||||
|
||||
# Forbidden 后 3 分钟内统一使用代理:记录代理生效截止时间与当前代理
|
||||
_FORBIDDEN_PROXY_UNTIL = 0.0
|
||||
_FORBIDDEN_PROXY_DICT = None
|
||||
_FORBIDDEN_PROXY_MINUTES = 0.5
|
||||
|
||||
|
||||
special_char_pattern = re.compile(r'[^\w\s]')
|
||||
|
||||
def remove_accents(input_str):
|
||||
# 将字符分解为基础字符和重音符号
|
||||
nksel = unicodedata.normalize('NFKD', input_str)
|
||||
# 过滤掉非间距重音符号,并重新编码
|
||||
return "".join([c for c in nksel if not unicodedata.combining(c)])
|
||||
|
||||
|
||||
def clean_text(text):
|
||||
text = remove_accents(text)
|
||||
return special_char_pattern.sub(' ', text).replace(" ","")
|
||||
|
||||
|
||||
def create_code_generator():
|
||||
counter = random.randint(0, 0xFFFFFF)
|
||||
def get_code():
|
||||
nonlocal counter
|
||||
counter = (counter + 1) % 0xFFFFFF
|
||||
low_16_bits = counter & 0xFFFF
|
||||
return f"{low_16_bits:04x}"
|
||||
return get_code()
|
||||
|
||||
|
||||
def search(hashsearch, data, proxies=None):
|
||||
global _FORBIDDEN_PROXY_UNTIL, _FORBIDDEN_PROXY_DICT
|
||||
|
||||
url = "https://api.branddb.wipo.int/search"
|
||||
headers = {
|
||||
"accept": "application/json, text/plain, */*",
|
||||
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
"cache-control": "no-cache",
|
||||
"content-type": "application/json",
|
||||
"hashsearch": hashsearch,
|
||||
"origin": "https://branddb.wipo.int",
|
||||
"pragma": "no-cache",
|
||||
"priority": "u=1, i",
|
||||
"referer": "https://branddb.wipo.int/",
|
||||
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"
|
||||
}
|
||||
|
||||
|
||||
|
||||
# 若 3 分钟内曾出现 Forbidden,则直接使用当时保存的代理
|
||||
now = time.time()
|
||||
if now < _FORBIDDEN_PROXY_UNTIL and _FORBIDDEN_PROXY_DICT:
|
||||
proxies = _FORBIDDEN_PROXY_DICT
|
||||
print("处于 Forbidden 代理窗口内,直接使用代理",proxies)
|
||||
|
||||
# 发送 POST 请求
|
||||
response = requests.post(url, headers=headers, json=data, proxies=proxies)
|
||||
enc_text = response.text
|
||||
# print("原始结果", enc_text)
|
||||
|
||||
# 若返回 Forbidden,则从 config 的 proxy_url 获取代理 IP 后重试,并开启 3 分钟代理窗口
|
||||
if (enc_text.strip() == '{"message":"Forbidden"}' or "Too Many Requests" in enc_text) and CONFIG_PROXY_URL:
|
||||
try:
|
||||
proxy_resp = requests.get(CONFIG_PROXY_URL, timeout=10)
|
||||
print("代理请求结果->:", proxy_resp.text)
|
||||
# 模式 2:账号密码代理,接口返回 JSON
|
||||
if CONFIG_PROXY_MODE == 2:
|
||||
resp_json = proxy_resp.json()
|
||||
proxy_list = resp_json.get("data", {}).get("list") or []
|
||||
first_item = proxy_list[0] if proxy_list else None
|
||||
if first_item:
|
||||
ip = first_item.get("ip")
|
||||
port = first_item.get("port")
|
||||
account = first_item.get("account")
|
||||
password = first_item.get("password")
|
||||
if ip and port and account and password:
|
||||
auth_proxy = f"{account}:{password}@{ip}:{port}"
|
||||
print("获取到账号密码代理:", auth_proxy)
|
||||
proxies = {
|
||||
"http": f"http://{auth_proxy}",
|
||||
"https": f"http://{auth_proxy}",
|
||||
}
|
||||
# 默认模式 1:普通 IP:port 文本
|
||||
if CONFIG_PROXY_MODE != 2:
|
||||
proxy_ip = (proxy_resp.text or "").strip()
|
||||
if proxy_ip:
|
||||
proxies = {
|
||||
"http": f"http://{proxy_ip}",
|
||||
"https": f"http://{proxy_ip}",
|
||||
}
|
||||
|
||||
if proxies:
|
||||
response = requests.post(url, headers=headers, json=data, proxies=proxies)
|
||||
enc_text = response.text
|
||||
print("代理重试结果", enc_text)
|
||||
# 记录 3 分钟内都使用该代理
|
||||
_FORBIDDEN_PROXY_UNTIL = now + _FORBIDDEN_PROXY_MINUTES * 60
|
||||
_FORBIDDEN_PROXY_DICT = proxies
|
||||
except Exception as e:
|
||||
print("获取代理或重试失败:", e)
|
||||
|
||||
return enc_text
|
||||
|
||||
|
||||
class TaskCancelledError(Exception):
|
||||
"""用户取消任务时抛出,供上层捕获并中止"""
|
||||
pass
|
||||
|
||||
|
||||
def single_file_handle(brand, strategy="Terms"):
|
||||
status_info = {
|
||||
"Ended": "已结束",
|
||||
"Expired": "已过期的",
|
||||
"Pending": "待决",
|
||||
"Registered": "已注册",
|
||||
"RegisteredMadrid": "国际注册有效",
|
||||
"Unknown": "未知"
|
||||
}
|
||||
country_info = {
|
||||
"AB": "ARABPAT",
|
||||
"AD": "安道尔",
|
||||
"AE": "UAE",
|
||||
"AF": "阿富汗",
|
||||
"AFR": "非洲",
|
||||
"AG": "安提瓜和巴布达",
|
||||
"AI": "安圭拉",
|
||||
"AL": "阿尔巴尼亚",
|
||||
"AM": "亚美尼亚",
|
||||
"AN": "荷属安的列斯",
|
||||
"ANT": "南极洲",
|
||||
"AO": "安哥拉",
|
||||
"AP": "非洲地区知识产权组织 ",
|
||||
"AQ": "南极洲",
|
||||
"AR": "阿根廷",
|
||||
"AS": "美属萨摩亚",
|
||||
"ASI": "亚洲",
|
||||
"AT": "奥地利",
|
||||
"AU": "澳大利亚",
|
||||
"AW": "阿鲁巴",
|
||||
"AX": "奥兰群岛",
|
||||
"AZ": "阿塞拜疆",
|
||||
"BA": "波斯尼亚和黑塞哥维纳",
|
||||
"BB": "巴巴多斯",
|
||||
"BD": "孟加拉国",
|
||||
"BE": "比利时",
|
||||
"BF": "布基纳法索",
|
||||
"BG": "保加利亚",
|
||||
"BH": "巴林",
|
||||
"BI": "布隆迪",
|
||||
"BJ": "贝宁",
|
||||
"BL": "圣巴泰勒米",
|
||||
"BM": "百慕大",
|
||||
"BN": "文莱达鲁萨兰国",
|
||||
"BO": "多民族玻利维亚国",
|
||||
"BQ": "博纳尔、圣俄斯塔休斯和萨巴",
|
||||
"BR": "巴西",
|
||||
"BS": "巴哈马",
|
||||
"BT": "不丹",
|
||||
"BV": "布韦岛",
|
||||
"BW": "博茨瓦纳",
|
||||
"BX": "比荷卢知识产权局",
|
||||
"BY": "白俄罗斯",
|
||||
"BZ": "伯利兹",
|
||||
"CA": "加拿大",
|
||||
"CC": "科科斯群岛(基灵群岛)",
|
||||
"CD": "刚果民主共和国",
|
||||
"CF": "中非共和国",
|
||||
"CG": "刚果",
|
||||
"CH": "瑞士",
|
||||
"CI": "科特迪瓦",
|
||||
"CK": "库克群岛",
|
||||
"CL": "智利",
|
||||
"CM": "喀麦隆",
|
||||
"CN": "中国",
|
||||
"CO": "哥伦比亚",
|
||||
"CR": "哥斯达黎加",
|
||||
"CS": "捷克斯洛伐克",
|
||||
"CU": "古巴",
|
||||
"CV": "佛得角",
|
||||
"CW": "库拉索",
|
||||
"CX": "圣诞岛",
|
||||
"CY": "塞浦路斯",
|
||||
"CZ": "捷克共和国",
|
||||
"DD": "德意志民主共和国",
|
||||
"DE": "德国",
|
||||
"DJ": "吉布提",
|
||||
"DK": "丹麦",
|
||||
"DM": "多米尼克",
|
||||
"DO": "多米尼加",
|
||||
"DT": "西德",
|
||||
"DZ": "阿尔及利亚",
|
||||
"EA": "欧亚专利组织",
|
||||
"EC": "厄瓜多尔",
|
||||
"EE": "爱沙尼亚",
|
||||
"EG": "埃及",
|
||||
"EH": "西撒哈拉",
|
||||
"EM": "欧洲联盟",
|
||||
"EP": "欧洲专利局",
|
||||
"ER": "厄立特里亚",
|
||||
"ES": "西班牙",
|
||||
"ET": "埃塞俄比亚",
|
||||
"EUR": "欧洲",
|
||||
"FI": "芬兰",
|
||||
"FJ": "斐济",
|
||||
"FK": "福克兰群岛(马尔维纳斯群岛)",
|
||||
"FM": "密克罗尼西亚联邦",
|
||||
"FO": "法罗群岛",
|
||||
"FR": "法国",
|
||||
"GA": "加蓬",
|
||||
"GB": "英国",
|
||||
"GC": "海湾阿拉伯国家合作委员会专利局",
|
||||
"GD": "格林纳达",
|
||||
"GE": "格鲁吉亚",
|
||||
"GF": "法属圭亚那",
|
||||
"GG": "格恩西岛",
|
||||
"GH": "加纳",
|
||||
"GI": "直布罗陀",
|
||||
"GL": "格陵兰",
|
||||
"GM": "冈比亚",
|
||||
"GN": "几内亚",
|
||||
"GP": "瓜德罗普",
|
||||
"GQ": "赤道几内亚",
|
||||
"GR": "希腊",
|
||||
"GS": "南乔治亚和南桑威奇群岛",
|
||||
"GT": "危地马拉",
|
||||
"GU": "关岛",
|
||||
"GW": "几内亚比绍",
|
||||
"GY": "圭亚那",
|
||||
"HK": "香港",
|
||||
"HM": "赫德岛和麦克唐纳群岛",
|
||||
"HN": "洪都拉斯",
|
||||
"HR": "克罗地亚",
|
||||
"HT": "海地",
|
||||
"HU": "匈牙利",
|
||||
"IB": "世界知识产权组织国际局",
|
||||
"ID": "印度尼西亚",
|
||||
"IE": "爱尔兰",
|
||||
"IL": "以色列",
|
||||
"IM": "马恩岛",
|
||||
"IN": "印度",
|
||||
"INN": "世界卫生组织",
|
||||
"IO": "英属印度洋领地",
|
||||
"IQ": "伊拉克",
|
||||
"IR": "伊朗伊斯兰共和国",
|
||||
"IS": "冰岛",
|
||||
"IT": "意大利",
|
||||
"JE": "泽西岛",
|
||||
"JM": "牙买加",
|
||||
"JO": "约旦",
|
||||
"JP": "日本",
|
||||
"KE": "肯尼亚",
|
||||
"KG": "吉尔吉斯斯坦",
|
||||
"KH": "柬埔寨",
|
||||
"KI": "基里巴斯",
|
||||
"KM": "科摩罗",
|
||||
"KN": "圣基茨和尼维斯",
|
||||
"KP": "朝鲜民主主义人民共和国",
|
||||
"KR": "大韩民国",
|
||||
"KW": "科威特",
|
||||
"KY": "开曼群岛",
|
||||
"KZ": "哈萨克斯坦",
|
||||
"LA": "老挝人民民主共和国",
|
||||
"LB": "黎巴嫩",
|
||||
"LC": "圣卢西亚",
|
||||
"LI": "列支敦士登",
|
||||
"LISBON": "WIPO",
|
||||
"LK": "斯里兰卡",
|
||||
"LP": "LATIPAT",
|
||||
"LR": "利比里亚",
|
||||
"LS": "莱索托",
|
||||
"LT": "立陶宛",
|
||||
"LU": "卢森堡",
|
||||
"LV": "拉脱维亚",
|
||||
"LY": "利比亚",
|
||||
"MA": "摩洛哥",
|
||||
"MC": "摩纳哥",
|
||||
"MD": "摩尔多瓦共和国",
|
||||
"ME": "黑山",
|
||||
"MF": "圣马丁(法国部分)",
|
||||
"MG": "马达加斯加",
|
||||
"MH": "马绍尔群岛",
|
||||
"MK": "北马其顿共和国",
|
||||
"ML": "马里",
|
||||
"MM": "缅甸",
|
||||
"MN": "蒙古",
|
||||
"MO": "澳门",
|
||||
"MP": "北马里亚纳群岛",
|
||||
"MQ": "马提尼克",
|
||||
"MR": "毛里塔尼亚",
|
||||
"MS": "蒙特塞拉特",
|
||||
"MT": "马耳他",
|
||||
"MU": "毛里求斯",
|
||||
"MV": "马尔代夫",
|
||||
"MW": "马拉维",
|
||||
"MX": "墨西哥",
|
||||
"MY": "马来西亚",
|
||||
"MZ": "莫桑比克",
|
||||
"NA": "纳米比亚",
|
||||
"NAM": "北美洲",
|
||||
"NC": "新喀里多尼亚",
|
||||
"NE": "尼日尔",
|
||||
"NF": "诺福克岛",
|
||||
"NG": "尼日利亚",
|
||||
"NI": "尼加拉瓜",
|
||||
"NL": "荷兰",
|
||||
"NO": "挪威",
|
||||
"NP": "尼泊尔",
|
||||
"NR": "瑙鲁",
|
||||
"NU": "纽埃",
|
||||
"NZ": "新西兰",
|
||||
"OA": "非洲知识产权组织",
|
||||
"OCE": "大洋洲",
|
||||
"OM": "阿曼",
|
||||
"PA": "巴拿马",
|
||||
"PE": "秘鲁",
|
||||
"PF": "法属波利尼西亚",
|
||||
"PG": "巴布亚新几内亚",
|
||||
"PH": "菲律宾",
|
||||
"PK": "巴基斯坦",
|
||||
"PL": "波兰",
|
||||
"PM": "圣皮埃尔和密克隆",
|
||||
"PN": "皮特凯恩",
|
||||
"PR": "波多黎各",
|
||||
"PS": "巴勒斯坦",
|
||||
"PT": "葡萄牙",
|
||||
"PW": "帕劳",
|
||||
"PY": "巴拉圭",
|
||||
"QA": "卡塔尔",
|
||||
"QO": "没有ST.3代码的组织",
|
||||
"QZ": "欧洲联盟",
|
||||
"RE": "留尼汪",
|
||||
"RO": "罗马尼亚",
|
||||
"RS": "塞尔维亚",
|
||||
"RU": "俄罗斯联邦",
|
||||
"RW": "卢旺达",
|
||||
"SA": "沙特阿拉伯",
|
||||
"SAM": "南美洲",
|
||||
"SB": "所罗门群岛",
|
||||
"SC": "塞舌尔",
|
||||
"SD": "苏丹",
|
||||
"SE": "瑞典",
|
||||
"SG": "新加坡",
|
||||
"SH": "圣赫勒拿、阿森松和特里斯坦-达库尼亚",
|
||||
"SI": "斯洛文尼亚",
|
||||
"SIXTER": "WIPO",
|
||||
"SJ": "斯瓦尔巴和扬马延",
|
||||
"SK": "斯洛伐克",
|
||||
"SL": "塞拉里昂",
|
||||
"SM": "圣马力诺",
|
||||
"SN": "塞内加尔",
|
||||
"SO": "索马里",
|
||||
"SR": "苏里南",
|
||||
"SS": "南苏丹",
|
||||
"ST": "圣多美和普林西比",
|
||||
"SU": "苏联",
|
||||
"SV": "萨尔瓦多",
|
||||
"SX": "圣马丁(荷兰部分)",
|
||||
"SY": "阿拉伯叙利亚共和国",
|
||||
"SZ": "斯威士兰",
|
||||
"TC": "特克斯和凯科斯群岛",
|
||||
"TD": "乍得",
|
||||
"TF": "法属南部领地",
|
||||
"TG": "多哥",
|
||||
"TH": "泰国",
|
||||
"TJ": "塔吉克斯坦",
|
||||
"TK": "托克劳",
|
||||
"TL": "东帝汶",
|
||||
"TM": "土库曼斯坦",
|
||||
"TN": "突尼斯",
|
||||
"TO": "汤加",
|
||||
"TR": "土耳其",
|
||||
"TT": "特立尼达和多巴哥",
|
||||
"TV": "图瓦卢",
|
||||
"TW": "台湾(中国的省)",
|
||||
"TZ": "坦桑尼亚联合共和国",
|
||||
"UA": "乌克兰",
|
||||
"UG": "乌干达",
|
||||
"UK": "UK",
|
||||
"UM": "美国本土外小岛屿",
|
||||
"US": "USA",
|
||||
"UY": "乌拉圭",
|
||||
"UZ": "乌兹别克斯坦",
|
||||
"VA": "罗马教廷",
|
||||
"VC": "圣文森特和格林纳丁斯",
|
||||
"VE": "委内瑞拉玻利瓦尔共和国",
|
||||
"VG": "英属维尔京群岛",
|
||||
"VI": "美属维尔京群岛",
|
||||
"VN": "越南",
|
||||
"VU": "瓦努阿图",
|
||||
"WF": "瓦利斯和富图纳",
|
||||
"WHO": "世界卫生组织",
|
||||
"WO": "WIPO",
|
||||
"WS": "萨摩亚",
|
||||
"XK": "科索沃共和国",
|
||||
"XN": "北欧专利局",
|
||||
"XX": "国际",
|
||||
"XXX": "跨国和国际局",
|
||||
"YD": "民主也门",
|
||||
"YE": "也门",
|
||||
"YT": "马约特岛",
|
||||
"YU": "塞尔维亚和黑山",
|
||||
"ZA": "南非",
|
||||
"ZM": "赞比亚",
|
||||
"ZW": "津巴布韦"
|
||||
}
|
||||
|
||||
check_value = [
|
||||
"瑞典",
|
||||
"芬兰",
|
||||
"丹麦",
|
||||
"挪威",
|
||||
"冰岛",
|
||||
"法国",
|
||||
"爱尔兰",
|
||||
"荷兰",
|
||||
"比利时",
|
||||
"卢森堡",
|
||||
"英国",
|
||||
"摩纳哥",
|
||||
"德国",
|
||||
"波兰",
|
||||
"捷克",
|
||||
"斯洛伐克",
|
||||
"匈牙利",
|
||||
"奥地利",
|
||||
"瑞士",
|
||||
"列支敦士登",
|
||||
"爱沙尼亚",
|
||||
"拉脱维亚",
|
||||
"立陶宛",
|
||||
"俄罗斯",
|
||||
"白俄罗斯",
|
||||
"乌克兰",
|
||||
"摩尔多瓦",
|
||||
"西班牙",
|
||||
"葡萄牙",
|
||||
"意大利",
|
||||
"希腊",
|
||||
"斯洛文尼亚",
|
||||
"克罗地亚",
|
||||
"罗马尼亚",
|
||||
"保加利亚",
|
||||
"塞尔维亚",
|
||||
"阿尔巴尼亚",
|
||||
"黑山",
|
||||
"马耳他",
|
||||
"塞浦路斯",
|
||||
"北马其顿",
|
||||
"梵蒂冈",
|
||||
"圣马力诺",
|
||||
"安道尔",
|
||||
"波黑",
|
||||
"欧洲联盟"
|
||||
]
|
||||
check_staus = ["已过期的","已结束"]
|
||||
|
||||
# 5. 初始化不符合品牌的数据列表
|
||||
faild_data = []
|
||||
# 初始化查询失败的品牌的数据列表
|
||||
query_faild_data = []
|
||||
|
||||
|
||||
# Simple 策略下最多翻 3 页(0,30,60),直到命中 554 行判断
|
||||
max_pages = 3 if strategy == "Simple" else 1
|
||||
page = 0
|
||||
matched = False
|
||||
|
||||
while page < max_pages and not matched:
|
||||
try:
|
||||
as_structure_dict = {
|
||||
"_id": create_code_generator(),
|
||||
"boolean": "AND",
|
||||
"bricks": [
|
||||
{
|
||||
"_id": create_code_generator(),
|
||||
"key": "brandName",
|
||||
"value": brand,
|
||||
"strategy": strategy
|
||||
}
|
||||
]
|
||||
}
|
||||
# 构造 asStructure 内部 JSON 对象
|
||||
as_structure_dict = as_structure_dict
|
||||
# print(as_structure_dict)
|
||||
|
||||
# 将内部对象转为 JSON 字符串(作为 asStructure 字段的值)
|
||||
as_structure_str = json.dumps(as_structure_dict, ensure_ascii=False)
|
||||
data = {
|
||||
"sort": "score desc",
|
||||
"rows": "30",
|
||||
"asStructure": as_structure_str,
|
||||
"fg": "_void_"
|
||||
}
|
||||
|
||||
# 翻页:第二页 start=30,第三页 start=60(仅 Simple 策略)
|
||||
if strategy == "Simple" and page > 0:
|
||||
data["start"] = str(30 * page)
|
||||
|
||||
# print("请求参数", as_structure_dict, "页码:", page)
|
||||
hashsearch = get_guid()
|
||||
enc_text = search(hashsearch, data)
|
||||
print(f"【{hashsearch}】待解密-->", enc_text)
|
||||
dec_text = decrypt_via_service(hashsearch, enc_text)
|
||||
# print(type(dec_text))
|
||||
dec_text = str(dec_text)
|
||||
# print(f"解密之后的结果-->", dec_text)
|
||||
data = json.loads(dec_text)
|
||||
except Exception as e:
|
||||
# 仅第一页请求失败时记录为查询失败品牌
|
||||
if page == 0:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
safe_brand = brand.encode('gbk', errors='replace').decode('gbk')
|
||||
print(f"品牌:{safe_brand},处理失败:{e}")
|
||||
# query_faild_data.append({"brand": brand, "time": datetime.datetime.now().strftime("%y-%m-%d %H:%M:%S")})
|
||||
query_faild_data.append(brand)
|
||||
break
|
||||
|
||||
# print(data)
|
||||
|
||||
if data.get("response", {}).get("numFound", 0) > 0:
|
||||
if strategy == "Simple":
|
||||
new_brand_name = clean_text(brand)
|
||||
_brand = new_brand_name.lower()
|
||||
else:
|
||||
_brand = brand
|
||||
|
||||
docs = data["response"]["docs"]
|
||||
for d in docs:
|
||||
office = d.get("office")
|
||||
status = d.get("status")
|
||||
registrationDate = d.get("registrationDate")
|
||||
status_name = status_info.get(status, status)
|
||||
brand_name = d.get("brandName")
|
||||
if isinstance(brand_name, list):
|
||||
brand_name = "|".join(brand_name)
|
||||
|
||||
if strategy == "Simple":
|
||||
new_brand_name = clean_text(brand_name)
|
||||
new_brand_name = new_brand_name.lower()
|
||||
if _brand != new_brand_name:
|
||||
continue
|
||||
|
||||
check_office = d.get("designation")
|
||||
|
||||
check_office.append(office)
|
||||
for i in set(check_office):
|
||||
office_name = country_info.get(i, i)
|
||||
# print(office_name,office_name in check_value and status_name not in check_staus)
|
||||
if office_name in check_value and status_name not in check_staus:
|
||||
# 命中 554 行判断,记录并结束当前品牌后续翻页
|
||||
# data_rows = [row for row in data_rows if row.get("品牌") != brand]
|
||||
# print(office_name, office in ["EM","QZ"] and i not in ["EM","QZ"])
|
||||
if office in ["EM","QZ"] and i not in ["EM","QZ"]:
|
||||
continue
|
||||
|
||||
if strategy == "Simple":
|
||||
faild_data.append({"brand": brand, "country": office_name, "status": status_name}) # "reason":"|".join(d.get("brandName","")
|
||||
matched = True
|
||||
else:
|
||||
# print("添加",brand,office_name)
|
||||
faild_data.append({"brand": brand, "country": office_name, "status": status_name})
|
||||
# break
|
||||
if matched:
|
||||
break
|
||||
|
||||
# 如果 Simple 策略下本页未命中,则翻下一页;其他策略不翻页
|
||||
if not matched:
|
||||
page += 1
|
||||
|
||||
|
||||
return faild_data,query_faild_data
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
file_path = r"D:\\私单交付\\maixiang_AI\\测试图片数据\\品牌采集测试\\brand_task_73\\源文件\\brand_task_75\\源文件\\3.10.xlsx"
|
||||
res = single_file_handle(file_path,"结果.xlsx")
|
||||
print(res)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
"""
|
||||
通过 pywebview 加载本地 HTML,执行 JS 实现解密与 GUID 生成。
|
||||
"""
|
||||
import json
|
||||
import threading
|
||||
import traceback
|
||||
|
||||
import webview
|
||||
|
||||
# 内嵌 HTML:加载 CryptoJS 并定义 decryptWithHashSearches、guid
|
||||
_DECRYPT_HTML = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
function decryptWithHashSearches(ciphertext, hashSearches) {
|
||||
try {
|
||||
var baseKey = "8?)i_~Nk6qv0IX;2";
|
||||
var keyStr = baseKey + (hashSearches || "");
|
||||
var key = CryptoJS.enc.Utf8.parse(keyStr);
|
||||
|
||||
var decrypted = CryptoJS.AES.decrypt(ciphertext, key, {
|
||||
mode: CryptoJS.mode.ECB
|
||||
});
|
||||
|
||||
return decrypted.toString(CryptoJS.enc.Utf8);
|
||||
} catch (error) {
|
||||
console.error('解密失败:', error.message);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function guid() {
|
||||
function _p8(s) {
|
||||
var p = (Math.random().toString(16) + "000000000").substr(2, 8);
|
||||
return s ? "-" + p.substr(0, 4) + "-" + p.substr(4, 4) : p;
|
||||
}
|
||||
return _p8() + _p8(true) + _p8(true) + _p8();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
_window = None
|
||||
_window_ready = threading.Event()
|
||||
_init_lock = threading.Lock()
|
||||
|
||||
|
||||
def _ensure_window():
|
||||
"""首次调用时在后台线程启动 pywebview 并等待页面加载完成。"""
|
||||
global _window
|
||||
with _init_lock:
|
||||
if _window is not None:
|
||||
return
|
||||
_window = webview.create_window(
|
||||
"",
|
||||
html=_DECRYPT_HTML,
|
||||
width=1,
|
||||
height=1,
|
||||
hidden=True,
|
||||
)
|
||||
|
||||
def run():
|
||||
webview.start(debug=False)
|
||||
|
||||
t = threading.Thread(target=run, daemon=True)
|
||||
t.start()
|
||||
_window.events.loaded.wait()
|
||||
_window_ready.set()
|
||||
_window_ready.wait()
|
||||
|
||||
|
||||
def decrypt_via_service(hash_searches, ciphertext):
|
||||
"""
|
||||
通过 pywebview 执行 JS decryptWithHashSearches 进行 AES-ECB 解密。
|
||||
|
||||
:param hash_searches: 对应 JS 的 hashSearches(密钥后缀,可为空串)
|
||||
:param ciphertext: Base64 密文
|
||||
:return: 解密后的 UTF-8 字符串,失败返回空串
|
||||
"""
|
||||
_ensure_window()
|
||||
# 将参数安全注入 JS(避免注入与引号问题)
|
||||
ciphertext_js = json.dumps(ciphertext)
|
||||
hash_searches_js = json.dumps(hash_searches or "")
|
||||
js = f"(function(){{ return decryptWithHashSearches({ciphertext_js}, {hash_searches_js}); }})();"
|
||||
try:
|
||||
result = _window.evaluate_js(js)
|
||||
return result if result is not None else ""
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"解密失败: {e}") from e
|
||||
|
||||
|
||||
def get_guid():
|
||||
"""通过 pywebview 执行 JS guid() 生成 UUID 格式字符串。"""
|
||||
_ensure_window()
|
||||
try:
|
||||
result = _window.evaluate_js("(function(){ return guid(); })();")
|
||||
if result is None:
|
||||
raise RuntimeError("guid() 返回为空")
|
||||
return result
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"获取 GUID 失败: {e}") from e
|
||||
|
||||
|
||||
# 使用示例
|
||||
if __name__ == "__main__":
|
||||
# 参数含义:hash_searches 对应原 key,ciphertext 对应原 data
|
||||
hash_searches = "a6efb809-b714-7efd-4b64-8c650b9030f0"
|
||||
ciphertext = "SEsfpOGa8B+sWk0ncknTNh/HNHbGiEVi/RNKxBvyHmAE5VjHonPi202c6VicC/GKfA8mLsIC5mGEpSaH2DdCaEJKeOTWD9SBHHbgtyS1O60VqjgAaptYe9LivvWKc/BU8sZOqhxPMzGUHDcKUts7d0p+hCc80XCXyM2ZT80smM1twndcDfpGkLDk2kJbzl2bGzIc60sl9MzKWfZA2sE0ztFjQ9wD2uhn5LrwoN8NnpiPLNbviMMGtHh4N6Dc0xtPzkzgfkiuxBfWnN1SeM9XVgujHvAGea/dqUWIJqLo26fZIOEFJ0MYL4c8CGLYIeP/70cT2IqJdf+IPBWsCiP29zSyAiQd3yLNvd8+xBLky7lR4ng3MZizn/vhW/5BSg1FtVglbAmNbKgHOIbtpP197Lv+uahx2IpsSPqpy4j2O2VV25YzBk9JpJ4WGG/SvF3f2ZYKKepEQ+kGmBxAfG/5Z8DTNIwnOpXhyjFpJb+fUdPV6LTCK/yFc8g31bNFAJkLW7y/VjewjFravZ3VfQjNAHCvifnrIxGG22MZ3TLVnlbh6ye6vTnd+v9GzqXu+ISR7vQGL/ZSM/bJIjuVkP2XnNUPf+NFdt75gyDmTylFmmbpg7WHaBjinPcyVjeZ38+quJhT8yEk66BOjBM47mVdfU8JLK3ToghxQ54dKWGUbc9HRzYIAQ0rIBBExcOcPM4J9DpufvajmEygoDaws4CxOQDlFoFLwNBYorJsKzAoDe1Cu8oFtk4x190Vd+leRKq6DQSNJwEmyrVkWyFpiuywSMaixFlSqve0lRs50FclXfV7gkBAAvz/DJp90i9yCJdMaihP5ZCvbhKxFGMowkU+tx5Ptnxd9hq6tUxHmuiwDI1eyY8EyjB+3MbrC3H/GY39Z5Qhkj5tkSBm4oGA/h6YjeunBlfMU/QGP3hyZIYALh/YmBlZxAcglWwcqlISUhcx1L3Dbw6ZQbpAmYHmA421xIlRJkdJuPmGoopcoFEVdIO0Ov3uP2JL5ybvUgCBMnbwcYRK9qRmvn2b8jHKch55YXerHBDCEMHz88rfgNIEL8DIAp+wMuIyJ0VrV0BwAXioLYNvMcggBp06gghq2NykGiXRZ/dz8nffFqPVkDNjzcPSGrml9fpKdO8x/GannMNBsA+oHlO2/d4dyo+Q2dZCarQ5UB/N1p+UsTW90kvITKwCbZfV/YY2NRm7HrXq+wLS1vY4mChY82Ybc3cYQT653Q=="
|
||||
|
||||
try:
|
||||
decrypted_text = decrypt_via_service(hash_searches, ciphertext)
|
||||
print("解密结果:", decrypted_text)
|
||||
except Exception as e:
|
||||
print("错误:", e)
|
||||
|
||||
try:
|
||||
g = get_guid()
|
||||
print("GUID:", g)
|
||||
except Exception as e:
|
||||
print("GUID 错误:", e)
|
||||
@@ -1,97 +0,0 @@
|
||||
base_url = "https://api.coze.cn/v1"
|
||||
coze_token = "sat_12nW40INoJxArrDXbY4lSCoudbqkOYTcphC99BP2efWyzxmsk4q81WDX3ezWgqZ5"
|
||||
workflow_id = "7608812635877900322"
|
||||
STITCH_WORKFLOW_ID = "7608813873483300907"
|
||||
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from queue import Queue
|
||||
load_dotenv()
|
||||
_base_url = os.getenv("base_url","http://159.75.121.33:15124")
|
||||
|
||||
# MySQL 配置
|
||||
mysql_host = os.getenv("mysql_host")
|
||||
mysql_user = os.getenv("mysql_user")
|
||||
mysql_password = os.getenv("mysql_password","WTFrb5y6hNLz6hNy")
|
||||
mysql_database = os.getenv("mysql_database","aiimage")
|
||||
proxy_url = os.getenv("proxy_url")
|
||||
proxy_mode = int(os.getenv("proxy_mode",1))
|
||||
|
||||
client_name=os.getenv("client_name") + ".exe"
|
||||
JAVA_API_BASE = os.getenv("java_api_base", "http://127.0.0.1:18080")
|
||||
|
||||
# 紫鸟浏览器配置
|
||||
from urllib.parse import unquote
|
||||
ZN_COMPANY = os.getenv("zn_company", "")
|
||||
ZN_USERNAME = os.getenv("zn_username", "")
|
||||
ZN_USERNAME = unquote(ZN_USERNAME)
|
||||
ZN_PASSWORD = os.getenv("zn_password", "#20zsg25")
|
||||
|
||||
# 删除品牌API配置
|
||||
DELETE_BRAND_API_BASE = os.getenv("DELETE_BRAND_API_BASE", JAVA_API_BASE)
|
||||
|
||||
|
||||
cache_path = "./user_data"
|
||||
|
||||
region = "cn-hangzhou"
|
||||
endpoint = "oss-cn-hangzhou.aliyuncs.com"
|
||||
bucket = "nanri-ai-images"
|
||||
accessKeyId = "LTAI5tNpyvzMNz9f2dHarsm8"
|
||||
accessKeySecret = "bQSZnFH455i8tzyOgeahJmUzwmhynz"
|
||||
bucket_path = "nanri-image/"
|
||||
|
||||
file_url_pre = f"https://{bucket}.oss-cn-hangzhou.aliyuncs.com/"
|
||||
|
||||
|
||||
|
||||
os.environ['OSS_ACCESS_KEY_ID'] = accessKeyId
|
||||
os.environ['OSS_ACCESS_KEY_SECRET'] = accessKeySecret
|
||||
os.environ['SECRET_KEY'] = "ddffc7c1d02121d9554d7b080b2511b6"
|
||||
|
||||
|
||||
debug = True
|
||||
version = "1.0.13"
|
||||
APP_UPDATE_URL = f"{_base_url}/api/version/latest" # 检测更新接口地址,返回 { "file_url": "...", "version": "x.x.x" }
|
||||
os.environ['APP_VERSION'] = version
|
||||
os.environ['APP_UPDATE_URL'] = APP_UPDATE_URL
|
||||
|
||||
|
||||
# 队列
|
||||
JSON_TASK_QUEUE = Queue()
|
||||
|
||||
#删除品牌,正在执行中的任务
|
||||
runing_task = {
|
||||
|
||||
}
|
||||
# 正在执行中的店铺
|
||||
runing_shop = {}
|
||||
|
||||
#
|
||||
# runing_task = {
|
||||
# "497": {
|
||||
# "status": "running", # running/completed/failed
|
||||
# "start_time": "2026-04-01 10:00:00",
|
||||
# "stop_requested": False,
|
||||
# "current_shop": "魏振峰",
|
||||
# "current_country": "德国",
|
||||
# "current_asin": "B0D451RQRG",
|
||||
# "total_shops": 1,
|
||||
# "processed_shops": 0,
|
||||
# "total_asins": 14,
|
||||
# "processed_asins": 3,
|
||||
# "success_count": 2,
|
||||
# "failed_count": 1
|
||||
# }
|
||||
# }
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
92
app/coze.py
92
app/coze.py
@@ -1,92 +0,0 @@
|
||||
import requests
|
||||
import time
|
||||
|
||||
from config import base_url, coze_token
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def upload_file(file_path,_coze_token =coze_token):
|
||||
url = f"{base_url}/files/upload"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {_coze_token}"
|
||||
}
|
||||
print(file_path)
|
||||
with open(file_path, 'rb') as f:
|
||||
files = {
|
||||
'file': f
|
||||
}
|
||||
# with open(f"{time.time()".replace(".","_")+".png","wb") as file:
|
||||
# file.write(f.read())
|
||||
# allow_redirects=True 模拟 curl 的 --location,默认即为 True
|
||||
response = requests.post(url, headers=headers, files=files, allow_redirects=True)
|
||||
data = response.json()
|
||||
print("上传图片->>",data)
|
||||
return data
|
||||
|
||||
def workflow_run(workflow_id, parameters,_coze_token = coze_token,is_async=True):
|
||||
url = f"{base_url}/workflow/run"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {_coze_token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# 请求体(JSON 格式)
|
||||
payload = {
|
||||
"parameters": parameters,
|
||||
# "parameters": {
|
||||
# "name": "包包",
|
||||
# "ratio": "3:4",
|
||||
# "menu": 3,
|
||||
# "resolution": "2K",
|
||||
# "count": 1,
|
||||
# "desc": "1、一个中国美女手上挎着这个包,2、站在商场内",
|
||||
# "language": "中文",
|
||||
# "style": "极简高级"
|
||||
# },
|
||||
"workflow_id": workflow_id,
|
||||
"is_async" : is_async,
|
||||
}
|
||||
# 发送 POST 请求(使用 json 参数自动序列化并设置 Content-Type)
|
||||
response = requests.post(url, headers=headers, json=payload)
|
||||
data = response.json()
|
||||
|
||||
print("图片生成参数->>",payload)
|
||||
print("图片生成->>",data)
|
||||
return data
|
||||
|
||||
|
||||
def query_result(workflow_id, execute_id,_coze_token=coze_token):
|
||||
url = f"{base_url}/workflows/{workflow_id}/run_histories/{execute_id}"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {_coze_token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
response = requests.get(url, headers=headers)
|
||||
data = response.json()
|
||||
print(f"【{execute_id}】结果查询->>",data)
|
||||
return data
|
||||
|
||||
if __name__ == '__main__':
|
||||
from config import workflow_id
|
||||
|
||||
parameters = {
|
||||
"images": [
|
||||
{
|
||||
"file_id": "7612667079497613350",
|
||||
|
||||
},{
|
||||
"file_id": "7612667042990768174",
|
||||
},{
|
||||
"file_id": "7612667111332577332",
|
||||
}
|
||||
]
|
||||
}
|
||||
# resp = workflow_run("7607680760402100258",parameters,_coze_token="sat_ZVNLR9Om54A3iMlJWAasHF9kbtDZnR3BjwjAxRwe8x7igEW446y5ROyVlW1UlpVX")
|
||||
# print(resp)
|
||||
|
||||
# resp = query_result("7607680760402100258","7612668958797447187",_coze_token="sat_ZVNLR9Om54A3iMlJWAasHF9kbtDZnR3BjwjAxRwe8x7igEW446y5ROyVlW1UlpVX")
|
||||
# print(resp)
|
||||
resp = upload_file("D:\私单交付\maixiang_AI\测试图片数据\IMG_2686.JPG",_coze_token="sat_ZVNLR9Om54A3iMlJWAasHF9kbtDZnR3BjwjAxRwe8x7igEW446y5ROyVlW1UlpVX")
|
||||
print(resp)
|
||||
@@ -1,261 +0,0 @@
|
||||
"""
|
||||
生成图片 API - 供 pywebview 前端调用
|
||||
流程:上传图片 -> workflow_run -> 轮询 query_result -> 解析返回图片 URL
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
import re
|
||||
import traceback
|
||||
|
||||
import requests
|
||||
|
||||
from coze import upload_file, workflow_run, query_result
|
||||
from config import workflow_id
|
||||
from ali_oss import upload_data_urls as oss_upload_data_urls
|
||||
|
||||
|
||||
def _data_url_to_temp_file(data_url: str, allow_video: bool = False) -> str:
|
||||
"""将 base64 data URL 转为临时文件路径"""
|
||||
# data:image/png;base64,xxxx 或 data:video/mp4;base64,xxxx
|
||||
if allow_video:
|
||||
match = re.match(r'data:(?:image|video)/(\w+);base64,(.+)', data_url)
|
||||
else:
|
||||
match = re.match(r'data:image/(\w+);base64,(.+)', data_url)
|
||||
if not match:
|
||||
raise ValueError('无效的 data URL 格式')
|
||||
mime = match.group(1).lower()
|
||||
ext_map = {'png': 'png', 'webp': 'png', 'jpeg': 'jpg', 'jpg': 'jpg', 'mp4': 'mp4', 'webm': 'webm'}
|
||||
ext = ext_map.get(mime, 'jpg')
|
||||
data = base64.b64decode(match.group(2))
|
||||
fd, path = tempfile.mkstemp(suffix=f'.{ext}')
|
||||
try:
|
||||
os.write(fd, data)
|
||||
finally:
|
||||
os.close(fd)
|
||||
return path
|
||||
|
||||
|
||||
def _upload_video(video_data_url: str) -> str:
|
||||
"""上传视频,返回 file_id"""
|
||||
if not video_data_url or not isinstance(video_data_url, str):
|
||||
raise ValueError('无效的视频数据')
|
||||
path = _data_url_to_temp_file(video_data_url, allow_video=True)
|
||||
try:
|
||||
resp = upload_file(path)
|
||||
if resp.get('code') == 0 and resp.get('data', {}).get('id'):
|
||||
return resp['data']['id']
|
||||
raise RuntimeError(f'视频上传失败: {resp.get("msg", resp)}')
|
||||
finally:
|
||||
try:
|
||||
os.unlink(path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _upload_images(image_data_urls: list) -> list:
|
||||
"""上传多张图片,返回 file_id 列表"""
|
||||
file_ids = []
|
||||
temp_paths = []
|
||||
try:
|
||||
for data_url in (image_data_urls or []):
|
||||
if not data_url or not isinstance(data_url, str):
|
||||
continue
|
||||
if data_url.startswith("http"):
|
||||
fd, path = tempfile.mkstemp(suffix=f'.png')
|
||||
data_content = requests.get(data_url).content
|
||||
os.write(fd, data_content)
|
||||
else:
|
||||
path = _data_url_to_temp_file(data_url)
|
||||
temp_paths.append(path)
|
||||
resp = upload_file(path)
|
||||
if resp.get('code') == 0 and resp.get('data', {}).get('id'):
|
||||
file_ids.append(resp['data']['id'])
|
||||
else:
|
||||
raise RuntimeError(f'上传失败: {resp.get("msg", resp)}')
|
||||
return file_ids
|
||||
finally:
|
||||
for p in temp_paths:
|
||||
try:
|
||||
os.unlink(p)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _parse_output(output_str: str) -> list:
|
||||
"""解析 query_result 中的 output,提取图片 URL 列表"""
|
||||
try:
|
||||
outer = json.loads(output_str)
|
||||
inner_str = outer.get('Output', '{}')
|
||||
inner = json.loads(inner_str)
|
||||
data_str = inner.get('data', '[]')
|
||||
data = json.loads(data_str)
|
||||
urls = data.get("images")
|
||||
long_image_url = data.get("merged_image_url")
|
||||
return urls if isinstance(urls, list) else [],long_image_url
|
||||
except Exception:
|
||||
return [],None
|
||||
|
||||
|
||||
def _parse_output_text(output_str: str) -> list:
|
||||
"""解析 query_result 中的 output,提取图片 URL 列表"""
|
||||
try:
|
||||
outer = json.loads(output_str)
|
||||
inner_str = outer.get('Output', '{}')
|
||||
inner = json.loads(inner_str)
|
||||
data_str = inner.get('data', '')
|
||||
if isinstance(data_str,str):
|
||||
data = json.loads(data_str).get("reverse_prompt")
|
||||
return [data]
|
||||
pattern = r'【?图像 \d+】.*?(?=【?图像 \d+】|$)'
|
||||
segments = re.findall(pattern, data_str, re.DOTALL)
|
||||
return segments
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _poll_until_done(wf_id: str, execute_id: str, interval: float =10, timeout: int = 600*2, is_text: bool = False) -> list:
|
||||
"""轮询直到成功或超时,返回图片 URL 列表"""
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
resp = query_result(wf_id, execute_id)
|
||||
if resp.get('code') != 0:
|
||||
raise RuntimeError(f'查询失败: {resp.get("msg", resp)}')
|
||||
items = resp.get('data') or []
|
||||
if not items:
|
||||
time.sleep(interval)
|
||||
continue
|
||||
item = items[0]
|
||||
status = item.get('execute_status', '')
|
||||
if status == 'Success':
|
||||
output = item.get('output', '{}')
|
||||
if is_text:
|
||||
return _parse_output_text(output)
|
||||
return _parse_output(output)
|
||||
if status and status not in ('Running', 'Pending', ''):
|
||||
raise RuntimeError(f'执行失败: {status}')
|
||||
time.sleep(interval)
|
||||
raise RuntimeError('生成超时')
|
||||
|
||||
|
||||
def _res_to_2k(res: str) -> str:
|
||||
"""统一分辨率为 2K/4K"""
|
||||
r = (res or '2k').strip().upper()
|
||||
return '4K' if r == '4K' else '2K'
|
||||
|
||||
|
||||
def generate(params: dict) -> dict:
|
||||
"""
|
||||
生成图片
|
||||
params: {
|
||||
menu: int, # 1=图片反推 2=图片编辑 3=随机海报 4=克隆海报 5=服饰穿搭
|
||||
prompt: str, # 用户自定义指令 (menu=1 必填)
|
||||
ref_images: list, # base64 data URLs - 参考图
|
||||
video: str, # base64 data URL - 视频 (menu=1 可选,仅支持1个)
|
||||
model_images: list, # base64 data URLs - 多模特图 (menu!=1,最多5张)
|
||||
name: str, desc: str, ratio: str, resolution: str, count: int,
|
||||
language: str, style: str, batch_prompt: list, brand_name: str,
|
||||
Ingredients: str, activity: str, mode: str, texts: list,
|
||||
proc_images: list, layout_image: str,
|
||||
}
|
||||
返回: { success: bool, urls: list, prompts: list, error: str }
|
||||
"""
|
||||
original_urls = []
|
||||
api_key = (params.get('api_key') or '').strip()
|
||||
try:
|
||||
menu = int(params.get('menu', 2))
|
||||
ref_data = params.get('ref_images') or []
|
||||
proc_data = params.get('proc_images') or []
|
||||
layout_data = params.get('layout_image')
|
||||
video_data = params.get('video')
|
||||
model_data = params.get('model_images') or []
|
||||
|
||||
# menu 1: 反推词,简化参数
|
||||
if menu == 1:
|
||||
base_params = {'menu': 1, 'prompt': str(params.get('prompt', '')).strip() or ''}
|
||||
|
||||
ref_ids = _upload_images(ref_data) if ref_data else []
|
||||
if ref_ids:
|
||||
base_params['ref_images'] = [{'file_id': fid} for fid in ref_ids]
|
||||
if video_data:
|
||||
try:
|
||||
vid = _upload_video(video_data)
|
||||
base_params['video'] = {'file_id': vid}
|
||||
except Exception as ve:
|
||||
return {'success': False, 'urls': [], 'prompts': [], 'error': f'视频上传失败: {ve}'}
|
||||
base_params = {k: v for k, v in base_params.items() if v is not None and v != ''}
|
||||
base_params["api_key"] = api_key
|
||||
resp = workflow_run(workflow_id, base_params)
|
||||
if resp.get('code') != 0:
|
||||
return {'success': False, 'urls': [], 'prompts': [], 'error': resp.get('msg', str(resp))}
|
||||
execute_id = resp.get('execute_id')
|
||||
if not execute_id:
|
||||
return {'success': False, 'urls': [], 'prompts': [], 'error': '未返回 execute_id'}
|
||||
prompts = _poll_until_done(workflow_id, str(execute_id), is_text=True)
|
||||
# menu 1 返回提示词列表,统一转为字符串
|
||||
# prompts = [str(x) for x in result_list] if result_list else []
|
||||
# prompts = [prompts]
|
||||
return {'success': True, 'urls': [], 'prompts': prompts, 'error': ''}
|
||||
|
||||
# menu != 1: 原有逻辑
|
||||
all_ref = list(ref_data)
|
||||
if layout_data:
|
||||
all_ref = [layout_data] + list(ref_data)
|
||||
all_originals = list(all_ref) + list(proc_data) + list(model_data)
|
||||
|
||||
if all_originals:
|
||||
try:
|
||||
original_urls = oss_upload_data_urls(all_originals, prefix="originals")
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
pass
|
||||
|
||||
ref_ids = _upload_images(all_ref) if all_ref else []
|
||||
proc_ids = _upload_images(proc_data) if proc_data else []
|
||||
model_ids = _upload_images(model_data) if model_data else []
|
||||
|
||||
res = _res_to_2k(params.get('resolution', '2K'))
|
||||
|
||||
base_params = {
|
||||
'name': str(params.get('name', '')).strip(),
|
||||
'ratio': str(params.get('ratio', '')).strip(),
|
||||
'menu': menu,
|
||||
'resolution': res,
|
||||
'count': int(params.get('count', 1)),
|
||||
'desc': str(params.get('desc', '')).strip(),
|
||||
'language': str(params.get('language', '中文')).strip() or '中文',
|
||||
'style': str(params.get('style', '')).strip(),
|
||||
'prompt': str(params.get('prompt', '')).strip(),
|
||||
'batch_prompt': params.get('batch_prompt') or [],
|
||||
'brand_name': str(params.get('brand_name', '')).strip(),
|
||||
'Ingredients': str(params.get('Ingredients', '')).strip(),
|
||||
'activity': str(params.get('activity', '')).strip(),
|
||||
'mode': str(params.get('mode', '1')).strip() or '1',
|
||||
'texts': params.get('text') or [],
|
||||
"api_key" : api_key
|
||||
}
|
||||
|
||||
if ref_ids:
|
||||
base_params['ref_images'] = [{'file_id': fid} for fid in ref_ids]
|
||||
if proc_ids:
|
||||
base_params['proc_images'] = [{'file_id': fid} for fid in proc_ids]
|
||||
if model_ids:
|
||||
base_params['model_images'] = [{'file_id': fid} for fid in model_ids]
|
||||
base_params = {k: v for k, v in base_params.items() if v is not None and v != ''}
|
||||
|
||||
resp = workflow_run(workflow_id, base_params)
|
||||
if resp.get('code') != 0:
|
||||
return {'success': False, 'urls': [], 'error': resp.get('msg', str(resp))}
|
||||
execute_id = resp.get('execute_id')
|
||||
if not execute_id:
|
||||
return {'success': False, 'urls': [], 'error': '未返回 execute_id'}
|
||||
|
||||
urls,long_image_url = _poll_until_done(workflow_id, str(execute_id))
|
||||
return {'success': True, 'urls': urls, 'original_urls': original_urls, 'error': '',"long_image_url":long_image_url}
|
||||
except Exception as e:
|
||||
return {'success': False, 'urls': [], 'prompts': [], 'original_urls': [], 'error': str(e),"long_image_url":""}
|
||||
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
"""
|
||||
HTML 模板加密/解密模块
|
||||
使用 AES (Fernet) 对 HTML 资源进行加密存储,运行时解密后渲染
|
||||
"""
|
||||
|
||||
import base64
|
||||
import os
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
|
||||
|
||||
def _get_fernet_key() -> bytes:
|
||||
"""从环境变量 HTML_ENCRYPT_KEY 派生 Fernet 密钥,若未设置则使用默认开发密钥"""
|
||||
raw = os.environ.get(
|
||||
"HTML_ENCRYPT_KEY",
|
||||
"maixiang_html_encrypt_default_key_change_in_production",
|
||||
)
|
||||
# 使用 PBKDF2 派生 32 字节密钥,再转为 Fernet 所需的 base64url
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=b"maixiang_html_salt",
|
||||
iterations=100000,
|
||||
)
|
||||
key_bytes = kdf.derive(raw.encode("utf-8"))
|
||||
return base64.urlsafe_b64encode(key_bytes)
|
||||
|
||||
|
||||
def encrypt(plain_data: bytes) -> bytes:
|
||||
"""加密原始数据,返回密文(bytes)"""
|
||||
key = _get_fernet_key()
|
||||
f = Fernet(key)
|
||||
return f.encrypt(plain_data)
|
||||
|
||||
|
||||
def decrypt(encrypted_data: bytes) -> bytes:
|
||||
"""解密数据,返回明文(bytes)"""
|
||||
key = _get_fernet_key()
|
||||
f = Fernet(key)
|
||||
return f.decrypt(encrypted_data)
|
||||
|
||||
|
||||
def encrypt_file(path: str) -> None:
|
||||
"""就地加密文件:读取 UTF-8 内容,加密后写回同一路径"""
|
||||
with open(path, "rb") as f:
|
||||
plain = f.read()
|
||||
encrypted = encrypt(plain)
|
||||
with open(path, "wb") as f:
|
||||
f.write(encrypted)
|
||||
|
||||
|
||||
def decrypt_file(path: str) -> None:
|
||||
"""就地解密文件:读取密文,解密后以 UTF-8 写回同一路径"""
|
||||
with open(path, "rb") as f:
|
||||
encrypted = f.read()
|
||||
plain = decrypt(encrypted)
|
||||
with open(path, "wb") as f:
|
||||
f.write(plain)
|
||||
|
||||
|
||||
def is_encrypted(data: bytes) -> bool:
|
||||
"""简单启发式:Fernet 密文以 b'gAAAAA' 开头(base64 编码后)"""
|
||||
try:
|
||||
return data[:7] == b"gAAAAAB"
|
||||
except Exception:
|
||||
return False
|
||||
460
app/main.py
460
app/main.py
@@ -1,460 +0,0 @@
|
||||
"""
|
||||
基于 pywebview 实现的跨平台 UI,需先登录方可使用
|
||||
"""
|
||||
from config import cache_path,debug,version,JAVA_API_BASE,JSON_TASK_QUEUE,runing_task,runing_shop
|
||||
import datetime
|
||||
import json
|
||||
import sys
|
||||
import shutil
|
||||
import os
|
||||
from amazon.main import TaskMonitor
|
||||
os.makedirs(cache_path,exist_ok=True)
|
||||
if not debug:
|
||||
today = datetime.datetime.now().strftime("%Y_%m_%d")
|
||||
sys.stdout = open(os.path.join(cache_path,f'{today}.log'), 'a',buffering=1,encoding='utf-8')
|
||||
sys.stderr = sys.stdout
|
||||
|
||||
import webview
|
||||
import threading
|
||||
import time
|
||||
import requests
|
||||
import subprocess
|
||||
import atexit
|
||||
|
||||
from amazon.del_brand import kill_process
|
||||
|
||||
|
||||
|
||||
with open("version.txt","w",encoding="utf-8") as file:
|
||||
file.write(json.dumps({"version":version}))
|
||||
|
||||
print(f"""
|
||||
========================================================
|
||||
版本: {version}
|
||||
========================================================
|
||||
|
||||
""")
|
||||
|
||||
|
||||
# 获取当前脚本所在目录
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
APP_URL = "http://127.0.0.1:5123"
|
||||
PORT = 5123
|
||||
_shutdown_started = False
|
||||
|
||||
|
||||
def cleanup_before_exit():
|
||||
global _shutdown_started
|
||||
if _shutdown_started:
|
||||
return
|
||||
_shutdown_started = True
|
||||
try:
|
||||
kill_process('v6')
|
||||
except Exception as e:
|
||||
print(f"【退出前】关闭紫鸟浏览器进程异常: {str(e)}")
|
||||
|
||||
|
||||
atexit.register(cleanup_before_exit)
|
||||
|
||||
|
||||
def generate_images(params):
|
||||
"""供前端调用的生成图片接口"""
|
||||
from generate_api import generate
|
||||
return generate(params)
|
||||
|
||||
|
||||
class WindowAPI:
|
||||
"""暴露给 JavaScript 的窗口控制 API"""
|
||||
|
||||
def __init__(self, window):
|
||||
self._window = window
|
||||
self._is_maximized = False
|
||||
window.events.maximized += lambda _: setattr(self, '_is_maximized', True)
|
||||
window.events.restored += lambda _: setattr(self, '_is_maximized', False)
|
||||
|
||||
def close(self):
|
||||
"""关闭窗口,异步执行清理逻辑"""
|
||||
def cleanup_and_exit():
|
||||
"""后台清理线程"""
|
||||
try:
|
||||
kill_process('v6')
|
||||
except Exception as e:
|
||||
print(f"【退出前】关闭紫鸟浏览器进程异常: {str(e)}")
|
||||
finally:
|
||||
time.sleep(0.5)
|
||||
os._exit(0)
|
||||
|
||||
# 先销毁窗口,让用户看到立即响应
|
||||
try:
|
||||
self._window.destroy()
|
||||
except:
|
||||
pass
|
||||
|
||||
# 在后台线程执行清理,不阻塞
|
||||
cleanup_thread = threading.Thread(target=cleanup_and_exit, daemon=True)
|
||||
cleanup_thread.start()
|
||||
|
||||
def minimize(self):
|
||||
self._window.minimize()
|
||||
|
||||
def maximize(self):
|
||||
self._window.maximize()
|
||||
|
||||
def toggle_maximize(self):
|
||||
if self._is_maximized:
|
||||
self._window.restore()
|
||||
else:
|
||||
self._window.maximize()
|
||||
|
||||
def save_image(self, url_or_data, filename='image.png'):
|
||||
"""弹窗选择保存位置并下载图片。url_or_data 可为 http(s) 链接或 data: base64"""
|
||||
import base64
|
||||
import re
|
||||
result = self._window.create_file_dialog(
|
||||
webview.SAVE_DIALOG,
|
||||
save_filename=filename,
|
||||
file_types=('图片文件 (*.png;*.jpg;*.jpeg)', '所有文件 (*.*)')
|
||||
)
|
||||
if not result:
|
||||
return {'success': False, 'error': '用户取消'}
|
||||
path = result[0] if isinstance(result, (list, tuple)) else result
|
||||
try:
|
||||
if url_or_data.startswith('data:'):
|
||||
match = re.match(r'data:image/\w+;base64,(.+)', url_or_data)
|
||||
if not match:
|
||||
print(url_or_data)
|
||||
print('无效的 data URL')
|
||||
return {'success': False, 'error': '无效的 data URL'}
|
||||
data = base64.b64decode(match.group(1))
|
||||
else:
|
||||
import requests
|
||||
resp = requests.get(url_or_data, timeout=30)
|
||||
resp.raise_for_status()
|
||||
data = resp.content
|
||||
with open(path, 'wb') as f:
|
||||
f.write(data)
|
||||
return {'success': True, 'path': path}
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def save_image_to_folder(self, url_or_data, dir_path, filename='image.png'):
|
||||
"""将图片保存到指定文件夹。url_or_data 可为 http(s) 链接或 data: base64"""
|
||||
import base64
|
||||
import re
|
||||
path = os.path.join(dir_path, filename)
|
||||
try:
|
||||
if url_or_data.startswith('data:'):
|
||||
match = re.match(r'data:image/\w+;base64,(.+)', url_or_data)
|
||||
if not match:
|
||||
return {'success': False, 'error': '无效的 data URL'}
|
||||
data = base64.b64decode(match.group(1))
|
||||
else:
|
||||
import requests
|
||||
resp = requests.get(url_or_data, timeout=30)
|
||||
resp.raise_for_status()
|
||||
data = resp.content
|
||||
os.makedirs(dir_path, exist_ok=True)
|
||||
with open(path, 'wb') as f:
|
||||
f.write(data)
|
||||
return {'success': True, 'path': path}
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def select_folder(self):
|
||||
# 此方法会在 JavaScript 中被调用,返回选择的文件夹路径
|
||||
result = webview.windows[0].create_file_dialog(
|
||||
webview.FOLDER_DIALOG, # 指定为文件夹对话框
|
||||
allow_multiple=False, # 是否允许多选
|
||||
directory='' # 初始目录
|
||||
)
|
||||
# create_file_dialog 返回的是列表(多选时)或 None
|
||||
return result[0] if result else ''
|
||||
|
||||
def select_brand_xlsx_files(self):
|
||||
"""品牌爬虫:选择 .xlsx 文件,允许多选,返回路径列表"""
|
||||
result = webview.windows[0].create_file_dialog(
|
||||
webview.OPEN_DIALOG,
|
||||
allow_multiple=True,
|
||||
file_types=('Excel 文件 (*.xlsx)', '所有文件 (*.*)')
|
||||
)
|
||||
print("选择的excel 文件",result)
|
||||
if not result:
|
||||
return []
|
||||
return result if isinstance(result, list) or isinstance(result, tuple) else [result]
|
||||
|
||||
def select_brand_folder(self):
|
||||
"""品牌爬虫:选择文件夹,返回文件夹路径(前端再请求后端展开该目录下所有 xlsx)"""
|
||||
result = webview.windows[0].create_file_dialog(
|
||||
webview.FOLDER_DIALOG,
|
||||
allow_multiple=False,
|
||||
directory=''
|
||||
)
|
||||
return result[0] if result else ''
|
||||
|
||||
def save_file_from_url(self, url, default_filename='download.zip'):
|
||||
"""弹窗选择保存位置,从 url 下载文件并保存。用于品牌任务结果 zip 等。"""
|
||||
print("调用到save_file_from_url方法")
|
||||
if not url or not url.strip():
|
||||
return {'success': False, 'error': '下载地址为空'}
|
||||
result = self._window.create_file_dialog(
|
||||
webview.SAVE_DIALOG,
|
||||
save_filename=default_filename,
|
||||
file_types=('ZIP 压缩包 (*.zip)', '所有文件 (*.*)')
|
||||
)
|
||||
if not result:
|
||||
return {'success': False, 'error': '用户取消'}
|
||||
path = result[0] if isinstance(result, (list, tuple)) else result
|
||||
try:
|
||||
import requests
|
||||
resp = requests.get(url.strip(), timeout=120, stream=True)
|
||||
resp.raise_for_status()
|
||||
with open(path, 'wb') as f:
|
||||
for chunk in resp.iter_content(chunk_size=65536):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
return {'success': True, 'path': path}
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def save_file_from_url_new(self, url, default_filename='download.bin'):
|
||||
"""弹窗选择保存位置,从 url 下载文件并保存。根据文件后缀动态设置保存类型。"""
|
||||
print("调用到save_file_from_url_new方法")
|
||||
if not url or not url.strip():
|
||||
return {'success': False, 'error': '下载地址为空'}
|
||||
|
||||
filename = str(default_filename or 'download.bin').strip() or 'download.bin'
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
|
||||
# 根据默认文件名后缀动态设置文件类型,避免固定为 zip 造成误导
|
||||
if ext == '.zip':
|
||||
file_types = ('ZIP 压缩包 (*.zip)', '所有文件 (*.*)')
|
||||
elif ext in ('.xlsx', '.xls'):
|
||||
file_types = ('Excel 文件 (*.xlsx;*.xls)', '所有文件 (*.*)')
|
||||
elif ext == '.csv':
|
||||
file_types = ('CSV 文件 (*.csv)', '所有文件 (*.*)')
|
||||
elif ext == '.txt':
|
||||
file_types = ('文本文件 (*.txt)', '所有文件 (*.*)')
|
||||
elif ext == '.json':
|
||||
file_types = ('JSON 文件 (*.json)', '所有文件 (*.*)')
|
||||
else:
|
||||
file_types = ('所有文件 (*.*)',)
|
||||
|
||||
result = self._window.create_file_dialog(
|
||||
webview.SAVE_DIALOG,
|
||||
save_filename=filename,
|
||||
file_types=file_types
|
||||
)
|
||||
if not result:
|
||||
return {'success': False, 'error': '用户取消'}
|
||||
|
||||
path = result[0] if isinstance(result, (list, tuple)) else result
|
||||
try:
|
||||
import requests
|
||||
resp = requests.get(url.strip(), timeout=120, stream=True)
|
||||
resp.raise_for_status()
|
||||
with open(path, 'wb') as f:
|
||||
for chunk in resp.iter_content(chunk_size=65536):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
return {'success': True, 'path': path}
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def save_template_xlsx(self):
|
||||
"""弹窗选择保存位置,将品牌文档格式模板 xlsx 保存到用户选择的位置。"""
|
||||
template_name = '品牌文档格式_模板.xlsx'
|
||||
template_path = os.path.join(BASE_DIR, 'static', template_name)
|
||||
if not os.path.isfile(template_path):
|
||||
return {'success': False, 'error': '模板文件不存在'}
|
||||
result = self._window.create_file_dialog(
|
||||
webview.SAVE_DIALOG,
|
||||
save_filename=template_name,
|
||||
file_types=('Excel 文件 (*.xlsx)', '所有文件 (*.*)')
|
||||
)
|
||||
if not result:
|
||||
return {'success': False, 'error': '用户取消'}
|
||||
path = result[0] if isinstance(result, (list, tuple)) else result
|
||||
try:
|
||||
shutil.copy2(template_path, path)
|
||||
return {'success': True, 'path': path}
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def save_template_zip(self):
|
||||
"""弹窗选择保存位置,将品牌文档格式模板 xlsx 保存到用户选择的位置。"""
|
||||
import shutil
|
||||
template_name = '模板2-以文件夹方式上传.zip'
|
||||
template_path = os.path.join(BASE_DIR, 'static', template_name)
|
||||
if not os.path.isfile(template_path):
|
||||
return {'success': False, 'error': '模板文件不存在'}
|
||||
result = self._window.create_file_dialog(
|
||||
webview.SAVE_DIALOG,
|
||||
save_filename=template_name,
|
||||
file_types=('ZIP 文件 (*.zip)', '所有文件 (*.*)')
|
||||
)
|
||||
if not result:
|
||||
return {'success': False, 'error': '用户取消'}
|
||||
path = result[0] if isinstance(result, (list, tuple)) else result
|
||||
try:
|
||||
shutil.copy2(template_path, path)
|
||||
return {'success': True, 'path': path}
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def upload_file_to_java(self, file_path, relative_path=None):
|
||||
"""按本地路径读取文件并上传到 Java 后端临时目录。"""
|
||||
if not file_path or not str(file_path).strip():
|
||||
return {'success': False, 'error': '文件路径为空'}
|
||||
normalized_path = os.path.abspath(str(file_path).strip())
|
||||
if not os.path.isfile(normalized_path):
|
||||
return {'success': False, 'error': '文件不存在'}
|
||||
try:
|
||||
with open(normalized_path, 'rb') as file_obj:
|
||||
response = requests.post(
|
||||
f"{JAVA_API_BASE}/api/files/upload",
|
||||
files={'file': (os.path.basename(normalized_path), file_obj)},
|
||||
data={'relativePath': str(relative_path).strip()} if relative_path else None,
|
||||
timeout=120,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, dict):
|
||||
return {'success': False, 'error': 'Java 返回格式错误'}
|
||||
return payload
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def open_external_url(self, url):
|
||||
if not url or not str(url).strip():
|
||||
return {'success': False, 'error': '链接为空'}
|
||||
target = str(url).strip()
|
||||
try:
|
||||
if sys.platform.startswith('win'):
|
||||
os.startfile(target)
|
||||
elif sys.platform == 'darwin':
|
||||
subprocess.Popen(['open', target])
|
||||
else:
|
||||
subprocess.Popen(['xdg-open', target])
|
||||
return {'success': True}
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def enqueue_json(self, data):
|
||||
"""保存任务到队列"""
|
||||
try:
|
||||
print("==============================")
|
||||
print(datetime.datetime.now().strftime( "%Y-%m-%d %H:%M:%S"),"调用传入",data)
|
||||
print("==================================")
|
||||
payload = data
|
||||
if isinstance(data, str):
|
||||
payload = json.loads(data)
|
||||
if not isinstance(payload, (dict, list)):
|
||||
return {'success': False, 'error': '仅支持 JSON 对象或数组'}
|
||||
# if payload.get("type") == "delete-brand-run" and payload.get("data").get("taskId") in runing_task:
|
||||
# return {'success': False, 'error': '已存在正在执行的删除品牌任务'}
|
||||
# 判断当前店铺是否正在运行中
|
||||
if payload.get("data").get("items"):
|
||||
shop_name = payload["data"]["items"][0]["shopName"]
|
||||
if shop_name in runing_shop:
|
||||
return {'success': False, 'error': '当前店铺正在执行中,请等待店铺完成之后重试'}
|
||||
JSON_TASK_QUEUE.put(payload)
|
||||
return {'success': True, 'queue_size': JSON_TASK_QUEUE.qsize()}
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def get_detail_del(self,task_id):
|
||||
"""获取正在执行的删除品牌任务详情"""
|
||||
task_info = runing_task.get(task_id)
|
||||
if not task_info:
|
||||
return {'success': False, 'error': '任务不存在'}
|
||||
return {'success': True, 'task_info': task_info}
|
||||
|
||||
def stop_task(self,task_id):
|
||||
"""停止正在执行的删除品牌任务"""
|
||||
task_info = runing_task.get(task_id)
|
||||
if not task_info:
|
||||
return {'success': False, 'error': '任务不存在'}
|
||||
# 这里可以设置一个标志位,实际的删除品牌任务需要定期检查这个标志位来决定是否停止
|
||||
task_info['stop_requested'] = True
|
||||
return {'success': True, 'message': '已请求停止任务'}
|
||||
|
||||
|
||||
def start_flask():
|
||||
from app import run_app
|
||||
run_app(host='127.0.0.1', port=PORT)
|
||||
|
||||
|
||||
def start_task_monitor():
|
||||
"""启动任务监控器(在独立线程中运行)"""
|
||||
monitor = TaskMonitor()
|
||||
try:
|
||||
print(f"[{datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] 任务监控线程已启动")
|
||||
monitor.start()
|
||||
except Exception:
|
||||
import traceback
|
||||
print(f"[{datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] 任务监控线程异常退出: {traceback.format_exc()}")
|
||||
|
||||
|
||||
def on_window_closing():
|
||||
"""窗口关闭事件处理器,异步执行清理逻辑,避免阻塞UI"""
|
||||
def cleanup_and_exit():
|
||||
"""后台清理线程"""
|
||||
try:
|
||||
kill_process('v6')
|
||||
except Exception as e:
|
||||
print(f"【退出前】关闭紫鸟浏览器进程异常: {str(e)}")
|
||||
finally:
|
||||
# 给一点时间让清理完成,然后强制退出
|
||||
time.sleep(0.5)
|
||||
os._exit(0)
|
||||
|
||||
# 立即在后台线程执行清理,不阻塞窗口关闭
|
||||
cleanup_thread = threading.Thread(target=cleanup_and_exit, daemon=True)
|
||||
cleanup_thread.start()
|
||||
|
||||
# 立即返回,让窗口快速关闭,用户不会感觉卡顿
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.exists(cache_path):
|
||||
os.makedirs(cache_path,exist_ok=True)
|
||||
|
||||
# 启动 Flask 服务
|
||||
t = threading.Thread(target=start_flask, daemon=True)
|
||||
t.start()
|
||||
|
||||
# 启动任务监控线程
|
||||
monitor_thread = threading.Thread(target=start_task_monitor, daemon=True)
|
||||
monitor_thread.start()
|
||||
|
||||
# 等待 Flask 启动
|
||||
time.sleep(2)
|
||||
|
||||
window = webview.create_window(
|
||||
title="数富AI",
|
||||
url=APP_URL,
|
||||
width=1400,
|
||||
height=900,
|
||||
resizable=True,
|
||||
min_size=(1200, 700),
|
||||
background_color="#1a1a1a",
|
||||
frameless=False,
|
||||
easy_drag=True
|
||||
)
|
||||
|
||||
# 为窗口关闭事件添加处理器
|
||||
window.events.closing += on_window_closing
|
||||
|
||||
api = WindowAPI(window)
|
||||
window.expose(api.close, api.minimize, api.maximize, api.toggle_maximize, generate_images, api.save_image, api.save_image_to_folder, api.select_folder, api.select_brand_xlsx_files, api.select_brand_folder,
|
||||
api.save_file_from_url, api.save_template_xlsx,api.save_template_zip,api.upload_file_to_java,api.open_external_url,api.enqueue_json,api.get_detail_del,api.save_file_from_url_new)
|
||||
webview.start(
|
||||
debug=True,
|
||||
storage_path=cache_path,
|
||||
private_mode=False
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,16 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>格式转换 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/convert.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-D4gpiFjY.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-CXHemZwJ.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/convert-7wWJ02Tw.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,16 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>数据去重 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/dedupe.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-D4gpiFjY.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-CXHemZwJ.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/dedupe-BpNHwt51.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,16 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>删除品牌 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/delete-brand.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-D4gpiFjY.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-CXHemZwJ.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/delete-brand-CdxGWtvK.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,16 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>数据拆分 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/split.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-D4gpiFjY.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-CXHemZwJ.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/split-CRUIYKS6.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.9 MiB |
Binary file not shown.
Binary file not shown.
@@ -1 +0,0 @@
|
||||
8cecee3bc02a178bf372ca1c3d02fc5cae3c0a51c8346f6e6a710988599c08be
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,249 +0,0 @@
|
||||
import hashlib
|
||||
import platform
|
||||
import subprocess
|
||||
import uuid
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class DeviceIDGenerator:
|
||||
"""
|
||||
Windows设备唯一ID生成器
|
||||
通过收集多个硬件特征来生成稳定的设备唯一标识符
|
||||
"""
|
||||
|
||||
def __init__(self, use_cache: bool = False, cache_file: str = ".device_id"):
|
||||
"""
|
||||
初始化设备ID生成器
|
||||
|
||||
Args:
|
||||
use_cache: 是否使用本地缓存
|
||||
cache_file: 缓存文件名
|
||||
"""
|
||||
self.use_cache = use_cache
|
||||
self.cache_file = cache_file
|
||||
|
||||
def _run_wmic_command(self, command: str) -> Optional[str]:
|
||||
"""
|
||||
执行WMIC命令并返回结果
|
||||
|
||||
Args:
|
||||
command: WMIC命令
|
||||
|
||||
Returns:
|
||||
命令执行结果,失败则返回None
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip()
|
||||
except (subprocess.TimeoutExpired, Exception):
|
||||
pass
|
||||
return None
|
||||
|
||||
def _get_motherboard_serial(self) -> Optional[str]:
|
||||
"""获取主板序列号"""
|
||||
return self._run_wmic_command("wmic baseboard get serialnumber /value")
|
||||
|
||||
def _get_cpu_id(self) -> Optional[str]:
|
||||
"""获取CPU ID"""
|
||||
return self._run_wmic_command("wmic cpu get processorid /value")
|
||||
|
||||
def _get_bios_serial(self) -> Optional[str]:
|
||||
"""获取BIOS序列号"""
|
||||
return self._run_wmic_command("wmic bios get serialnumber /value")
|
||||
|
||||
def _get_disk_serial(self) -> Optional[str]:
|
||||
"""获取系统盘序列号"""
|
||||
return self._run_wmic_command("wmic diskdrive get serialnumber /value")
|
||||
|
||||
def _get_machine_guid(self) -> Optional[str]:
|
||||
"""获取Windows机器GUID"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
'reg query "HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography" /v MachineGuid',
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
if result.returncode == 0:
|
||||
for line in result.stdout.split('\n'):
|
||||
if 'MachineGuid' in line:
|
||||
return line.split()[-1]
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _extract_value(self, wmic_output: str) -> str:
|
||||
"""从WMIC输出中提取实际值"""
|
||||
if not wmic_output:
|
||||
return ""
|
||||
|
||||
lines = wmic_output.split('\n')
|
||||
for line in lines:
|
||||
if '=' in line and not line.strip().endswith('='):
|
||||
return line.split('=', 1)[1].strip()
|
||||
return ""
|
||||
|
||||
def _collect_hardware_info(self) -> dict:
|
||||
"""
|
||||
收集硬件信息
|
||||
|
||||
Returns:
|
||||
包含各种硬件信息的字典
|
||||
"""
|
||||
hardware_info = {}
|
||||
|
||||
# 主板序列号
|
||||
motherboard = self._get_motherboard_serial()
|
||||
hardware_info['motherboard'] = self._extract_value(motherboard) if motherboard else ""
|
||||
|
||||
# CPU ID
|
||||
cpu_id = self._get_cpu_id()
|
||||
hardware_info['cpu'] = self._extract_value(cpu_id) if cpu_id else ""
|
||||
|
||||
# BIOS序列号
|
||||
bios = self._get_bios_serial()
|
||||
hardware_info['bios'] = self._extract_value(bios) if bios else ""
|
||||
|
||||
# 硬盘序列号
|
||||
disk = self._get_disk_serial()
|
||||
hardware_info['disk'] = self._extract_value(disk) if disk else ""
|
||||
|
||||
# Windows机器GUID
|
||||
machine_guid = self._get_machine_guid()
|
||||
hardware_info['machine_guid'] = machine_guid if machine_guid else ""
|
||||
|
||||
# 计算机名称
|
||||
hardware_info['computer_name'] = platform.node()
|
||||
|
||||
# MAC地址(作为备用)
|
||||
hardware_info['mac_address'] = ':'.join(['{:02x}'.format((uuid.getnode() >> elements) & 0xff)
|
||||
for elements in range(0, 2*6, 2)][::-1])
|
||||
|
||||
return hardware_info
|
||||
|
||||
def _generate_device_id(self, hardware_info: dict) -> str:
|
||||
"""
|
||||
基于硬件信息生成设备ID
|
||||
|
||||
Args:
|
||||
hardware_info: 硬件信息字典
|
||||
|
||||
Returns:
|
||||
32位十六进制设备ID
|
||||
"""
|
||||
# 过滤掉空值,并按键排序确保一致性
|
||||
filtered_info = {k: v for k, v in hardware_info.items() if v and v.strip()}
|
||||
|
||||
# 如果没有任何硬件信息,使用MAC地址作为后备方案
|
||||
if not filtered_info:
|
||||
filtered_info = {'mac_address': hardware_info.get('mac_address', str(uuid.getnode()))}
|
||||
|
||||
# 将所有信息连接成字符串
|
||||
info_string = '|'.join(f"{k}:{v}" for k, v in sorted(filtered_info.items()))
|
||||
|
||||
# 使用SHA256生成哈希值
|
||||
hash_object = hashlib.sha256(info_string.encode('utf-8'))
|
||||
device_id = hash_object.hexdigest()
|
||||
|
||||
return device_id
|
||||
|
||||
def _load_cached_device_id(self) -> Optional[str]:
|
||||
"""从缓存文件加载设备ID"""
|
||||
try:
|
||||
if os.path.exists(self.cache_file):
|
||||
with open(self.cache_file, 'r', encoding='utf-8') as f:
|
||||
cached_id = f.read().strip()
|
||||
if len(cached_id) == 64: # SHA256哈希长度
|
||||
return cached_id
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _save_device_id_to_cache(self, device_id: str) -> None:
|
||||
"""将设备ID保存到缓存文件"""
|
||||
try:
|
||||
with open(self.cache_file, 'w', encoding='utf-8') as f:
|
||||
f.write(device_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_device_id(self) -> str:
|
||||
"""
|
||||
获取设备唯一ID
|
||||
|
||||
Returns:
|
||||
64字符的十六进制设备ID
|
||||
"""
|
||||
# 如果启用缓存,先尝试从缓存加载
|
||||
if self.use_cache:
|
||||
cached_id = self._load_cached_device_id()
|
||||
if cached_id:
|
||||
return cached_id
|
||||
|
||||
# 收集硬件信息
|
||||
hardware_info = self._collect_hardware_info()
|
||||
|
||||
# 生成设备ID
|
||||
device_id = self._generate_device_id(hardware_info)
|
||||
|
||||
# 保存到缓存
|
||||
if self.use_cache:
|
||||
self._save_device_id_to_cache(device_id)
|
||||
|
||||
return device_id
|
||||
|
||||
def get_device_id_short(self, length: int = 16) -> str:
|
||||
"""
|
||||
获取短版本的设备ID
|
||||
|
||||
Args:
|
||||
length: 返回ID的长度
|
||||
|
||||
Returns:
|
||||
指定长度的设备ID
|
||||
"""
|
||||
full_id = self.get_device_id()
|
||||
return full_id[:length]
|
||||
|
||||
def get_hardware_info(self) -> dict:
|
||||
"""
|
||||
获取硬件信息(用于调试)
|
||||
|
||||
Returns:
|
||||
硬件信息字典
|
||||
"""
|
||||
return self._collect_hardware_info()
|
||||
|
||||
|
||||
# 使用示例
|
||||
def main():
|
||||
"""使用示例"""
|
||||
# 创建设备ID生成器实例
|
||||
device_generator = DeviceIDGenerator()
|
||||
|
||||
# 获取完整设备ID(64字符)
|
||||
device_id = device_generator.get_device_id()
|
||||
print(f"完整设备ID: {device_id}")
|
||||
|
||||
# 获取短版本设备ID(16字符)
|
||||
short_id = device_generator.get_device_id_short(16)
|
||||
print(f"短设备ID: {short_id}")
|
||||
|
||||
# 查看硬件信息(调试用)
|
||||
hardware_info = device_generator.get_hardware_info()
|
||||
print("\n硬件信息:")
|
||||
for key, value in hardware_info.items():
|
||||
print(f" {key}: {value}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1 +0,0 @@
|
||||
{"version": "1.0.13"}
|
||||
@@ -1,539 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>管理后台 - 数富AI</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
|
||||
background: #f5f5f5;
|
||||
padding: 24px;
|
||||
}
|
||||
.nav { margin-bottom: 24px; }
|
||||
.nav a { color: #667eea; text-decoration: none; }
|
||||
.nav a:hover { text-decoration: underline; }
|
||||
h1 { font-size: 20px; margin-bottom: 20px; }
|
||||
|
||||
/* Tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
.tab {
|
||||
padding: 12px 24px;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
font-size: 15px;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.tab:hover { color: #667eea; }
|
||||
.tab.active { color: #667eea; font-weight: 600; border-bottom-color: #667eea; }
|
||||
.tab-panel { display: none; }
|
||||
.tab-panel.active { display: block; }
|
||||
|
||||
/* Form & Table */
|
||||
.form-box, .panel-box {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-group label { display: block; font-size: 14px; margin-bottom: 6px; }
|
||||
.form-group input, .form-group select {
|
||||
width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 6px;
|
||||
}
|
||||
.form-group input[type="checkbox"] { width: auto; margin-right: 8px; }
|
||||
.form-row { display: flex; gap: 16px; flex-wrap: wrap; align-items: flex-end; margin-bottom: 16px; }
|
||||
.form-row .form-group { margin-bottom: 0; flex: 1; min-width: 120px; }
|
||||
|
||||
.btn { padding: 10px 20px; background: #667eea; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; }
|
||||
.btn:hover { opacity: 0.9; }
|
||||
.btn-sm { padding: 6px 12px; font-size: 13px; }
|
||||
.btn-danger { background: #e74c3c; }
|
||||
.btn-secondary { background: #95a5a6; }
|
||||
|
||||
.msg { margin-top: 12px; font-size: 14px; }
|
||||
.msg.ok { color: #27ae60; }
|
||||
.msg.err { color: #e74c3c; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #eee; font-size: 14px; }
|
||||
th { background: #f8f9fa; font-weight: 600; }
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.pagination span { font-size: 14px; color: #666; }
|
||||
.pagination button { padding: 6px 12px; border: 1px solid #ddd; background: #fff; cursor: pointer; border-radius: 4px; }
|
||||
.pagination button:hover:not(:disabled) { background: #f0f0f0; }
|
||||
.pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.thumb { width: 60px; height: 60px; object-fit: cover; border-radius: 4px; margin-right: 8px; vertical-align: middle; }
|
||||
.thumb-wrap { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.empty-tip { color: #999; font-size: 14px; padding: 24px; text-align: center; }
|
||||
.modal-mask {
|
||||
display: none;
|
||||
position: fixed; left: 0; top: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.4); z-index: 1000;
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
.modal-mask.show { display: flex; }
|
||||
.modal { background: #fff; border-radius: 8px; padding: 24px; min-width: 320px; max-width: 90%; }
|
||||
.modal h3 { margin-bottom: 16px; font-size: 16px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="nav"><a href="/home">← 返回首页</a></div>
|
||||
<h1>管理后台</h1>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab active" data-tab="users">用户管理</div>
|
||||
<div class="tab" data-tab="history">查看生成记录</div>
|
||||
</div>
|
||||
|
||||
<!-- 用户管理 -->
|
||||
<div id="panel-users" class="tab-panel active">
|
||||
<div class="form-box">
|
||||
<h3 style="margin-bottom:16px;font-size:15px;">创建用户</h3>
|
||||
<p style="margin-bottom:16px;font-size:13px;color:#666;">无注册入口,仅管理员可在此创建用户。层级:超级管理员 → 管理员 → 普通号。</p>
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input type="text" id="username" placeholder="用户名(至少2个字符)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<input type="password" id="password" placeholder="密码(至少6个字符)">
|
||||
</div>
|
||||
<div class="form-group" id="formGroupRole">
|
||||
<label>角色</label>
|
||||
<select id="createRole">
|
||||
<option value="normal">普通号</option>
|
||||
<option value="admin" id="optAdmin">管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="formGroupCreatedBy" style="display:none;">
|
||||
<label>所属管理员</label>
|
||||
<select id="createCreatedBy">
|
||||
<option value="">请选择管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn" id="btnCreate">创建用户</button>
|
||||
<p class="msg" id="msgCreate"></p>
|
||||
</div>
|
||||
|
||||
<div class="panel-box">
|
||||
<h3 style="margin-bottom:16px;font-size:15px;">用户列表</h3>
|
||||
<div class="form-row" style="margin-bottom:16px;">
|
||||
<div class="form-group" style="min-width:180px;">
|
||||
<label>用户名(模糊搜索)</label>
|
||||
<input type="text" id="searchUsername" placeholder="输入用户名关键字">
|
||||
</div>
|
||||
<div class="form-group" id="filterCreatedByGroup" style="min-width:160px;display:none;">
|
||||
<label>所属管理员</label>
|
||||
<select id="filterCreatedBy">
|
||||
<option value="">全部</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn" id="btnSearchUsers">查询</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>用户名</th>
|
||||
<th>角色</th>
|
||||
<th>所属管理员</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="userListBody"></tbody>
|
||||
</table>
|
||||
<div class="pagination" id="userPagination"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 查看生成记录 -->
|
||||
<div id="panel-history" class="tab-panel">
|
||||
<div class="form-box">
|
||||
<h3 style="margin-bottom:16px;font-size:15px;">筛选条件</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="min-width:140px;">
|
||||
<label>指定用户</label>
|
||||
<select id="filterUser">
|
||||
<option value="">全部用户</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="min-width:140px;">
|
||||
<label>开始时间</label>
|
||||
<input type="datetime-local" id="filterTimeStart">
|
||||
</div>
|
||||
<div class="form-group" style="min-width:140px;">
|
||||
<label>结束时间</label>
|
||||
<input type="datetime-local" id="filterTimeEnd">
|
||||
</div>
|
||||
<button class="btn" id="btnFilterHistory">查询</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-box">
|
||||
<h3 style="margin-bottom:16px;font-size:15px;">生成记录</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>用户</th>
|
||||
<th>类型</th>
|
||||
<th>创建时间</th>
|
||||
<th>结果预览</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="historyListBody"></tbody>
|
||||
</table>
|
||||
<div class="pagination" id="historyPagination"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑用户弹窗 -->
|
||||
<div class="modal-mask" id="editUserModal">
|
||||
<div class="modal">
|
||||
<h3>编辑用户</h3>
|
||||
<input type="hidden" id="editUserId">
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input type="text" id="editUsername" readonly style="background:#f5f5f5;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>新密码(不修改留空)</label>
|
||||
<input type="password" id="editPassword" placeholder="留空则不修改密码">
|
||||
</div>
|
||||
<div class="form-group" id="editFormGroupRole" style="display:none;">
|
||||
<label>角色</label>
|
||||
<select id="editRole">
|
||||
<option value="normal">普通号</option>
|
||||
<option value="admin">管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="editFormGroupCreator" style="display:none;">
|
||||
<label>所属管理员</label>
|
||||
<input type="text" id="editCreatorName" readonly style="background:#f5f5f5;">
|
||||
</div>
|
||||
<p class="msg" id="msgEdit"></p>
|
||||
<div style="margin-top:16px;display:flex;gap:8px;">
|
||||
<button class="btn" id="btnSaveUser">保存</button>
|
||||
<button class="btn btn-secondary" id="btnCloseEdit">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
// Tab 切换
|
||||
document.querySelectorAll('.tab').forEach(function(t) {
|
||||
t.onclick = function() {
|
||||
document.querySelectorAll('.tab').forEach(function(x) { x.classList.remove('active'); });
|
||||
document.querySelectorAll('.tab-panel').forEach(function(x) { x.classList.remove('active'); });
|
||||
t.classList.add('active');
|
||||
var id = 'panel-' + t.dataset.tab;
|
||||
document.getElementById(id).classList.add('active');
|
||||
if (t.dataset.tab === 'users') loadUsers(1);
|
||||
else if (t.dataset.tab === 'history') loadHistory(1);
|
||||
};
|
||||
});
|
||||
|
||||
// ========== 用户管理 ==========
|
||||
var userPage = 1, userPageSize = 15;
|
||||
var currentUserRole = 'admin';
|
||||
var adminsList = [];
|
||||
function roleLabel(role) {
|
||||
if (role === 'super_admin') return '超级管理员';
|
||||
if (role === 'admin') return '管理员';
|
||||
return '普通号';
|
||||
}
|
||||
function buildUserListQuery(page) {
|
||||
var q = 'page=' + (page || 1) + '&page_size=' + userPageSize;
|
||||
var kw = (document.getElementById('searchUsername').value || '').trim();
|
||||
if (kw) q += '&username=' + encodeURIComponent(kw);
|
||||
var cby = document.getElementById('filterCreatedBy').value;
|
||||
if (cby) q += '&created_by_id=' + encodeURIComponent(cby);
|
||||
return q;
|
||||
}
|
||||
function loadUsers(page) {
|
||||
userPage = page || 1;
|
||||
fetch('/api/admin/users?' + buildUserListQuery(userPage))
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(res) {
|
||||
var tbody = document.getElementById('userListBody');
|
||||
if (!res.success) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="empty-tip">加载失败: ' + (res.error || '') + '</td></tr>';
|
||||
return;
|
||||
}
|
||||
currentUserRole = res.current_user_role || 'admin';
|
||||
adminsList = res.admins || [];
|
||||
var items = res.items || [];
|
||||
if (items.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="empty-tip">暂无用户</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = items.map(function(u) {
|
||||
return '<tr><td>' + u.id + '</td><td>' + (u.username || '') + '</td><td>' +
|
||||
roleLabel(u.role || 'normal') + '</td><td>' + (u.creator_username || '-') + '</td><td>' + (u.created_at || '') + '</td><td>' +
|
||||
'<button class="btn btn-sm" data-edit="' + u.id + '" data-user="' + (JSON.stringify(u).replace(/"/g, '"')) + '">编辑</button> ' +
|
||||
'<button class="btn btn-sm btn-danger" data-delete="' + u.id + '" data-name="' + (u.username || '').replace(/"/g, '"') + '">删除</button>' +
|
||||
'</td></tr>';
|
||||
}).join('');
|
||||
}
|
||||
renderPagination('userPagination', res.total, res.page, res.page_size, loadUsers);
|
||||
bindUserActions();
|
||||
updateCreateFormByRole();
|
||||
updateUserFilterByRole();
|
||||
})
|
||||
.catch(function() {
|
||||
document.getElementById('userListBody').innerHTML = '<tr><td colspan="6" class="empty-tip">请求失败</td></tr>';
|
||||
});
|
||||
}
|
||||
function updateUserFilterByRole() {
|
||||
var grp = document.getElementById('filterCreatedByGroup');
|
||||
var sel = document.getElementById('filterCreatedBy');
|
||||
if (currentUserRole === 'super_admin') {
|
||||
grp.style.display = 'block';
|
||||
var cur = sel.value;
|
||||
sel.innerHTML = '<option value="">全部</option>';
|
||||
adminsList.forEach(function(a) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = a.id;
|
||||
opt.textContent = a.username;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
sel.value = cur || '';
|
||||
} else {
|
||||
grp.style.display = 'none';
|
||||
}
|
||||
}
|
||||
function updateCreateFormByRole() {
|
||||
var roleSel = document.getElementById('createRole');
|
||||
var optAdmin = document.getElementById('optAdmin');
|
||||
var formCreatedBy = document.getElementById('formGroupCreatedBy');
|
||||
var selCreatedBy = document.getElementById('createCreatedBy');
|
||||
if (currentUserRole === 'super_admin') {
|
||||
if (optAdmin) optAdmin.style.display = '';
|
||||
formCreatedBy.style.display = (roleSel.value === 'normal') ? 'block' : 'none';
|
||||
selCreatedBy.innerHTML = '<option value="">请选择管理员</option>';
|
||||
adminsList.forEach(function(a) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = a.id;
|
||||
opt.textContent = a.username;
|
||||
selCreatedBy.appendChild(opt);
|
||||
});
|
||||
} else {
|
||||
if (optAdmin) optAdmin.style.display = 'none';
|
||||
roleSel.value = 'normal';
|
||||
formCreatedBy.style.display = 'none';
|
||||
}
|
||||
}
|
||||
function bindUserActions() {
|
||||
document.querySelectorAll('[data-edit]').forEach(function(btn) {
|
||||
btn.onclick = function() {
|
||||
var raw = (btn.getAttribute('data-user') || '{}').replace(/"/g, '"');
|
||||
var u;
|
||||
try { u = JSON.parse(raw); } catch (e) { u = {}; }
|
||||
document.getElementById('editUserId').value = u.id || '';
|
||||
document.getElementById('editUsername').value = u.username || '';
|
||||
document.getElementById('editPassword').value = '';
|
||||
var editRole = document.getElementById('editRole');
|
||||
var editFormGroupRole = document.getElementById('editFormGroupRole');
|
||||
var editFormGroupCreator = document.getElementById('editFormGroupCreator');
|
||||
var editCreatorName = document.getElementById('editCreatorName');
|
||||
editFormGroupRole.style.display = (currentUserRole === 'super_admin' && u.role !== 'super_admin') ? 'block' : 'none';
|
||||
editFormGroupCreator.style.display = (u.role === 'normal' && u.creator_username) ? 'block' : 'none';
|
||||
editCreatorName.value = u.creator_username || '';
|
||||
if (u.role !== 'super_admin') { editRole.value = u.role || 'normal'; }
|
||||
document.getElementById('msgEdit').textContent = '';
|
||||
document.getElementById('editUserModal').classList.add('show');
|
||||
};
|
||||
});
|
||||
document.querySelectorAll('[data-delete]').forEach(function(btn) {
|
||||
btn.onclick = function() {
|
||||
if (!confirm('确定删除用户 "' + (btn.dataset.name || '') + '" 吗?')) return;
|
||||
fetch('/api/admin/user/' + btn.dataset.delete, { method: 'DELETE' })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(res) {
|
||||
if (res.success) { loadUsers(userPage); }
|
||||
else { alert(res.error || '删除失败'); }
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('btnSearchUsers').onclick = function() { loadUsers(1); };
|
||||
document.getElementById('createRole').onchange = function() { updateCreateFormByRole(); };
|
||||
document.getElementById('btnCreate').onclick = function() {
|
||||
var username = (document.getElementById('username').value || '').trim();
|
||||
var password = document.getElementById('password').value || '';
|
||||
var role = document.getElementById('createRole').value || 'normal';
|
||||
var createdById = document.getElementById('createCreatedBy').value ? parseInt(document.getElementById('createCreatedBy').value, 10) : null;
|
||||
var msgEl = document.getElementById('msgCreate');
|
||||
msgEl.textContent = '';
|
||||
msgEl.className = 'msg';
|
||||
if (!username || username.length < 2) {
|
||||
msgEl.textContent = '用户名至少2个字符';
|
||||
msgEl.classList.add('err');
|
||||
return;
|
||||
}
|
||||
if (!password || password.length < 6) {
|
||||
msgEl.textContent = '密码至少6个字符';
|
||||
msgEl.classList.add('err');
|
||||
return;
|
||||
}
|
||||
var body = { username: username, password: password, role: role };
|
||||
if (role === 'normal' && currentUserRole === 'super_admin' && createdById) body.created_by_id = createdById;
|
||||
fetch('/api/admin/user', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(res) {
|
||||
if (res.success) {
|
||||
msgEl.textContent = res.msg || '创建成功';
|
||||
msgEl.classList.add('ok');
|
||||
document.getElementById('username').value = '';
|
||||
document.getElementById('password').value = '';
|
||||
loadUsers(1);
|
||||
} else {
|
||||
msgEl.textContent = res.error || '创建失败';
|
||||
msgEl.classList.add('err');
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
msgEl.textContent = '请求失败';
|
||||
msgEl.classList.add('err');
|
||||
});
|
||||
};
|
||||
|
||||
document.getElementById('btnSaveUser').onclick = function() {
|
||||
var uid = document.getElementById('editUserId').value;
|
||||
var password = document.getElementById('editPassword').value;
|
||||
var editRoleEl = document.getElementById('editRole');
|
||||
var msgEl = document.getElementById('msgEdit');
|
||||
msgEl.textContent = '';
|
||||
msgEl.className = 'msg';
|
||||
var body = {};
|
||||
if (password) body.password = password;
|
||||
if (currentUserRole === 'super_admin' && editRoleEl && editRoleEl.offsetParent !== null)
|
||||
body.role = editRoleEl.value || 'normal';
|
||||
fetch('/api/admin/user/' + uid, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(res) {
|
||||
if (res.success) {
|
||||
msgEl.textContent = res.msg || '保存成功';
|
||||
msgEl.classList.add('ok');
|
||||
document.getElementById('editUserModal').classList.remove('show');
|
||||
loadUsers(userPage);
|
||||
} else {
|
||||
msgEl.textContent = res.error || '保存失败';
|
||||
msgEl.classList.add('err');
|
||||
}
|
||||
});
|
||||
};
|
||||
document.getElementById('btnCloseEdit').onclick = function() {
|
||||
document.getElementById('editUserModal').classList.remove('show');
|
||||
};
|
||||
|
||||
// ========== 生成记录 ==========
|
||||
var historyPage = 1, historyPageSize = 15;
|
||||
function toSqlDatetime(val) {
|
||||
if (!val) return '';
|
||||
return val.replace('T', ' ');
|
||||
}
|
||||
function buildHistoryQuery(page) {
|
||||
var q = 'page=' + (page || 1) + '&page_size=' + historyPageSize;
|
||||
var uid = document.getElementById('filterUser').value;
|
||||
var start = toSqlDatetime(document.getElementById('filterTimeStart').value);
|
||||
var end = toSqlDatetime(document.getElementById('filterTimeEnd').value);
|
||||
if (uid) q += '&user_id=' + uid;
|
||||
if (start) q += '&time_start=' + encodeURIComponent(start);
|
||||
if (end) q += '&time_end=' + encodeURIComponent(end);
|
||||
return q;
|
||||
}
|
||||
function loadHistory(page) {
|
||||
historyPage = page || 1;
|
||||
fetch('/api/admin/history?' + buildHistoryQuery(historyPage))
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(res) {
|
||||
var tbody = document.getElementById('historyListBody');
|
||||
if (!res.success) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="empty-tip">加载失败: ' + (res.error || '') + '</td></tr>';
|
||||
return;
|
||||
}
|
||||
var items = res.items || [];
|
||||
if (items.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="empty-tip">暂无记录</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = items.map(function(h) {
|
||||
var urls = (h.result_urls || []);
|
||||
var thumbUrls = (h.long_image_url ? [h.long_image_url] : []).concat(urls);
|
||||
var thumbs = thumbUrls.slice(0, 3).map(function(url) {
|
||||
return '<img src="' + (url || '').replace(/"/g, '"') + '" class="thumb" alt="">';
|
||||
}).join('');
|
||||
return '<tr><td>' + h.id + '</td><td>' + (h.username || '-') + '</td><td>' +
|
||||
(h.panel_type || '-') + '</td><td>' + (h.created_at || '') + '</td><td>' +
|
||||
'<div class="thumb-wrap">' + (thumbs || '-') + '</div></td></tr>';
|
||||
}).join('');
|
||||
}
|
||||
renderPagination('historyPagination', res.total, res.page, res.page_size, loadHistory);
|
||||
})
|
||||
.catch(function() {
|
||||
document.getElementById('historyListBody').innerHTML = '<tr><td colspan="5" class="empty-tip">请求失败</td></tr>';
|
||||
});
|
||||
}
|
||||
function loadUserOptions() {
|
||||
fetch('/api/admin/users?page=1&page_size=999')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(res) {
|
||||
var sel = document.getElementById('filterUser');
|
||||
var cur = sel.value;
|
||||
sel.innerHTML = '<option value="">全部用户</option>';
|
||||
(res.items || []).forEach(function(u) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = u.id;
|
||||
opt.textContent = u.username + ' (' + roleLabel(u.role || 'normal') + ')';
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
sel.value = cur || '';
|
||||
});
|
||||
}
|
||||
document.getElementById('btnFilterHistory').onclick = function() { loadHistory(1); };
|
||||
|
||||
// ========== 分页 ==========
|
||||
function renderPagination(elId, total, page, pageSize, onPage) {
|
||||
var el = document.getElementById(elId);
|
||||
if (!el) return;
|
||||
var totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
el.innerHTML = '<span>共 ' + total + ' 条</span>' +
|
||||
'<button ' + (page <= 1 ? 'disabled' : '') + ' data-p="' + (page - 1) + '">上一页</button>' +
|
||||
'<span>第 ' + page + ' / ' + totalPages + ' 页</span>' +
|
||||
'<button ' + (page >= totalPages ? 'disabled' : '') + ' data-p="' + (page + 1) + '">下一页</button>';
|
||||
el.querySelectorAll('[data-p]').forEach(function(b) {
|
||||
if (!b.disabled) b.onclick = function() { onPage(parseInt(b.dataset.p, 10)); };
|
||||
});
|
||||
}
|
||||
|
||||
// 初始化
|
||||
loadUsers(1);
|
||||
loadUserOptions();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,353 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>首页 - 数富AI</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", sans-serif;
|
||||
background: linear-gradient(rgba(255,255,255,0.5), rgba(255,255,255,0.5)),
|
||||
url("/static/bg.jpg") center/cover no-repeat;;
|
||||
/*background-image: url("/static/bg.jpg");*/
|
||||
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
}
|
||||
.header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
.header-title { font-size: 18px; font-weight: 600; color: #333; }
|
||||
.header-right { display: flex; align-items: center; gap: 16px; position: relative; }
|
||||
.header-right a, .header-right span {
|
||||
font-size: 14px; color: #555; text-decoration: none;
|
||||
}
|
||||
.header-right a:hover { color: #667eea; }
|
||||
.entrances {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
.entrance-btn {
|
||||
width: 160px;
|
||||
height: 100px;
|
||||
background: #c5c1c1;
|
||||
border: 4px solid #b8d4e3;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
}
|
||||
.entrance-btn:hover {
|
||||
background: #f8fbfd;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||||
}
|
||||
.entrance-btn.wb { cursor: pointer; }
|
||||
.entrance-btn.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 40px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0,0,0,0.75);
|
||||
color: #fff;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast.show { opacity: 1; }
|
||||
.admin-link {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
}
|
||||
.update-section {
|
||||
margin-top: 48px;
|
||||
padding: 20px 24px;
|
||||
background: rgba(255,255,255,0.85);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0,0,0,0.06);
|
||||
max-width: 420px;
|
||||
}
|
||||
.update-section-title {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.update-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.update-version-text {
|
||||
font-size: 13px;
|
||||
color: #555;
|
||||
}
|
||||
.btn-check-update {
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
background: #667eea;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-check-update:hover {
|
||||
background: #5a6fd6;
|
||||
}
|
||||
.update-hint {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 8px;
|
||||
min-height: 18px;
|
||||
}
|
||||
.btn-download-update {
|
||||
margin-top: 8px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
background: #28a745;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-download-update:hover {
|
||||
background: #218838;
|
||||
}
|
||||
.header-update-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(102,126,234,0.12);
|
||||
color: #4c5bd4;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 15px;
|
||||
padding: 0;
|
||||
}
|
||||
.header-update-btn:hover {
|
||||
background: rgba(102,126,234,0.2);
|
||||
}
|
||||
.header-update-panel {
|
||||
position: absolute;
|
||||
top: 44px;
|
||||
right: 0;
|
||||
width: 280px;
|
||||
padding: 14px 16px;
|
||||
background: rgba(255,255,255,0.98);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.12);
|
||||
border: 1px solid rgba(0,0,0,0.04);
|
||||
z-index: 10;
|
||||
display: none;
|
||||
}
|
||||
.header-update-title {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body data-user-id="{{ user_id or '' }}">
|
||||
<header class="header">
|
||||
<span class="header-title">数富AI</span>
|
||||
<div class="header-right">
|
||||
{% if is_admin %}
|
||||
<!-- <a href="/admin" class="admin-link">用户管理</a>-->
|
||||
{% endif %}
|
||||
<span>{{ username }}</span>
|
||||
<button type="button" class="header-update-btn" id="homeUpdateToggle" title="检测更新">↻</button>
|
||||
<a href="/logout" onclick="handleLogout()">退出</a>
|
||||
<div class="header-update-panel" id="homeUpdatePanel">
|
||||
<div class="header-update-title"><span>↻</span> 软件更新</div>
|
||||
<div class="update-block">
|
||||
<span class="update-version-text" id="homeVersionText">当前版本: {{ version }}</span>
|
||||
<button type="button" class="btn-check-update" id="homeBtnCheckUpdate"><span>↻</span> 检测</button>
|
||||
</div>
|
||||
<div class="update-hint" id="homeUpdateHint"></div>
|
||||
<div style="margin-top: 4px;">
|
||||
<button type="button" class="btn-download-update" id="homeBtnDownloadUpdate" style="display: none;">立即更新</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="entrances" id="entrances">
|
||||
<a class="entrance-btn" href="/brand" data-column-key="brand">亚马逊</a>
|
||||
<a class="entrance-btn disabled" href="javascript:;" data-msg="暂未开通,敬请期待" data-column-key="wb">wildberries</a>
|
||||
<a class="entrance-btn image" href="/image" data-column-key="image">图片</a>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
// 权限校验:根据 column-permissions 接口按 column_key 显示入口
|
||||
(function() {
|
||||
localStorage.setItem("uid",{{ user_id }})
|
||||
var uid = document.body.getAttribute('data-user-id');
|
||||
if (!uid) return;
|
||||
var baseUrl = window.location.origin;
|
||||
var apiUrl = baseUrl + '/api/admin/user/' + uid + '/column-permissions';
|
||||
fetch(apiUrl, { credentials: 'same-origin' })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(res) {
|
||||
if (!res || !res.success || !Array.isArray(res.items)) return;
|
||||
var allowedKeys = res.items.map(function(item) { return (item.column_key || '').toLowerCase(); });
|
||||
document.querySelectorAll('.entrances .entrance-btn[data-column-key]').forEach(function(btn) {
|
||||
var key = (btn.getAttribute('data-column-key') || '').toLowerCase();
|
||||
btn.style.display = allowedKeys.indexOf(key) >= 0 ? '' : 'none';
|
||||
});
|
||||
})
|
||||
.catch(function() {});
|
||||
})();
|
||||
|
||||
document.querySelectorAll('.entrance-btn.disabled').forEach(function(btn) {
|
||||
btn.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
var msg = btn.getAttribute('data-msg') || '暂未开通,敬请期待';
|
||||
var t = document.getElementById('toast');
|
||||
t.textContent = msg;
|
||||
t.classList.add('show');
|
||||
setTimeout(function() { t.classList.remove('show'); }, 2000);
|
||||
};
|
||||
});
|
||||
|
||||
function handleLogout() {
|
||||
try{
|
||||
localStorage.removeItem('maixiang_api_key');
|
||||
}catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 软件更新(与 index 设置中逻辑一致)
|
||||
(function() {
|
||||
const versionText = document.getElementById('homeVersionText');
|
||||
const updateHint = document.getElementById('homeUpdateHint');
|
||||
const btnDownload = document.getElementById('homeBtnDownloadUpdate');
|
||||
const updatePanel = document.getElementById('homeUpdatePanel');
|
||||
const toggleBtn = document.getElementById('homeUpdateToggle');
|
||||
if (!versionText || !updateHint || !btnDownload || !updatePanel || !toggleBtn) return;
|
||||
|
||||
let currentVersion = '{{version}}';
|
||||
versionText.textContent = '当前版本: v' + currentVersion;
|
||||
|
||||
// 打开/关闭浮层
|
||||
toggleBtn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
const isVisible = updatePanel.style.display === 'block';
|
||||
updatePanel.style.display = isVisible ? 'none' : 'block';
|
||||
});
|
||||
|
||||
// 点击外部关闭浮层
|
||||
document.addEventListener('click', function() {
|
||||
updatePanel.style.display = 'none';
|
||||
});
|
||||
updatePanel.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
document.getElementById('homeBtnCheckUpdate').onclick = async function() {
|
||||
updateHint.textContent = '正在检测更新...';
|
||||
btnDownload.style.display = 'none';
|
||||
try {
|
||||
const resp = await fetch('/api/version', { credentials: 'same-origin' }).catch(function() { return null; });
|
||||
if (resp && resp.ok) {
|
||||
const data = await resp.json();
|
||||
currentVersion = (data.version || currentVersion).replace(/^v/i, '');
|
||||
versionText.textContent = '当前版本: v' + currentVersion;
|
||||
if (data.has_update && data.latest_version) {
|
||||
updateHint.textContent = '发现新版本 v' + data.latest_version + (data.desc ? ':' + data.desc : '');
|
||||
btnDownload.style.display = 'inline-block';
|
||||
btnDownload.textContent = '立即更新';
|
||||
btnDownload.onclick = async function() {
|
||||
if (!confirm('有更新,是否现在更新?\n更新将下载安装包并重启程序。')) return;
|
||||
if (!data.file_url) {
|
||||
updateHint.textContent = '暂无下载地址,请关注官方渠道。';
|
||||
return;
|
||||
}
|
||||
updateHint.textContent = '正在下载并准备更新,程序将自动退出...';
|
||||
btnDownload.disabled = true;
|
||||
try {
|
||||
const updateResp = await fetch('/api/update/do', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ file_url: data.file_url })
|
||||
});
|
||||
const result = await updateResp.json().catch(function() { return {}; });
|
||||
if (result.success) {
|
||||
updateHint.textContent = '更新已启动,程序即将退出...';
|
||||
} else {
|
||||
updateHint.textContent = result.error || '更新启动失败';
|
||||
btnDownload.disabled = false;
|
||||
}
|
||||
} catch (e) {
|
||||
updateHint.textContent = '请求更新失败,请重试';
|
||||
btnDownload.disabled = false;
|
||||
}
|
||||
};
|
||||
} else {
|
||||
updateHint.textContent = '已是最新版本';
|
||||
}
|
||||
} else {
|
||||
updateHint.textContent = '无法连接更新服务,请稍后重试。';
|
||||
}
|
||||
} catch (e) {
|
||||
updateHint.textContent = '检测更新失败';
|
||||
}
|
||||
};
|
||||
btnDownload.style.display = 'none';
|
||||
})();
|
||||
// (function() {
|
||||
// fetch('/api/auth/check', { credentials: 'same-origin' })
|
||||
// .then(function(r) { return r.json(); })
|
||||
// .then(function(res) {
|
||||
// if (res.logged_in && res.redirect) {
|
||||
// window.location.href = res.redirect;
|
||||
// }else{
|
||||
// window.location.href = "/login"
|
||||
// // handleLogout()
|
||||
// }
|
||||
// })
|
||||
// .catch(function() {});
|
||||
// })();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user