Optimize cloud drive upload flow
This commit is contained in:
@@ -2,19 +2,30 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from typing import Optional, Callable
|
||||
from typing import Callable, Optional
|
||||
|
||||
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
|
||||
class QuarkUploader:
|
||||
"""
|
||||
夸克网盘上传工具 - 终极稳健版 (双重状态锁)
|
||||
夸克网盘上传工具。
|
||||
|
||||
通过真实 Chrome + 持久用户目录上传,避免依赖非公开接口。
|
||||
"""
|
||||
def __init__(self, chrome_path: str, cookies_dir: str, log_callback: Optional[Callable[[str], None]] = None):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
chrome_path: str,
|
||||
cookies_dir: str,
|
||||
log_callback: Optional[Callable[[str], None]] = None,
|
||||
):
|
||||
self.chrome_path = chrome_path
|
||||
self.cookies_dir = cookies_dir
|
||||
self.log_callback = log_callback
|
||||
self.url = "https://pan.quark.cn/"
|
||||
|
||||
|
||||
if not os.path.exists(self.cookies_dir):
|
||||
os.makedirs(self.cookies_dir, exist_ok=True)
|
||||
|
||||
@@ -23,144 +34,403 @@ class QuarkUploader:
|
||||
if self.log_callback:
|
||||
self.log_callback(message)
|
||||
|
||||
def upload_file(self, file_path: str, target_folder_name: str, root_path: str = "精品项目整理") -> bool:
|
||||
def upload_file(
|
||||
self,
|
||||
file_path: str,
|
||||
target_folder_name: str,
|
||||
root_path: str = "精品项目整理",
|
||||
) -> bool:
|
||||
"""
|
||||
上传文件到夸克网盘
|
||||
上传文件到夸克网盘。
|
||||
"""
|
||||
if not os.path.exists(file_path):
|
||||
self._log(f"错误: 本地文件不存在 {file_path}")
|
||||
return False
|
||||
|
||||
filename = os.path.basename(file_path)
|
||||
file_size_mb = os.path.getsize(file_path) / 1024 / 1024
|
||||
context = None
|
||||
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
self._log(f"正在启动浏览器...")
|
||||
self._log("正在启动浏览器...")
|
||||
launch_args = {
|
||||
"user_data_dir": self.cookies_dir,
|
||||
"headless": False,
|
||||
"viewport": {"width": 1280, "height": 800}
|
||||
"viewport": {"width": 1280, "height": 800},
|
||||
}
|
||||
if self.chrome_path and os.path.exists(self.chrome_path):
|
||||
launch_args["executable_path"] = self.chrome_path
|
||||
|
||||
context = p.chromium.launch_persistent_context(**launch_args)
|
||||
page = context.new_page()
|
||||
|
||||
self._log("正在打开夸克网盘...")
|
||||
page.goto(self.url, wait_until="networkidle")
|
||||
|
||||
# 1. 登录检测
|
||||
try:
|
||||
page.wait_for_selector("text=全部文件", timeout=10000)
|
||||
except:
|
||||
self._log("请在浏览器中完成登录...")
|
||||
page.wait_for_selector("text=全部文件", timeout=180000)
|
||||
|
||||
# 2. 导航到目录
|
||||
self._log(f"定位目录: {root_path} > {target_folder_name}")
|
||||
page.get_by_text("全部文件").first.click()
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
# 进入根目录
|
||||
if not page.get_by_text(root_path, exact=True).first.is_visible():
|
||||
self._create_folder(page, root_path)
|
||||
page.get_by_text(root_path, exact=True).first.dblclick()
|
||||
page.wait_for_timeout(1000)
|
||||
|
||||
# 进入项目目录
|
||||
if not page.get_by_text(target_folder_name, exact=True).first.is_visible():
|
||||
self._create_folder(page, target_folder_name)
|
||||
page.get_by_text(target_folder_name, exact=True).first.dblclick()
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
# 3. 查重
|
||||
if page.get_by_text(filename, exact=True).count() > 0:
|
||||
self._log(f"云端已存在 '{filename}',跳过上传。")
|
||||
context.close()
|
||||
return True
|
||||
|
||||
# 4. 执行上传
|
||||
self._log(f"开始上传: {filename}")
|
||||
# 清除可能存在的旧“上传成功”提示
|
||||
page.evaluate("() => { const els = document.querySelectorAll('.ant-notification-notice'); els.forEach(e => e.remove()); }")
|
||||
|
||||
page.set_input_files("input[type=file]", file_path)
|
||||
|
||||
# 5. 确认上传已启动 (关键一步)
|
||||
self._log("等待上传任务初始化...")
|
||||
try:
|
||||
# 等待出现任何进度指示元素
|
||||
page.wait_for_selector("text=% , .ant-progress-inner, text=正在上传", timeout=20000)
|
||||
self._log("检测到上传流已建立。")
|
||||
except:
|
||||
self._log("警告:未检测到明显的进度条,可能由于文件较小或 UI 延迟,继续监测列表状态。")
|
||||
|
||||
# 6. 深度监测循环
|
||||
self._log("进入深度监测模式,请保持浏览器前台运行...")
|
||||
start_time = time.time()
|
||||
timeout = 1800 # 30分钟
|
||||
|
||||
# 连续稳定次数计数
|
||||
stable_count = 0
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
# A. 检查全局进度标志
|
||||
is_uploading = page.get_by_text("%").is_visible() or \
|
||||
page.get_by_text("正在上传").is_visible() or \
|
||||
page.locator(".ant-progress-inner").is_visible()
|
||||
|
||||
# B. 检查列表中该行的具体状态 (精准匹配)
|
||||
# 找到包含文件名的那一行 tr,看里面是否有“正在上传”字样
|
||||
row_status_text = ""
|
||||
try:
|
||||
row = page.locator(f"tr:has-text('{filename}')").first
|
||||
if row.is_visible():
|
||||
row_status_text = row.inner_text()
|
||||
except:
|
||||
pass
|
||||
|
||||
is_item_active = "正在上传" in row_status_text or "%" in row_status_text or "等待上传" in row_status_text
|
||||
|
||||
# C. 确认列表里确实有这个文件
|
||||
in_list = page.get_by_text(filename, exact=True).count() > 0
|
||||
|
||||
if not is_uploading and not is_item_active and in_list:
|
||||
stable_count += 1
|
||||
if stable_count >= 5: # 连续 5 次检测(约 15 秒)都处于稳定态
|
||||
self._log("检测到上传任务已从任务列表中消失,且列表文件状态正常。")
|
||||
self._log("为了绝对安全,最后等待 15 秒进行数据落盘同步...")
|
||||
page.wait_for_timeout(15000)
|
||||
self._log("上传确认圆满成功!")
|
||||
context.close()
|
||||
return True
|
||||
else:
|
||||
self._log(f"上传疑似完成,正在进行稳定性校验 ({stable_count}/5)...")
|
||||
else:
|
||||
if is_uploading or is_item_active:
|
||||
self._log("监测到活跃传输流...")
|
||||
stable_count = 0 # 只要发现还在传,重置计数
|
||||
|
||||
time.sleep(3)
|
||||
context = p.chromium.launch_persistent_context(**launch_args)
|
||||
context.add_init_script(
|
||||
"Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
|
||||
)
|
||||
|
||||
page = context.new_page()
|
||||
page.set_default_timeout(60000)
|
||||
|
||||
self._log("正在打开夸克网盘...")
|
||||
page.goto(self.url, wait_until="domcontentloaded", timeout=60000)
|
||||
|
||||
if not self._wait_for_login(page, timeout=10):
|
||||
self._log("请在浏览器中完成夸克登录,登录后会自动继续...")
|
||||
if not self._wait_for_login(page, timeout=180):
|
||||
raise RuntimeError("等待登录超时")
|
||||
|
||||
self._log(f"定位目录: {root_path} > {target_folder_name}")
|
||||
self._open_all_files(page)
|
||||
self._wait_for_file_list_loaded(page)
|
||||
|
||||
if not self._ensure_folder(page, root_path):
|
||||
raise RuntimeError(f"无法进入或创建根目录: {root_path}")
|
||||
|
||||
if not self._ensure_folder(page, target_folder_name):
|
||||
raise RuntimeError(f"无法进入或创建项目目录: {target_folder_name}")
|
||||
|
||||
if self._check_file_exists(page, filename):
|
||||
self._log(f"云端已存在 '{filename}',跳过上传。")
|
||||
return True
|
||||
|
||||
self._clear_old_notifications(page)
|
||||
self._log(f"开始上传: {filename} ({file_size_mb:.2f} MB)")
|
||||
self._select_upload_file(page, file_path)
|
||||
|
||||
if not self._wait_for_upload_started(page, filename):
|
||||
self._log("警告:未检测到明显的上传任务,继续监测文件列表。")
|
||||
else:
|
||||
self._log("检测到上传任务已建立。")
|
||||
|
||||
success = self._wait_for_upload_finished(page, filename)
|
||||
if success:
|
||||
self._log("夸克网盘上传确认成功。")
|
||||
return True
|
||||
|
||||
self._log("传输任务监测超时,请确认网速或手动核实。")
|
||||
context.close()
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self._log(f"代码执行异常: {str(e)}")
|
||||
except Exception as exc:
|
||||
self._log(f"代码执行异常: {exc}")
|
||||
return False
|
||||
finally:
|
||||
if context:
|
||||
try:
|
||||
context.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _wait_for_login(self, page, timeout: float) -> bool:
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < timeout:
|
||||
try:
|
||||
body_text = self._body_text(page)
|
||||
if "全部文件" in body_text or "我的文件" in body_text:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1)
|
||||
return False
|
||||
|
||||
def _open_all_files(self, page):
|
||||
if self._click_text(page, ["全部文件", "我的文件"]):
|
||||
page.wait_for_timeout(1000)
|
||||
return
|
||||
self._log("未找到“全部文件”入口,尝试直接在当前页面继续。")
|
||||
|
||||
def _wait_for_file_list_loaded(
|
||||
self,
|
||||
page,
|
||||
timeout: float = 12,
|
||||
poll_interval: float = 0.4,
|
||||
) -> bool:
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < timeout:
|
||||
try:
|
||||
state = page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
const text = document.body ? document.body.innerText : '';
|
||||
const loadingWords = ['加载中', '正在加载', '请稍候'];
|
||||
const hasLoadingText = loadingWords.some(word => text.includes(word));
|
||||
const itemCount = document.querySelectorAll(
|
||||
'[title], [aria-label], [class*="file"], [class*="File"]'
|
||||
).length;
|
||||
const hasAction = ['新建', '上传', '全部文件', '我的文件']
|
||||
.some(word => text.includes(word));
|
||||
const hasEmptyState = ['暂无文件', '空文件夹', '拖拽文件']
|
||||
.some(word => text.includes(word));
|
||||
return {hasLoadingText, itemCount, hasAction, hasEmptyState};
|
||||
}
|
||||
"""
|
||||
)
|
||||
if (
|
||||
state["hasAction"]
|
||||
and (state["itemCount"] > 0 or state["hasEmptyState"] or not state["hasLoadingText"])
|
||||
):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(poll_interval)
|
||||
return False
|
||||
|
||||
def _ensure_folder(self, page, folder_name: str) -> bool:
|
||||
self._wait_for_file_list_loaded(page)
|
||||
|
||||
if self._open_folder(page, folder_name):
|
||||
return True
|
||||
|
||||
self._log(f"目录不存在,准备创建: {folder_name}")
|
||||
if not self._create_folder(page, folder_name):
|
||||
return False
|
||||
|
||||
def _create_folder(self, page, folder_name: str):
|
||||
"""创建目录"""
|
||||
end_time = time.time() + 20
|
||||
while time.time() < end_time:
|
||||
if self._open_folder(page, folder_name):
|
||||
return True
|
||||
time.sleep(1)
|
||||
|
||||
return False
|
||||
|
||||
def _open_folder(self, page, folder_name: str) -> bool:
|
||||
try:
|
||||
page.get_by_text("新建").first.click()
|
||||
locator = page.get_by_text(folder_name, exact=True)
|
||||
count = locator.count()
|
||||
for index in range(min(count, 5)):
|
||||
item = locator.nth(index)
|
||||
if item.is_visible():
|
||||
item.dblclick(force=True, timeout=5000)
|
||||
page.wait_for_timeout(1200)
|
||||
self._wait_for_file_list_loaded(page)
|
||||
self._log(f"已进入目录: {folder_name}")
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
def _create_folder(self, page, folder_name: str) -> bool:
|
||||
try:
|
||||
if not self._click_text(page, ["新建"]):
|
||||
self._log("未找到“新建”按钮。")
|
||||
return False
|
||||
|
||||
page.wait_for_timeout(500)
|
||||
page.get_by_text("新建文件夹").first.click()
|
||||
if not self._click_text(page, ["新建文件夹", "文件夹"]):
|
||||
self._log("未找到“新建文件夹”入口。")
|
||||
return False
|
||||
|
||||
page.wait_for_timeout(500)
|
||||
page.keyboard.type(folder_name)
|
||||
page.keyboard.press("Enter")
|
||||
self._log(f"已创建目录: {folder_name}")
|
||||
page.wait_for_timeout(1500)
|
||||
except:
|
||||
return True
|
||||
except Exception as exc:
|
||||
self._log(f"创建目录失败: {exc}")
|
||||
return False
|
||||
|
||||
def _select_upload_file(self, page, file_path: str):
|
||||
input_locator = page.locator("input[type=file]")
|
||||
if input_locator.count() == 0:
|
||||
self._click_text(page, ["上传", "上传文件"])
|
||||
page.wait_for_selector("input[type=file]", timeout=10000)
|
||||
|
||||
page.locator("input[type=file]").first.set_input_files(file_path)
|
||||
|
||||
def _wait_for_upload_started(
|
||||
self,
|
||||
page,
|
||||
filename: str,
|
||||
timeout: float = 20,
|
||||
) -> bool:
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < timeout:
|
||||
state = self._upload_state(page, filename)
|
||||
if state["uploading"] or state["item_active"] or state["file_exists"]:
|
||||
return True
|
||||
time.sleep(0.5)
|
||||
return False
|
||||
|
||||
def _wait_for_upload_finished(
|
||||
self,
|
||||
page,
|
||||
filename: str,
|
||||
timeout: float = 1800,
|
||||
) -> bool:
|
||||
start_time = time.time()
|
||||
stable_count = 0
|
||||
last_active_log = 0.0
|
||||
|
||||
self._log("进入上传监测模式,请保持浏览器前台运行...")
|
||||
while time.time() - start_time < timeout:
|
||||
state = self._upload_state(page, filename)
|
||||
|
||||
if state["has_error"]:
|
||||
self._log("检测到页面提示上传失败。")
|
||||
return False
|
||||
|
||||
if state["file_exists"] and not state["uploading"] and not state["item_active"]:
|
||||
stable_count += 1
|
||||
if stable_count >= 5:
|
||||
self._log("文件已出现在列表中,且上传状态连续稳定。")
|
||||
self._log("最后等待 15 秒进行网盘同步确认...")
|
||||
page.wait_for_timeout(15000)
|
||||
return self._check_file_exists(page, filename)
|
||||
|
||||
self._log(f"上传疑似完成,稳定性校验 ({stable_count}/5)...")
|
||||
else:
|
||||
stable_count = 0
|
||||
now = time.time()
|
||||
if (state["uploading"] or state["item_active"]) and now - last_active_log >= 15:
|
||||
self._log("监测到活跃传输流,继续等待...")
|
||||
last_active_log = now
|
||||
|
||||
time.sleep(3)
|
||||
|
||||
return False
|
||||
|
||||
def _upload_state(self, page, filename: str) -> dict:
|
||||
try:
|
||||
state = page.evaluate(
|
||||
"""
|
||||
(filename) => {
|
||||
const normalize = value => (value || '').replace(/\\s+/g, ' ').trim();
|
||||
const visible = element => {
|
||||
if (!element) return false;
|
||||
const style = window.getComputedStyle(element);
|
||||
const rect = element.getBoundingClientRect();
|
||||
return style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& rect.width > 0
|
||||
&& rect.height > 0;
|
||||
};
|
||||
const bodyText = normalize(document.body ? document.body.innerText : '');
|
||||
const nodes = [
|
||||
...document.querySelectorAll(
|
||||
'[title], [aria-label], [class*="file"], [class*="File"], [class*="name"], [class*="Name"]'
|
||||
)
|
||||
].filter(visible);
|
||||
|
||||
const matchingNode = nodes.find(node => {
|
||||
const title = normalize(node.getAttribute('title'));
|
||||
const label = normalize(node.getAttribute('aria-label'));
|
||||
const text = normalize(node.textContent);
|
||||
return title === filename || label === filename || text === filename;
|
||||
});
|
||||
|
||||
const container = matchingNode
|
||||
? matchingNode.closest('tr, li, [class*="item"], [class*="Item"], [class*="row"], [class*="Row"], [class*="file"], [class*="File"]')
|
||||
: null;
|
||||
const containerText = normalize(container ? container.innerText : '');
|
||||
const activeWords = ['正在上传', '上传中', '等待上传', '传输中', '解析中', '处理中'];
|
||||
const errorWords = ['上传失败', '传输失败', '网络异常', '上传出错'];
|
||||
const progressPattern = /(?:^|\\D)(?:100|\\d{1,2})(?:\\.\\d+)?%/;
|
||||
const itemHasProgress = container
|
||||
? [...container.querySelectorAll(
|
||||
'.ant-progress-inner, [class*="progress"], [class*="Progress"]'
|
||||
)].some(visible)
|
||||
: false;
|
||||
|
||||
const itemActive = activeWords.some(word => containerText.includes(word))
|
||||
|| progressPattern.test(containerText)
|
||||
|| itemHasProgress;
|
||||
const uploading = activeWords.some(word => bodyText.includes(word))
|
||||
|| itemActive;
|
||||
const hasError = errorWords.some(word => bodyText.includes(word));
|
||||
|
||||
return {
|
||||
file_exists: Boolean(matchingNode),
|
||||
item_active: itemActive,
|
||||
uploading,
|
||||
has_error: hasError,
|
||||
};
|
||||
}
|
||||
""",
|
||||
filename,
|
||||
)
|
||||
return {
|
||||
"file_exists": bool(state.get("file_exists")),
|
||||
"item_active": bool(state.get("item_active")),
|
||||
"uploading": bool(state.get("uploading")),
|
||||
"has_error": bool(state.get("has_error")),
|
||||
}
|
||||
except Exception:
|
||||
return {
|
||||
"file_exists": self._check_file_exists(page, filename),
|
||||
"item_active": False,
|
||||
"uploading": False,
|
||||
"has_error": False,
|
||||
}
|
||||
|
||||
def _check_file_exists(self, page, filename: str) -> bool:
|
||||
try:
|
||||
locator = page.get_by_text(filename, exact=True)
|
||||
count = locator.count()
|
||||
for index in range(min(count, 5)):
|
||||
if locator.nth(index).is_visible():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
names = page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
const normalize = value => (value || '').replace(/\\s+/g, ' ').trim();
|
||||
const visible = element => {
|
||||
const style = window.getComputedStyle(element);
|
||||
const rect = element.getBoundingClientRect();
|
||||
return style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& rect.width > 0
|
||||
&& rect.height > 0;
|
||||
};
|
||||
const result = [];
|
||||
for (const node of document.querySelectorAll('[title], [aria-label]')) {
|
||||
if (!visible(node)) continue;
|
||||
const title = normalize(node.getAttribute('title'));
|
||||
const label = normalize(node.getAttribute('aria-label'));
|
||||
if (title) result.push(title);
|
||||
if (label) result.push(label);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
"""
|
||||
)
|
||||
return filename in names
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _click_text(self, page, texts: list[str]) -> bool:
|
||||
for text in texts:
|
||||
for exact in (True, False):
|
||||
try:
|
||||
locator = page.get_by_text(text, exact=exact)
|
||||
count = locator.count()
|
||||
for index in range(min(count, 5)):
|
||||
item = locator.nth(index)
|
||||
if item.is_visible():
|
||||
item.click(force=True, timeout=5000)
|
||||
return True
|
||||
except PlaywrightTimeoutError:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
|
||||
def _clear_old_notifications(self, page):
|
||||
try:
|
||||
page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
document
|
||||
.querySelectorAll('.ant-notification-notice, .ant-message-notice')
|
||||
.forEach(element => element.remove());
|
||||
}
|
||||
"""
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _body_text(self, page) -> str:
|
||||
return page.evaluate("() => document.body ? document.body.innerText : ''")
|
||||
|
||||
Reference in New Issue
Block a user