""" 品牌爬虫蓝图:展开文件夹、运行任务、任务列表/详情、下载结果 """ 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": ""}) 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: 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/') # @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//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//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//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/', 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/') @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