diff --git a/.env b/.env index 3164fdb..d1fdeed 100644 --- a/.env +++ b/.env @@ -1,2 +1,6 @@ PM_USER_ID=b21364 -PM_PASSWORD=b21364!.`nDB_HOST=localhost`nDB_USER=root`nDB_PASSWORD=45278434`nDB_NAME=crawling +PM_PASSWORD=b21364!. +DB_HOST=localhost +DB_USER=root +DB_PASSWORD=45278434 +DB_NAME=PM_proto diff --git a/README.md b/README.md index 39180ec..afa860c 100644 --- a/README.md +++ b/README.md @@ -37,12 +37,18 @@ AI는 파일을 분류할 때 단순한 키워드 매칭이 아닌, 아래의 ** --- -# 프로젝트 관리 규칙 +# 🛠️ 개발 및 관리 규칙 (Strict Development Rules) -1. **언어 설정**: 영어로 생각하되, 모든 답변은 한국어로 작성한다. (일본어, 중국어는 절대 사용하지 않는다.) -2. **수정 권한 제한**: 사용자가 명시적으로 지시한 사항 외에는 **절대 절대 절대** 코드를 임의로 수정하지 않는다. -3. **로그 기록 철저**: 모달 오픈 여부, 수집 성공/실패 여부 등 진행 상황을 실시간 로그에 상세히 표시한다. -4. **선보고 후승인**: 모든 기능 수정 및 코드 변경 전에는 예상 방안을 먼저 보고하고, 사용자가 **'진행시켜'**라고 명령한 경우에만 작업을 수행한다. +1. **언어 설정**: 영어로 생각하되, 모든 답변은 **한국어**로 작성한다. +2. **임의 수정 절대 금지 (Zero-Arbitrary Change)**: + - 사용자가 명시적으로 지시한 부분 외에는 **단 한 줄의 코드도, 그 어떤 파일도 임의로 수정, 정리, 리팩토링하지 않는다.** + - 지시받지 않은 다른 파트의 코드는 절대 건드리지 않으며, 영향 범위가 요청 범위를 벗어나지 않도록 '외과 수술식(Surgical) 수정'을 원칙으로 한다. +3. **개선 작업 절차 (Test-First Approach)**: + - 사용자가 개선(Refactoring, Optimization 등)을 지시한 경우, **수정 전 현재 시스템이 정상적으로 잘 작동하는지 먼저 전수 확인**한다. + - 기존 동작 방식과 성능을 기준(Baseline)으로 삼고, 수정 후에도 **기존의 모든 기능이 무결하게 유지되는지 반드시 테스트하여 입증**한다. + - 검증 결과를 바탕으로 "무엇을, 왜, 어떻게" 바꿀지 상세 보고 후, 사용자로부터 **'진행시켜'** 승인을 얻은 뒤에만 집행한다. +4. **선보고 후승인**: 모든 기능 수정 및 코드 변경 전에는 예상 방안을 먼저 보고하고 승인 절차를 거친다. +5. **로그 기록 철저**: 진행 상황(로그인, 수집, 오류 등)을 실시간 로그에 상세히 표시하여 투명성을 확보한다. --- diff --git a/__pycache__/crawler_service.cpython-312.pyc b/__pycache__/crawler_service.cpython-312.pyc index 73a9a5c..65ae16e 100644 Binary files a/__pycache__/crawler_service.cpython-312.pyc and b/__pycache__/crawler_service.cpython-312.pyc differ diff --git a/__pycache__/server.cpython-312.pyc b/__pycache__/server.cpython-312.pyc index 34b8a2d..2f465da 100644 Binary files a/__pycache__/server.cpython-312.pyc and b/__pycache__/server.cpython-312.pyc differ diff --git a/composition_debug.png b/composition_debug.png deleted file mode 100644 index 5ccabb3..0000000 Binary files a/composition_debug.png and /dev/null differ diff --git a/crawler_service.py b/crawler_service.py index f56d3a2..e2af8e7 100644 --- a/crawler_service.py +++ b/crawler_service.py @@ -11,15 +11,18 @@ from datetime import datetime from playwright.async_api import async_playwright from dotenv import load_dotenv -load_dotenv() +load_dotenv(override=True) + +# 글로벌 중단 제어용 이벤트 +crawl_stop_event = threading.Event() def get_db_connection(): - """MySQL 데이터베이스 연결을 반환합니다.""" + """MySQL 데이터베이스 연결을 반환 (환경변수 기반)""" return pymysql.connect( - host='localhost', - user='root', - password='45278434', - database='crawling', + host=os.getenv('DB_HOST', 'localhost'), + user=os.getenv('DB_USER', 'root'), + password=os.getenv('DB_PASSWORD', '45278434'), + database=os.getenv('DB_NAME', 'PM_proto'), charset='utf8mb4', cursorclass=pymysql.cursors.DictCursor ) @@ -27,12 +30,10 @@ def get_db_connection(): def clean_date_string(date_str): if not date_str: return "" match = re.search(r'(\d{2})[./-](\d{2})[./-](\d{2})', date_str) - if match: - return f"20{match.group(1)}.{match.group(2)}.{match.group(3)}" + if match: return f"20{match.group(1)}.{match.group(2)}.{match.group(3)}" return date_str[:10].replace("-", ".") def parse_log_id(log_id): - """ID 구조: 로그고유번호_시간_활동한 사람_활동내용_활동대상""" if not log_id or "_" not in log_id: return log_id try: parts = log_id.split('_') @@ -45,6 +46,7 @@ def parse_log_id(log_id): return log_id def crawler_thread_worker(msg_queue, user_id, password): + crawl_stop_event.clear() if sys.platform == 'win32': asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) @@ -55,11 +57,18 @@ def crawler_thread_worker(msg_queue, user_id, password): async with async_playwright() as p: browser = None try: - msg_queue.put(json.dumps({'type': 'log', 'message': '브라우저 엔진 가동 (전 기능 완벽 복구 모드)...'})) - browser = await p.chromium.launch(headless=False, args=["--no-sandbox"]) - context = await browser.new_context(viewport={'width': 1600, 'height': 900}) + msg_queue.put(json.dumps({'type': 'log', 'message': '브라우저 엔진 가동 (전 기능 복구 모드)...'})) + browser = await p.chromium.launch(headless=False, args=[ + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-blink-features=AutomationControlled" + ]) + context = await browser.new_context( + viewport={'width': 1600, 'height': 900}, + user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" + ) - captured_data = {"tree": None, "_is_root_archive": False, "_tree_url": "", "project_list": []} + captured_data = {"tree": None, "_is_root_archive": False, "project_list": []} async def global_interceptor(response): url = response.url @@ -68,17 +77,13 @@ def crawler_thread_worker(msg_queue, user_id, password): data = await response.json() captured_data["project_list"] = data.get("data", []) elif "getTreeObject" in url: - # [핵심 복원] 정확한 루트 경로 판별 로직 is_root = False if "params[resourcePath]=" in url: path_val = url.split("params[resourcePath]=")[1].split("&")[0] if path_val in ["%2F", "/"]: is_root = True - if is_root: - data = await response.json() - captured_data["tree"] = data - captured_data["_is_root_archive"] = "archive" in url - captured_data["_tree_url"] = url + captured_data["tree"] = await response.json() + captured_data["_is_root_archive"] = True except: pass context.on("response", global_interceptor) @@ -86,54 +91,52 @@ def crawler_thread_worker(msg_queue, user_id, password): await page.goto("https://overseas.projectmastercloud.com/dashboard", wait_until="domcontentloaded") # 로그인 - if await page.locator("#login-by-id").is_visible(timeout=5000): + if await page.locator("#login-by-id").is_visible(timeout=10000): await page.click("#login-by-id") await page.fill("#user_id", user_id) await page.fill("#user_pw", password) await page.click("#login-btn") - # 리스트 로딩 대기 await page.wait_for_selector("h4.list__contents_aria_group_body_list_item_label", timeout=60000) await asyncio.sleep(3) - # [Phase 1] DB 기초 정보 동기화 (마스터 테이블) + # [Phase 1] DB 마스터 정보 동기화 if captured_data["project_list"]: conn = get_db_connection() try: with conn.cursor() as cursor: for p_info in captured_data["project_list"]: - try: - sql = """ - INSERT INTO projects_master (project_id, project_nm, short_nm, master, continent, country) - VALUES (%s, %s, %s, %s, %s, %s) - ON DUPLICATE KEY UPDATE - project_nm = VALUES(project_nm), short_nm = VALUES(short_nm), - master = VALUES(master), continent = VALUES(continent), country = VALUES(country) - """ - cursor.execute(sql, (p_info.get("project_id"), p_info.get("project_nm"), - p_info.get("short_nm", "").strip(), p_info.get("master"), - p_info.get("large_class"), p_info.get("mid_class"))) - except: continue - conn.commit() - msg_queue.put(json.dumps({'type': 'log', 'message': f'DB 마스터 정보 동기화 완료.'})) + sql = """ + INSERT INTO projects_master (project_id, project_nm, short_nm, master, continent, country) + VALUES (%s, %s, %s, %s, %s, %s) + ON DUPLICATE KEY UPDATE + project_nm = VALUES(project_nm), short_nm = VALUES(short_nm), + master = VALUES(master), continent = VALUES(continent), country = VALUES(country) + """ + cursor.execute(sql, (p_info.get("project_id"), p_info.get("project_nm"), + p_info.get("short_nm", "").strip(), p_info.get("master"), + p_info.get("large_class"), p_info.get("mid_class"))) + conn.commit() + msg_queue.put(json.dumps({'type': 'log', 'message': 'DB 마스터 정보 동기화 완료.'})) finally: conn.close() - # [Phase 2] h4 태그 기반 수집 루프 + # [Phase 2] 수집 루프 names = await page.locator("h4.list__contents_aria_group_body_list_item_label").all_inner_texts() project_names = list(dict.fromkeys([n.strip() for n in names if n.strip()])) count = len(project_names) for i, project_name in enumerate(project_names): - # 현재 프로젝트의 고유 ID 매칭 (저장용) + if crawl_stop_event.is_set(): + msg_queue.put(json.dumps({'type': 'log', 'message': '>>> 중단 신호 감지: 종료합니다.'})) + break + + msg_queue.put(json.dumps({'type': 'log', 'message': f'[{i+1}/{count}] {project_name} 수집 시작'})) p_match = next((p for p in captured_data["project_list"] if p.get('project_nm') == project_name or p.get('short_nm', '').strip() == project_name), None) current_p_id = p_match.get('project_id') if p_match else None + captured_data["tree"] = None; captured_data["_is_root_archive"] = False - captured_data["tree"] = None - captured_data["_is_root_archive"] = False - msg_queue.put(json.dumps({'type': 'log', 'message': f'[{i+1}/{count}] {project_name} 수집 시작'})) - try: - # 1. 프로젝트 진입 ([완전 복원] 좌표 클릭) + # 1. 프로젝트 진입 (좌표 클릭) target_el = page.locator(f"h4.list__contents_aria_group_body_list_item_label:has-text('{project_name}')").first await target_el.scroll_into_view_if_needed() box = await target_el.bounding_box() @@ -143,44 +146,34 @@ def crawler_thread_worker(msg_queue, user_id, password): await page.wait_for_selector("text=활동로그", timeout=30000) await asyncio.sleep(2) - recent_log = "데이터 없음" - file_count = 0 + recent_log = "데이터 없음"; file_count = 0 - # 2. 활동로그 ([완전 복원] 3회 재시도 + 좌표 클릭 + 날짜 필터) + # 2. 활동로그 (날짜 필터 적용 버전) modal_opened = False for _ in range(3): - log_btn = page.get_by_text("활동로그").first - btn_box = await log_btn.bounding_box() - if btn_box: await page.mouse.click(btn_box['x'] + 5, btn_box['y'] + 5) - else: await page.evaluate("(el) => el.click()", await log_btn.element_handle()) - + await page.get_by_text("활동로그").first.click() try: await page.wait_for_selector("article.archive-modal", timeout=5000) - modal_opened = True - break + modal_opened = True; break except: await asyncio.sleep(1) if modal_opened: - # 날짜 필터 입력 + # 날짜 필터 2020-01-01 적용 inputs = await page.locator("article.archive-modal input").all() for inp in inputs: if (await inp.get_attribute("type")) == "date": - await inp.fill("2020-01-01") - break + await inp.fill("2020-01-01"); break apply_btn = page.locator("article.archive-modal").get_by_text("적용").first if await apply_btn.is_visible(): await apply_btn.click() - await asyncio.sleep(5) # 렌더링 보장 + await asyncio.sleep(5) log_elements = await page.locator("article.archive-modal div[id*='_']").all() if log_elements: - raw_id = await log_elements[0].get_attribute("id") - recent_log = parse_log_id(raw_id) - msg_queue.put(json.dumps({'type': 'log', 'message': f' - [분석] 최신 로그 ID 추출 성공: {recent_log}'})) - msg_queue.put(json.dumps({'type': 'log', 'message': f' - [최종 결과] {recent_log}'})) + recent_log = parse_log_id(await log_elements[0].get_attribute("id")) await page.keyboard.press("Escape") - # 3. 구성 수집 ([완전 복원] BaseURL fetch + 정밀 합산) + # 3. 구성 수집 (API Fetch 방식 - 팝업 없음) await page.evaluate("""() => { const baseUrl = window.location.origin + window.location.pathname.split('/').slice(0, 2).join('/'); fetch(`${baseUrl}/archive/getTreeObject?params[storageType]=CLOUD¶ms[resourcePath]=/`); @@ -190,43 +183,26 @@ def crawler_thread_worker(msg_queue, user_id, password): await asyncio.sleep(0.5) if captured_data["tree"]: - data_root = captured_data["tree"] - tree = data_root.get('currentTreeObject', data_root) if isinstance(data_root, dict) else {} - total = 0 - # 루트 파일 합산 - rf = tree.get("file", {}) - total += len(rf) if isinstance(rf, (dict, list)) else 0 - # 폴더별 filesCount 합산 + tree = captured_data["tree"].get('currentTreeObject', captured_data["tree"]) + total = len(tree.get("file", {})) folders = tree.get("folder", {}) if isinstance(folders, dict): - for f in folders.values(): - c = f.get("filesCount", "0") - total += int(c) if str(c).isdigit() else 0 + for f in folders.values(): total += int(f.get("filesCount", 0)) file_count = total - msg_queue.put(json.dumps({'type': 'log', 'message': f' - [구성] 데이터 채택 성공: ...{captured_data.get("_tree_url", "")[-40:]}'})) - msg_queue.put(json.dumps({'type': 'log', 'message': f' - [구성] 최종 정밀 합산 성공 ({file_count}개)'})) - # 4. DB 실시간 저장 (히스토리 테이블) + # 4. DB 실시간 저장 if current_p_id: - conn = get_db_connection() - try: + with get_db_connection() as conn: with conn.cursor() as cursor: - # 오늘 날짜 히스토리 데이터 삽입 또는 업데이트 - sql = """ - INSERT INTO projects_history (project_id, crawl_date, recent_log, file_count) - VALUES (%s, CURRENT_DATE(), %s, %s) - ON DUPLICATE KEY UPDATE - recent_log = VALUES(recent_log), file_count = VALUES(file_count) - """ + sql = "INSERT INTO projects_history (project_id, crawl_date, recent_log, file_count) VALUES (%s, CURRENT_DATE(), %s, %s) ON DUPLICATE KEY UPDATE recent_log=VALUES(recent_log), file_count=VALUES(file_count)" cursor.execute(sql, (current_p_id, recent_log, file_count)) conn.commit() - msg_queue.put(json.dumps({'type': 'log', 'message': f' - [DB] 히스토리 업데이트 완료 (ID: {current_p_id})'})) - finally: conn.close() + msg_queue.put(json.dumps({'type': 'log', 'message': f' - [성공] 로그: {recent_log[:20]}... / 파일: {file_count}개'})) await page.goto("https://overseas.projectmastercloud.com/dashboard", wait_until="domcontentloaded") except Exception as e: - msg_queue.put(json.dumps({'type': 'log', 'message': f' - [{project_name}] 건너뜀: {str(e)}'})) + msg_queue.put(json.dumps({'type': 'log', 'message': f' - {project_name} 실패: {str(e)}'})) await page.goto("https://overseas.projectmastercloud.com/dashboard") msg_queue.put(json.dumps({'type': 'done', 'data': []})) @@ -241,10 +217,8 @@ def crawler_thread_worker(msg_queue, user_id, password): loop.close() async def run_crawler_service(): - user_id = os.getenv("PM_USER_ID") - password = os.getenv("PM_PASSWORD") msg_queue = queue.Queue() - thread = threading.Thread(target=crawler_thread_worker, args=(msg_queue, user_id, password)) + thread = threading.Thread(target=crawler_thread_worker, args=(msg_queue, os.getenv("PM_USER_ID"), os.getenv("PM_PASSWORD"))) thread.start() while True: try: diff --git a/crawling_result 2026.03.06.csv b/crawling_result 2026.03.06.csv deleted file mode 100644 index bd6d6c4..0000000 --- a/crawling_result 2026.03.06.csv +++ /dev/null @@ -1,42 +0,0 @@ -projectName,recentLog,fileCount -ITTC 관개 교육센터,"26.01.29, 박진규, 폴더 삭제",16 -비엔티안 메콩강 관리 2차,"25.12.07, 나쉬, 파일 업로드",260 -만달레이 철도 개량 감리,"25.11.19, 이태훈, 보안참여자 권한 추가",298 -푸옥호아 양수 발전,"26.02.23, 이철호, 폴더 이름 변경",139 -아시르 지잔 고속도로,"26.03.04, 이태훈, 보안참여자 권한 추가",73 -지방 도로 복원,"26.03.04, 이태훈, 보안참여자 권한 추가",0 -타슈켄트 철도,"26.02.05, 조항언, 파일 업로드",51 -Habbaniyah Shuaiba AirBase,"26.03.04, 이태훈, 보안참여자 권한 추가",0 -시엠립 하수처리 개선,"26.02.09, 이태훈, 보안참여자 권한 추가",221 -반테 민체이 관개 홍수저감,"25.12.07, 나쉬, 파일 업로드",44 -메콩유역 수자원 관리 기후적응,"25.11.19, 이태훈, 보안참여자 권한 추가",0 -잘랄아바드 상수도 계획,"26.03.06, -, 폴더 자동 삭제 (파일 개수 미달)",58 -CAREC 도로 감리,"26.03.04, 이태훈, 보안참여자 권한 추가",0 -펀잡 홍수 방재,"25.12.08, 콰윰 아딜, 폴더 삭제",0 -KP 아보타바드 상수도,"26.02.26, 정기일, 파일 업로드",240 -홍수 복원 InFRA2,"25.12.18, -, 폴더 자동 삭제 (파일 개수 미달)",6 -PGN 해상교량 BID2,"26.03.04, 이태훈, 보안참여자 권한 추가",631 -홍수 관리 Package5B,"25.12.02, 조명훈, 폴더 이름 변경",14 -족자~바웬 도로사업,"26.03.06, 시스템관리-Savannah, 참관자 권한 추가",0 -테치만 상수도 확장,"26.02.09, 이태훈, 보안참여자 권한 추가",0 -기니 벼 재배단지,"26.01.07, -, 폴더 자동 삭제 (파일 개수 미달)",43 -부수쿠마 분뇨 자원화 2단계,"26.02.09, 이태훈, 보안참여자 권한 추가",9 -우간다 벼 재배단지,"25.12.08, 박수빈, 파일 업로드",52 -Adeaa-Becho 지하수 관개,"25.12.29, -, 폴더 자동 삭제 (파일 개수 미달)",140 -도도타군 관개,"25.12.30, -, 폴더 자동 삭제 (파일 개수 미달)",142 -지하수 관개 환경설계,"26.02.09, 이태훈, 보안참여자 권한 추가",0 -Dodoma 하수 설계감리,"26.02.09, 이태훈, 보안참여자 권한 추가",32 -Iringa 상하수도 개선,"26.02.09, 이태훈, 보안참여자 권한 추가",0 -도도마 유수율 상수도개선,"26.02.12, 서하연, 부관리자 권한 추가",35 -잔지바르 쌀 생산,"25.12.08, 박수빈, 파일 업로드",23 -SALDEORO 수력발전 28MW,"25.11.19, 이태훈, 보안참여자 권한 추가",0 -LaPaz Danli 상수도,"26.02.09, 이태훈, 보안참여자 권한 추가",60 -에스꼬마 차라짜니 도로,"26.03.06, -, 폴더 자동 삭제 (파일 개수 미달)",0 -마모레 교량도로,"26.03.04, 이태훈, 보안참여자 권한 추가",120 -Bombeo-Colomi 도로설계,"26.03.04, 이태훈, 보안참여자 권한 추가",48 -AI 폐기물,"25.11.19, 이태훈, 보안참여자 권한 추가",0 -도로 통행료 현대화,"26.02.25, 류창수, 폴더 삭제",0 -Barranca 상하수도 확장,"26.02.09, 이태훈, 보안참여자 권한 추가",44 -태평양 철도,"26.02.24, -, 폴더 자동 삭제 (파일 개수 미달)",101 -필리핀 사무소,"26.03.06, 한형남, 파일 다운로드",316 -PGN 해상교량 BID2,"26.03.04, 이태훈, 보안참여자 권한 추가",631 diff --git a/crawling_result 2026.03.09.csv b/crawling_result 2026.03.09.csv deleted file mode 100644 index b1bc926..0000000 --- a/crawling_result 2026.03.09.csv +++ /dev/null @@ -1,42 +0,0 @@ -projectName,recentLog,fileCount -비엔티안 메콩강 관리 2차,212487_25-12-07 12:22:26_나쉬_파일 업로드_/서류/06. 전문가파견/01. 파견/251031~260208_전문가 파견(이범주).zip,260 -ITTC 관개 교육센터,225728_26-01-29 09:10:21_박진규_폴더 삭제_/서류/ggg,16 -만달레이 철도 개량 감리,207041_25-11-19 16:54:36_이태훈_보안참여자 권한 추가_홍아름_김혜인,298 -푸옥호아 양수 발전,233465_26-02-23 10:24:46_이철호_폴더 이름 변경_/6 준공/3 준공도서 26년 2월 작성예정 발주처 협의중_/6 준공/3 준공도서 26년 3월 작성예정 발주처 협의중,139 -아시르 지잔 고속도로,234455_26-03-04 14:31:52_이태훈_보안참여자 권한 추가_복진훈,73 -타슈켄트 철도,228919_26-02-05 10:08:11_조항언_파일 업로드_/02_성과품/B_중간보고/BB_중간보고서/[러문] INTERIM REPORT_0115_F.pdf,51 -지방 도로 복원,234456_26-03-04 14:32:07_이태훈_보안참여자 권한 추가_복진훈,0 -Habbaniyah Shuaiba AirBase,234457_26-03-04 14:32:28_이태훈_보안참여자 권한 추가_복진훈,0 -시엠립 하수처리 개선,231205_26-02-09 11:03:05_이태훈_보안참여자 권한 추가_김창환_배형원,221 -반테 민체이 관개 홍수저감,212512_25-12-07 12:35:02_나쉬_파일 업로드_/서류/04. 기성/18차 기성금/06. 통장사본.pdf,44 -메콩유역 수자원 관리 기후적응,207047_25-11-19 17:01:19_이태훈_보안참여자 권한 추가_홍아름_김혜인,0 -잘랄아바드 상수도 계획,234860_26-03-06 10:24:27_-_폴더 자동 삭제 (파일 개수 미달)_/4. MP 성과품/3. 최종보고서/초안 제출,58 -펀잡 홍수 방재,212686_25-12-08 13:05:24_콰윰 아딜_폴더 삭제_/RFP,0 -CAREC 도로 감리,234458_26-03-04 14:32:50_이태훈_보안참여자 권한 추가_복진훈,0 -KP 아보타바드 상수도,234120_26-02-26 20:58:46_정기일_파일 업로드_/99 참고자료/99 체코 두코바니 원전/00 2025년 입찰/Engineering work instruction_241206.pdf,240 -PGN 해상교량 BID2,234454_26-03-04 14:31:31_이태훈_보안참여자 권한 추가_복진훈,631 -홍수 복원 InFRA2,214399_25-12-18 09:05:49_-_폴더 자동 삭제 (파일 개수 미달)_/서류/3.경비신청 및 정산/1. 신청,6 -홍수 관리 Package5B,211353_25-12-02 10:04:18_조명훈_폴더 이름 변경_/서류/01. 계약서/01. 계약서_/서류/01. 계약서/01. 사업계약서,14 -족자~바웬 도로사업,234908_26-03-06 13:36:39_시스템관리-Savannah_참관자 권한 추가_이호성,0 -테치만 상수도 확장,231210_26-02-09 11:06:41_이태훈_보안참여자 권한 추가_배형원_정낙훈,0 -기니 벼 재배단지,216888_26-01-07 11:07:23_-_폴더 자동 삭제 (파일 개수 미달)_/계약서류/경비 신청 및 정산/01. 신청,43 -우간다 벼 재배단지,"212622_25-12-08 11:17:57_박수빈_파일 업로드_/계약서류/경비 신청 및 정산/01. 현장경비 신청전표/11-20251029-J0401-006 (9,10월 현장운영비).pdf",52 -부수쿠마 분뇨 자원화 2단계,231215_26-02-09 11:08:34_이태훈_보안참여자 권한 추가_김창환_배형원,9 -지하수 관개 환경설계,231212_26-02-09 11:07:35_이태훈_보안참여자 권한 추가_김창환_배형원,0 -Adeaa-Becho 지하수 관개,215553_25-12-29 09:36:23_-_폴더 자동 삭제 (파일 개수 미달)_/Topographic Survey/측량조사성과품/Appendix B - List of GPS Control Points,140 -도도타군 관개,215706_25-12-30 09:17:43_-_폴더 자동 삭제 (파일 개수 미달)_/서류/03.경비신청 및 정산/02.사내정산 2차,142 -Iringa 상하수도 개선,231216_26-02-09 11:10:18_이태훈_보안참여자 권한 추가_김창환_배형원,0 -Dodoma 하수 설계감리,231217_26-02-09 11:11:06_이태훈_보안참여자 권한 추가_김창환_배형원,32 -도도마 유수율 상수도개선,232629_26-02-12 17:26:13_서하연_부관리자 권한 추가_정기일,35 -잔지바르 쌀 생산,212641_25-12-08 11:43:37_박수빈_파일 업로드_/서류/경비신청 및 정산/01. 25년/11-20250409-J0401-005 (운영경비 2분기 송금).pdf,23 -SALDEORO 수력발전 28MW,207029_25-11-19 16:44:04_이태훈_보안참여자 권한 추가_홍아름_김혜인,0 -LaPaz Danli 상수도,231219_26-02-09 11:12:37_이태훈_보안참여자 권한 추가_배형원_정낙훈,60 -에스꼬마 차라짜니 도로,234837_26-03-06 08:00:21_-_폴더 자동 삭제 (파일 개수 미달)_/EOI/001/EOI서류,0 -Bombeo-Colomi 도로설계,234463_26-03-04 14:34:24_이태훈_보안참여자 권한 추가_복진훈,48 -마모레 교량도로,234462_26-03-04 14:33:56_이태훈_보안참여자 권한 추가_복진훈,120 -AI 폐기물,207034_25-11-19 16:50:49_이태훈_보안참여자 권한 추가_홍아름_김혜인,0 -도로 통행료 현대화,233938_26-02-25 14:39:08_류창수_폴더 삭제_/필리핀다바오프로젝트,0 -Barranca 상하수도 확장,231220_26-02-09 11:13:28_이태훈_보안참여자 권한 추가_김창환_배형원,44 -태평양 철도,233798_26-02-24 17:52:33_-_폴더 자동 삭제 (파일 개수 미달)_/01_수행문서/D_보고자료/D6_최종보고,101 -필리핀 사무소,234973_26-03-09 09:35:45_한형남_파일 다운로드_/3. DPTMP(Davao Public Transportation Modernization Project)/AFCS/2.배포자료/PHI DPTMP AFCS Package PIM 04March2026.pdf_/3. DPTMP(Davao Public Transportation Modernization Project)/AFCS/2.배포자료/PAFCS PIM v04March2026.pdf,322 -PGN 해상교량 BID2,234454_26-03-04 14:31:31_이태훈_보안참여자 권한 추가_복진훈,631 diff --git a/crwaling_result.csv b/crwaling_result.csv deleted file mode 100644 index 5560a02..0000000 --- a/crwaling_result.csv +++ /dev/null @@ -1,42 +0,0 @@ -projectName,recentLog,fileCount -ITTC 관개 교육센터,"26.01.29, 박진규, 폴더 삭제",16 -비엔티안 메콩강 관리 2차,"25.12.07, 나쉬, 파일 업로드",260 -만달레이 철도 개량 감리,"25.11.19, 이태훈, 보안참여자 권한 추가",298 -푸옥호아 양수 발전,"26.02.23, 이철호, 폴더 이름 변경",139 -아시르 지잔 고속도로,"26.03.04, 이태훈, 보안참여자 권한 추가",75 -지방 도로 복원,"26.03.04, 이태훈, 보안참여자 권한 추가",0 -타슈켄트 철도,"26.02.05, 조항언, 파일 업로드",51 -Habbaniyah Shuaiba AirBase,"26.03.04, 이태훈, 보안참여자 권한 추가",0 -시엠립 하수처리 개선,"26.02.09, 이태훈, 보안참여자 권한 추가",222 -반테 민체이 관개 홍수저감,"25.12.07, 나쉬, 파일 업로드",0 -메콩유역 수자원 관리 기후적응,"25.11.19, 이태훈, 보안참여자 권한 추가",0 -잘랄아바드 상수도 계획,"26.03.06, -, 폴더 자동 삭제 (파일 개수 미달)",58 -CAREC 도로 감리,"26.03.04, 이태훈, 보안참여자 권한 추가",0 -펀잡 홍수 방재,"25.12.08, 콰윰 아딜, 폴더 삭제",0 -KP 아보타바드 상수도,"26.02.26, 정기일, 파일 업로드",240 -홍수 복원 InFRA2,"25.12.18, -, 폴더 자동 삭제 (파일 개수 미달)",6 -PGN 해상교량 BID2,"26.03.04, 이태훈, 보안참여자 권한 추가",0 -홍수 관리 Package5B,"25.12.02, 조명훈, 폴더 이름 변경",14 -족자~바웬 도로사업,"26.03.06, 시스템관리-Savannah, 참관자 권한 추가",0 -테치만 상수도 확장,"26.02.09, 이태훈, 보안참여자 권한 추가",0 -기니 벼 재배단지,"26.01.07, -, 폴더 자동 삭제 (파일 개수 미달)",44 -부수쿠마 분뇨 자원화 2단계,"26.02.09, 이태훈, 보안참여자 권한 추가",9 -우간다 벼 재배단지,"25.12.08, 박수빈, 파일 업로드",52 -Adeaa-Becho 지하수 관개,"25.12.29, -, 폴더 자동 삭제 (파일 개수 미달)",140 -도도타군 관개,"25.12.30, -, 폴더 자동 삭제 (파일 개수 미달)",0 -지하수 관개 환경설계,"26.02.09, 이태훈, 보안참여자 권한 추가",0 -Dodoma 하수 설계감리,"26.02.09, 이태훈, 보안참여자 권한 추가",32 -Iringa 상하수도 개선,"26.02.09, 이태훈, 보안참여자 권한 추가",0 -도도마 유수율 상수도개선,"26.02.12, 서하연, 부관리자 권한 추가",35 -잔지바르 쌀 생산,"25.12.08, 박수빈, 파일 업로드",23 -SALDEORO 수력발전 28MW,"25.11.19, 이태훈, 보안참여자 권한 추가",0 -LaPaz Danli 상수도,"26.02.09, 이태훈, 보안참여자 권한 추가",65 -에스꼬마 차라짜니 도로,"26.03.06, -, 폴더 자동 삭제 (파일 개수 미달)",0 -마모레 교량도로,"26.03.04, 이태훈, 보안참여자 권한 추가",120 -Bombeo-Colomi 도로설계,"26.03.04, 이태훈, 보안참여자 권한 추가",49 -AI 폐기물,"25.11.19, 이태훈, 보안참여자 권한 추가",0 -도로 통행료 현대화,"26.02.25, 류창수, 폴더 삭제",0 -Barranca 상하수도 확장,"26.02.09, 이태훈, 보안참여자 권한 추가",50 -태평양 철도,"26.02.24, -, 폴더 자동 삭제 (파일 개수 미달)",101 -필리핀 사무소,"26.03.06, 한형남, 파일 다운로드",0 -PGN 해상교량 BID2,"26.03.04, 이태훈, 보안참여자 권한 추가",637 diff --git a/db_2026.03.09.csv b/db_2026.03.09.csv deleted file mode 100644 index 475e24e..0000000 --- a/db_2026.03.09.csv +++ /dev/null @@ -1,42 +0,0 @@ -[PM Overseas 프로젝트 현황],,2026.03.04,,,,,,<<활동로그가 없는 프로젝트 (8),, -,,,,,,,,,, -No.,프로젝트 명,담당부서,담당자,종료(예정)일,최근 활동로그,과업개요 작성 유무,파일 수,비고,, -1,라오스 ITTC 관개 교육센터 PMC,수자원1부,방노성,2025.12.20,"2026.01.29, 폴더 삭제",O,16,2026.01.29 로그는 테스트 활동 추정,종료(예정)일 지남,진행 -2,라오스 비엔티안 메콩강 관리 2차 DD,수자원1부,방노성,2026.05.31,"2025.12.07, 파일업로드",X,260,탭 1개에 모든파일 업로드,, -3,미얀마 만달레이 철도 개량 감리 CS,철도사업부,김태헌,2027.11.17,"2025.11.17, 폴더이름변경",O,298,,, -4,베트남 푸옥호아 양수 발전 FS,수력부,이철호,2025.11.30,"2026.02.23, 폴더이름변경",O,139,준공도서 3월 작성예정,종료(예정)일 지남,준공 -5,사우디아라비아 아시르 지잔 고속도로 FS,도로부,공태원,2025.11.21,"2026.02.09, 파일다운로드",O,73,,종료(예정)일 지남,준공 -6,우즈베키스탄 지방 도로 복원 MP,도로부,장진영,2029.04.28,X,X,0,,, -7,우즈베키스탄 타슈켄트 철도 FS,철도사업부,김태헌,2026.03.20,"2026.02.05, 파일업로드",O,51,,, -8,이라크 Habbaniyah Shuaiba AirBase PD,도로부,강동구,2026.12.31,X,X,0,,, -9,메콩유역 수자원 관리 기후적응 MP,수자원1부,정귀한,2025.12.31,X,X,0,,종료(예정)일 지남,준공 -10,캄보디아 반테 민체이 관개 홍수저감 MP,수자원1부,이대주,2026.08.28,"2025.12.07, 파일업로드",X,44,,, -11,캄보디아 시엠립 하수처리 개선 DD,물환경사업1부,변역근,2028.12.18,"2026.02.06, AI 요약",O,221,,, -12,키르기스스탄 잘랄아바드 상수도 계획 MP,물환경사업1부,변기상,2025.12.31,"2026.02.12, 파일업로드",X,60,,종료(예정)일 지남,준공 -13,파키스탄 펀잡 홍수 방재 PMC,수자원1부,방노성,2027.12.31,"2025.12.08, 폴더삭제",O,0,,, -14,파키스탄 KP 아보타바드 상수도 PMC,물환경사업2부,변기상,2026.12.31,"2026.02.26, 파일업로드",O,240,,, -15,파키스탄 CAREC 도로 감리 DD,도로부,황효섭,2026.10.26,X,X,0,,, -16,필리핀 홍수 복원 InFRA2 DD,수자원1부,이대주,2026.08.07,"2025.12.01, 폴더삭제",O,6,최근로그 >> 폴더자동삭제(파일 개수 미달),, -17,필리핀 홍수 관리 Package5B MP,수자원1부,이희철,2026.05.31,"2025.12.02, 폴더이름변경",O,14,,, -18,필리핀 PGN 해상교량 BID2 IDC,구조부,이상희,2026.05.31,"2026.02.11, 파일다운로드",O,631,,, -19,가나 테치만 상수도 확장 DS,물환경사업2부,-,2029.04.25,X,X,0,책임자 및 담당자 설정X,, -20,기니 벼 재배단지 PMC,수자원1부,이대주,2028.12.20,"2025.12.08, 파일업로드",O,43,최근로그 >> 폴더자동삭제(파일 개수 미달),, -21,우간다 벼 재배단지 PMC,수자원1부,방노성,2028.12.20,"2025.12.08, 파일업로드",O,52,,, -22,우간다 부수쿠마 분뇨 자원화 2단계 PMC,물환경사업2부,변기상,2026.12.31,"2026.02.05, 파일업로드",X,9,,, -23,에티오피아 지하수 관개 환경설계 DD,물환경사업2부,변기상,2026.06.23,X,X,0,,, -24,에티오피아 도도타군 관개 PMC,수자원1부,방노성,2026.12.31,"2025.12.01, 폴더이름변경",O,144,탭 1개에 모든파일 업로드 // 최근로그 >> 폴더자동삭제(파일 개수 미달),, -25,에티오피아 Adeaa-Becho 지하수 관개 MP,수자원1부,방노성,2026.07.31,"2025.11.21, 파일업로드",O,146,최근로그 >> 폴더자동삭제(파일 개수 미달),, -26,탄자니아 Iringa 상하수도 개선 CS,물환경사업1부,백운영,2029.06.08,"2026.02.03, 폴더생성",X,0,,, -27,탄자니아 Dodoma 하수 설계감리 DD,물환경사업2부,변기상,2027.07.08,"2026.02.04, 폴더삭제",X,32,,, -28,탄자니아 잔지바르 쌀 생산 PMC,수자원1부,방노성,2027.12.20,"2025.12.08, 파일 업로드",O,23,,, -29,탄자니아 도도마 유수율 상수도개선 PMC,물환경사업1부,박순석,2026.12.31,"2026.02.12, 부관리자권한추가",X,35,,, -30,아르헨티나 SALDEORO 수력발전 28MW DD,플랜트1부,양정모,2026.01.31,X,X,0,,종료(예정)일 지남,준공 -31,온두라스 LaPaz Danli 상수도 CS,물환경사업2부,-,2027.02.23,"2026.01.29, 파일 삭제",O,60,"책임자 및 담당자 설정 X, 실 관리부서는 해외사업부, 더미파일 다수",, -32,볼리비아 에스꼬마 차라짜니 도로 CS,도로부,전홍찬,2029.12.15,"2026.02.06, 파일업로드",X,1,,, -33,볼리비아 마모레 교량도로 FS,도로부,황효섭,2025.10.17,"2026.02.06, 파일업로드",X,120,,종료(예정)일 지남,준공 -34,볼리비아 Bombeo-Colomi 도로설계 DD,도로부,황효섭,2026.07.24,"2025.12.05, 파일삭제",O,48,"더미파일(폴더유지용) 12개, 실 관리부서는 해외사업부",, -35,콜롬비아 AI 폐기물 FS,플랜트1부,서재희,2026.02.27,X,X,0,,종료(예정)일 지남, -36,파라과이 도로 통행료 현대화 MP,교통계획부,오제훈,2025.10.24,"2025.02.25, 폴더삭제",X,0,,종료(예정)일 지남,준공 -37,페루 Barranca 상하수도 확장 DD,물환경사업2부,변기상,2026.03.08,"2025.11.14, 파일업로드",O,44,"더미파일(폴더유지용) 27개, 실 관리부서는 해외사업부",, -38,엘살바도르 태평양 철도 FS,철도사업부,김태헌,2025.12.31,"2026.02.24, 폴더자동삭제",X,101,,종료(예정)일 지남,준공 -39,필리핀 사무소,해외사업부,한형남,,"2026-03-09, 파일다운로드",과업개요 페이지 없음,323,,, diff --git a/db_2026.03.10.csv b/db_2026.03.10.csv deleted file mode 100644 index 4708abf..0000000 --- a/db_2026.03.10.csv +++ /dev/null @@ -1,42 +0,0 @@ -[PM Overseas 프로젝트 현황],,2026.03.04,,,,,,<<활동로그가 없는 프로젝트 (8),, -,,,,,,,,,, -No.,프로젝트 명,담당부서,담당자,종료(예정)일,최근 활동로그,과업개요 작성 유무,파일 수,비고,, -1,라오스 ITTC 관개 교육센터 PMC,수자원1부,방노성,2025.12.20,"2026.01.29, 폴더 삭제",O,16,2026.01.29 로그는 테스트 활동 추정,종료(예정)일 지남,진행 -2,라오스 비엔티안 메콩강 관리 2차 DD,수자원1부,방노성,2026.05.31,"2025.12.07, 파일업로드",X,260,탭 1개에 모든파일 업로드,, -3,미얀마 만달레이 철도 개량 감리 CS,철도사업부,김태헌,2027.11.17,"2025.11.17, 폴더이름변경",O,298,,, -4,베트남 푸옥호아 양수 발전 FS,수력부,이철호,2025.11.30,"2026.02.23, 폴더이름변경",O,139,준공도서 3월 작성예정,종료(예정)일 지남,준공 -5,사우디아라비아 아시르 지잔 고속도로 FS,도로부,공태원,2025.11.21,"2026.02.09, 파일다운로드",O,73,,종료(예정)일 지남,준공 -6,우즈베키스탄 지방 도로 복원 MP,도로부,장진영,2029.04.28,X,X,0,,, -7,우즈베키스탄 타슈켄트 철도 FS,철도사업부,김태헌,2026.03.20,"2026.02.05, 파일업로드",O,51,,, -8,이라크 Habbaniyah Shuaiba AirBase PD,도로부,강동구,2026.12.31,X,X,0,,, -9,메콩유역 수자원 관리 기후적응 MP,수자원1부,정귀한,2025.12.31,X,X,0,,종료(예정)일 지남,준공 -10,캄보디아 반테 민체이 관개 홍수저감 MP,수자원1부,이대주,2026.08.28,"2025.12.07, 파일업로드",X,44,,, -11,캄보디아 시엠립 하수처리 개선 DD,물환경사업1부,변역근,2028.12.18,"2026.02.06, AI 요약",O,221,,, -12,키르기스스탄 잘랄아바드 상수도 계획 MP,물환경사업1부,변기상,2025.12.31,"2026.02.12, 파일업로드",X,60,,종료(예정)일 지남,준공 -13,파키스탄 펀잡 홍수 방재 PMC,수자원1부,방노성,2027.12.31,"2025.12.08, 폴더삭제",O,0,,, -14,파키스탄 KP 아보타바드 상수도 PMC,물환경사업2부,변기상,2026.12.31,"2026.02.26, 파일업로드",O,240,,, -15,파키스탄 CAREC 도로 감리 DD,도로부,황효섭,2026.10.26,X,X,0,,, -16,필리핀 홍수 복원 InFRA2 DD,수자원1부,이대주,2026.08.07,"2025.12.01, 폴더삭제",O,6,최근로그 >> 폴더자동삭제(파일 개수 미달),, -17,필리핀 홍수 관리 Package5B MP,수자원1부,이희철,2026.05.31,"2025.12.02, 폴더이름변경",O,14,,, -18,필리핀 PGN 해상교량 BID2 IDC,구조부,이상희,2026.05.31,"2026.02.11, 파일다운로드",O,631,,, -19,가나 테치만 상수도 확장 DS,물환경사업2부,-,2029.04.25,X,X,0,책임자 및 담당자 설정X,, -20,기니 벼 재배단지 PMC,수자원1부,이대주,2028.12.20,"2025.12.08, 파일업로드",O,43,최근로그 >> 폴더자동삭제(파일 개수 미달),, -21,우간다 벼 재배단지 PMC,수자원1부,방노성,2028.12.20,"2025.12.08, 파일업로드",O,52,,, -22,우간다 부수쿠마 분뇨 자원화 2단계 PMC,물환경사업2부,변기상,2026.12.31,"2026.02.05, 파일업로드",X,9,,, -23,에티오피아 지하수 관개 환경설계 DD,물환경사업2부,변기상,2026.06.23,X,X,0,,, -24,에티오피아 도도타군 관개 PMC,수자원1부,방노성,2026.12.31,"2025.12.01, 폴더이름변경",O,144,탭 1개에 모든파일 업로드 // 최근로그 >> 폴더자동삭제(파일 개수 미달),, -25,에티오피아 Adeaa-Becho 지하수 관개 MP,수자원1부,방노성,2026.07.31,"2025.11.21, 파일업로드",O,146,최근로그 >> 폴더자동삭제(파일 개수 미달),, -26,탄자니아 Iringa 상하수도 개선 CS,물환경사업1부,백운영,2029.06.08,"2026.02.03, 폴더생성",X,0,,, -27,탄자니아 Dodoma 하수 설계감리 DD,물환경사업2부,변기상,2027.07.08,"2026.02.04, 폴더삭제",X,32,,, -28,탄자니아 잔지바르 쌀 생산 PMC,수자원1부,방노성,2027.12.20,"2025.12.08, 파일 업로드",O,23,,, -29,탄자니아 도도마 유수율 상수도개선 PMC,물환경사업1부,박순석,2026.12.31,"2026.02.12, 부관리자권한추가",X,35,,, -30,아르헨티나 SALDEORO 수력발전 28MW DD,플랜트1부,양정모,2026.01.31,X,X,0,,종료(예정)일 지남,준공 -31,온두라스 LaPaz Danli 상수도 CS,물환경사업2부,-,2027.02.23,"2026.01.29, 파일 삭제",O,60,"책임자 및 담당자 설정 X, 실 관리부서는 해외사업부, 더미파일 다수",, -32,볼리비아 에스꼬마 차라짜니 도로 CS,도로부,전홍찬,2029.12.15,"2026.02.06, 파일업로드",X,1,,, -33,볼리비아 마모레 교량도로 FS,도로부,황효섭,2025.10.17,"2026.02.06, 파일업로드",X,120,,종료(예정)일 지남,준공 -34,볼리비아 Bombeo-Colomi 도로설계 DD,도로부,황효섭,2026.07.24,"2025.12.05, 파일삭제",O,48,"더미파일(폴더유지용) 12개, 실 관리부서는 해외사업부",, -35,콜롬비아 AI 폐기물 FS,플랜트1부,서재희,2026.02.27,X,X,0,,종료(예정)일 지남, -36,파라과이 도로 통행료 현대화 MP,교통계획부,오제훈,2025.10.24,"2025.02.25, 폴더삭제",X,0,,종료(예정)일 지남,준공 -37,페루 Barranca 상하수도 확장 DD,물환경사업2부,변기상,2026.03.08,"2025.11.14, 파일업로드",O,44,"더미파일(폴더유지용) 27개, 실 관리부서는 해외사업부",, -38,엘살바도르 태평양 철도 FS,철도사업부,김태헌,2025.12.31,"2026.02.24, 폴더자동삭제",X,101,,종료(예정)일 지남,준공 -39,필리핀 사무소,해외사업부,한형남,,"2026.03.10, PDF 변환",과업개요 페이지 없음,829,,, diff --git a/debug_modal.html b/debug_modal.html deleted file mode 100644 index 31b4811..0000000 --- a/debug_modal.html +++ /dev/null @@ -1,226 +0,0 @@ - -
-
-
- 로그필터 - -
-
-
- 활동시간 -
- 시작 - -
-
- 종료 - -
-
-
- 사용자 -
-
모든 사용자
- - -
-
-
-
- 활동유형 -
- - -
-
- 파일 / 폴더관련 - - - - - - - - - 유저관련 - - - - 기타 - -
-
-
- -
-
- - -
- \ No newline at end of file diff --git a/diag_folders.py b/diag_folders.py deleted file mode 100644 index d63fa8e..0000000 --- a/diag_folders.py +++ /dev/null @@ -1,66 +0,0 @@ -import asyncio, os, json, re, sys -from playwright.async_api import async_playwright -from dotenv import load_dotenv - -load_dotenv() - -async def run_diagnostics(): - user_id = os.getenv("PM_USER_ID") - password = os.getenv("PM_PASSWORD") - - async with async_playwright() as p: - browser = await p.chromium.launch(headless=False) - context = await browser.new_context(viewport={"width": 1600, "height": 900}) - page = await context.new_page() - - print(">>> 로그인 중...") - await page.goto("https://overseas.projectmastercloud.com/dashboard") - if await page.locator("#login-by-id").is_visible(timeout=5000): - await page.click("#login-by-id") - await page.fill("#user_id", user_id) - await page.fill("#user_pw", password) - await page.click("#login-btn") - - await page.wait_for_selector("h4.list__contents_aria_group_body_list_item_label", timeout=60000) - - project_name = "필리핀 사무소" - print(f">>> [{project_name}] 폴더 전수 조사 시작...") - - target_el = page.get_by_text(project_name).first - await target_el.scroll_into_view_if_needed() - await target_el.click(force=True) - - await asyncio.sleep(10) # 충분한 로딩 대기 - - print("\n" + "="*60) - print(f"{'No':<4} | {'Folder Name':<40} | {'Files'}") - print("-" * 60) - - # fetch 결과를 직접 리턴받음 - tree_data = await page.evaluate("""async () => { - const resp = await fetch('/api/getTreeObject?params[storageType]=CLOUD¶ms[resourcePath]=/'); - return await resp.json(); - }""") - - if tree_data: - tree = tree_data.get('currentTreeObject', {}) - folders = tree.get('folder', {}) - folder_items = list(folders.values()) if isinstance(folders, dict) else (folders if isinstance(folders, list) else []) - - total_sum = 0 - for i, f in enumerate(folder_items): - name = f.get('name', 'Unknown') - count = int(f.get('filesCount', 0)) - total_sum += count - print(f"{i+1:<4} | {name:<40} | {count}개") - - print("-" * 60) - print(f">>> 총 {len(folder_items)}개 폴더 발견 | 전체 파일 합계: {total_sum}개") - print("="*60) - else: - print(">>> [오류] 데이터를 가져오지 못했습니다.") - - await browser.close() - -if __name__ == "__main__": - asyncio.run(run_diagnostics()) diff --git a/js/common.js b/js/common.js index 94ef6aa..accf091 100644 --- a/js/common.js +++ b/js/common.js @@ -1,9 +1,25 @@ -// 공통 네비게이션 및 유틸리티 로직 +/** + * Project Master Overseas Common JS + * 공통 네비게이션, 유틸리티, 전역 이벤트 관리 + */ + function navigateTo(path) { location.href = path; } -// 상단바 클릭 시 홈으로 이동 등 공통 이벤트 설정 -document.addEventListener('DOMContentLoaded', () => { - // 필요한 경우 공통 초기화 로직 추가 +// --- 전역 이벤트: 모든 모달창 ESC 키로 닫기 --- +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + // 대시보드 모달 + if (typeof closeAuthModal === 'function') closeAuthModal(); + if (typeof closeActivityModal === 'function') closeActivityModal(); + + // 메일 시스템 모달 + if (typeof closeModal === 'function') closeModal(); + if (typeof closeAddressBook === 'function') closeAddressBook(); + } +}); + +document.addEventListener('DOMContentLoaded', () => { + // 공통 초기화 로직 }); diff --git a/js/dashboard.js b/js/dashboard.js index a870fca..41daaa9 100644 --- a/js/dashboard.js +++ b/js/dashboard.js @@ -1,295 +1,220 @@ +/** + * Project Master Overseas Dashboard JS + * 기능: 데이터 로드, 활성도 분석, 인증 모달 제어, 크롤링 동기화 및 중단 + */ + +// --- 글로벌 상태 관리 --- let rawData = []; +let projectActivityDetails = []; +let isCrawling = false; -const continentMap = { - "라오스": "아시아", "미얀마": "아시아", "베트남": "아시아", "사우디아라비아": "아시아", - "우즈베키스탄": "아시아", "이라크": "아시아", "캄보디아": "아시아", - "키르기스스탄": "아시아", "파키스탄": "아시아", "필리핀": "아시아", - "아르헨티나": "아메리카", "온두라스": "아메리카", "볼리비아": "아메리카", "콜롬비아": "아메리카", - "파라과이": "아메리카", "페루": "아메리카", "엘살바도르": "아메리카", - "가나": "아프리카", "기니": "아프리카", "우간다": "아프리카", "에티오피아": "아프리카", "탄자니아": "아프리카" -}; - -const continentOrder = { - "아시아": 1, - "아프리카": 2, - "아메리카": 3, - "지사": 4 -}; +const CONTINENT_ORDER = { "아시아": 1, "아프리카": 2, "아메리카": 3, "지사": 4 }; +// --- 초기화 --- async function init() { + console.log("Dashboard Initializing..."); const container = document.getElementById('projectAccordion'); - const baseDateStrong = document.getElementById('baseDate'); - if (!container) return; + if (!container) return; - // 1. 가용한 날짜 목록 가져오기 및 셀렉트 박스 생성 + await loadAvailableDates(); + await loadDataByDate(); +} + +// --- 데이터 통신 및 로드 --- +async function loadAvailableDates() { try { - const datesRes = await fetch('/available-dates'); - const dates = await datesRes.json(); - - if (dates && dates.length > 0) { - let selectHtml = ``; - // 기준날짜 텍스트 영역을 셀렉트 박스로 교체 - const baseDateInfo = document.querySelector('.base-date-info'); - if (baseDateInfo) { - baseDateInfo.innerHTML = `기준날짜: ${selectHtml}`; - } + const response = await fetch('/available-dates'); + const dates = await response.json(); + if (dates?.length > 0) { + const selectHtml = ` + `; + const baseDateStrong = document.getElementById('baseDate'); + if (baseDateStrong) baseDateStrong.innerHTML = selectHtml; } - } catch (e) { - console.error("날짜 목록 로드 실패:", e); - } - - // 2. 기본 데이터 로드 (최신 날짜) - loadDataByDate(); + } catch (e) { console.error("날짜 로드 실패:", e); } } async function loadDataByDate(selectedDate = "") { - const container = document.getElementById('projectAccordion'); - try { - const url = selectedDate ? `/project-data?date=${selectedDate}` : `/project-data?t=${new Date().getTime()}`; + await loadActivityAnalysis(selectedDate); + const url = selectedDate ? `/project-data?date=${selectedDate}` : `/project-data?t=${Date.now()}`; const response = await fetch(url); const data = await response.json(); - if (data.error) throw new Error(data.error); - rawData = data.projects || []; renderDashboard(rawData); - } catch (e) { console.error("데이터 로드 실패:", e); alert("데이터를 가져오는 데 실패했습니다."); } } +async function loadActivityAnalysis(date = "") { + const dashboard = document.getElementById('activityDashboard'); + if (!dashboard) return; + try { + const url = date ? `/project-activity?date=${date}` : `/project-activity`; + const response = await fetch(url); + const data = await response.json(); + if (data.error) return; + const { summary, details } = data; + projectActivityDetails = details; + dashboard.innerHTML = ` +
+
정상 (7일 이내)
${summary.active}
+
+
+
주의 (14일 이내)
${summary.warning}
+
+
+
방치 (14일 초과)
${summary.stale}
+
+
+
데이터 없음 (파일 0개 등)
${summary.unknown}
+
`; + } catch (e) { console.error("분석 로드 실패:", e); } +} + +// --- 렌더링 엔진 --- function renderDashboard(data) { const container = document.getElementById('projectAccordion'); - container.innerHTML = ''; // 초기화 - const groupedData = {}; - - data.forEach((item, index) => { - let continent = item[5] || "기기타"; - let country = item[6] || "미분류"; - - if (!groupedData[continent]) groupedData[continent] = {}; - if (!groupedData[continent][country]) groupedData[continent][country] = []; - - groupedData[continent][country].push({ item, index }); - }); - - const sortedContinents = Object.keys(groupedData).sort((a, b) => (continentOrder[a] || 99) - (continentOrder[b] || 99)); - - sortedContinents.forEach(continent => { - const continentGroup = document.createElement('div'); - continentGroup.className = 'continent-group'; - - let continentHtml = ` -
- ${continent} - -
-
- `; - - const sortedCountries = Object.keys(groupedData[continent]).sort((a, b) => a.localeCompare(b)); - - sortedCountries.forEach(country => { - continentHtml += ` -
-
- ${country} - -
-
-
-
-
프로젝트명
-
담당부서
-
담당자
-
파일수
-
최근로그
-
- `; - - const sortedProjects = groupedData[continent][country].sort((a, b) => a.item[0].localeCompare(b.item[0])); - - sortedProjects.forEach(({ item, index }) => { - const projectName = item[0]; - const dept = item[1]; - const admin = item[2]; - const recentLogRaw = item[3]; - const fileCount = item[4]; - - const recentLog = recentLogRaw === "X" ? "기록 없음" : recentLogRaw; - const logTime = recentLog !== "기록 없음" ? recentLog.split(',')[0] : "기록 없음"; - - let statusClass = ""; - if (fileCount === 0) statusClass = "status-error"; - else if (recentLog === "기록 없음") statusClass = "status-warning"; - - continentHtml += ` -
-
-
${projectName}
-
${dept}
-
${admin}
-
${fileCount}
-
${recentLog}
-
-
-
-
-

참여 인원 상세

- - - - - - - - -
이름소속사용자권한
${admin}${dept}관리자
김철수${dept}부관리자
박지민${dept}일반참여자
최유리${dept}참관자
-
-
-

최근 문의사항 및 파일 변경 로그

- - - - - - - -
유형내용일시
로그데이터 동기화 완료${logTime}
문의프로젝트 접근 권한 요청2026-02-23
파일설계도면 v2.pdf 업로드2026-02-22
-
-
-
-
- `; - }); - - continentHtml += ` -
-
-
- `; + container.innerHTML = ''; + const grouped = groupData(data); + Object.keys(grouped).sort((a,b) => (CONTINENT_ORDER[a]||99) - (CONTINENT_ORDER[b]||99)).forEach(continent => { + const continentDiv = document.createElement('div'); + continentDiv.className = 'continent-group active'; + let html = `
${continent}
`; + Object.keys(grouped[continent]).sort().forEach(country => { + html += `
${country}
+
프로젝트명
담당부서
담당자
파일수
최근로그
+ ${grouped[continent][country].sort((a,b)=>a[0].localeCompare(b[0])).map(p => createProjectHtml(p)).join('')}
`; }); + html += `
`; + continentDiv.innerHTML = html; + container.appendChild(continentDiv); + }); +} - continentHtml += ` +function groupData(data) { + const res = {}; + data.forEach(item => { + const c1 = item[5] || "기타", c2 = item[6] || "미분류"; + if (!res[c1]) res[c1] = {}; + if (!res[c1][c2]) res[c1][c2] = []; + res[c1][c2].push(item); + }); + return res; +} + +function createProjectHtml(p) { + const [name, dept, admin, logRaw, files] = p; + const recentLog = (!logRaw || logRaw === "X" || logRaw === "데이터 없음") ? "기록 없음" : logRaw; + const logTime = recentLog !== "기록 없음" ? recentLog.split(',')[0] : "기록 없음"; + const statusClass = (files === 0 || files === null) ? "status-error" : (recentLog === "기록 없음") ? "status-warning" : ""; + return ` +
+
+
${name}
${dept}
${admin}
${files||0}
${recentLog}
- `; - - continentGroup.innerHTML = continentHtml; - container.appendChild(continentGroup); - }); - - const allContinents = container.querySelectorAll('.continent-group'); - allContinents.forEach(continent => { - continent.classList.add('active'); - }); - - const allCountries = container.querySelectorAll('.country-group'); - allCountries.forEach(country => { - country.classList.add('active'); - }); +
+
+

참여 인원 상세

이름소속권한
${admin}${dept}관리자
+

최근 활동

유형내용일시
로그동기화 완료${logTime}
+
+
+
`; } -function toggleGroup(header) { - const group = header.parentElement; - group.classList.toggle('active'); -} - -function toggleAccordion(header) { - const item = header.parentElement; - const container = item.parentElement; - - const allItems = container.querySelectorAll('.accordion-item'); - allItems.forEach(el => { - if (el !== item) el.classList.remove('active'); - }); - +// --- 이벤트 핸들러 --- +function toggleGroup(h) { h.parentElement.classList.toggle('active'); } +function toggleAccordion(h) { + const item = h.parentElement; + item.parentElement.querySelectorAll('.accordion-item').forEach(el => { if(el!==item) el.classList.remove('active'); }); item.classList.toggle('active'); } -async function syncData() { - const btn = document.getElementById('syncBtn'); - const logConsole = document.getElementById('logConsole'); - const logBody = document.getElementById('logBody'); +function showActivityDetails(status) { + const modal = document.getElementById('activityDetailModal'), tbody = document.getElementById('modalTableBody'), title = document.getElementById('modalTitle'); + const names = { active:'정상', warning:'주의', stale:'방치', unknown:'데이터 없음' }; + const filtered = (projectActivityDetails || []).filter(d => d.status === status); + title.innerText = `${names[status]} 목록 (${filtered.length}개)`; + tbody.innerHTML = filtered.map(p => { + const o = rawData.find(r => r[0] === p.name); + return `${p.name}${o?o[1]:"-"}${o?o[2]:"-"}`; + }).join(''); + modal.style.display = 'flex'; +} - btn.classList.add('loading'); - btn.innerHTML = ` 동기화 중 (진행 상황 확인 중...)`; - btn.disabled = true; +function closeActivityModal() { document.getElementById('activityDetailModal').style.display = 'none'; } - logConsole.style.display = 'block'; - logBody.innerHTML = ''; - - function addLog(msg) { - const logItem = document.createElement('div'); - logItem.innerText = `[${new Date().toLocaleTimeString()}] ${msg}`; - logBody.appendChild(logItem); - logConsole.scrollTop = logConsole.scrollHeight; - } - - try { - console.log("Attempting to connect to /sync..."); - const response = await fetch(`/sync`); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - const chunk = decoder.decode(value); - const lines = chunk.split('\n'); - - for (const line of lines) { - if (line.startsWith('data: ')) { - const payload = JSON.parse(line.substring(6)); - - if (payload.type === 'log') { - addLog(payload.message); - } else if (payload.type === 'done') { - const newData = payload.data; - newData.forEach(scrapedItem => { - const target = rawData.find(item => - item[0].replace(/\s/g, '').includes(scrapedItem.projectName.replace(/\s/g, '')) || - scrapedItem.projectName.replace(/\s/g, '').includes(item[0].replace(/\s/g, '')) - ); - - if (target) { - if (scrapedItem.recentLog !== "기존데이터유지") { - target[3] = scrapedItem.recentLog; - } - target[4] = scrapedItem.fileCount; - } - }); - - document.getElementById('projectAccordion').innerHTML = ''; - init(); - addLog(">>> 모든 동기화 작업이 완료되었습니다!"); - alert(`총 ${newData.length}개 프로젝트 동기화 완료!`); - logConsole.style.display = 'none'; - } - } - } - } - } catch (e) { - addLog(`오류 발생: ${e.message}`); - alert("서버 연결 실패. 백엔드 서버가 실행 중인지 확인하세요."); - console.error(e); - } finally { - btn.classList.remove('loading'); - btn.innerHTML = ` 데이터 동기화 (크롤링)`; - btn.disabled = false; +function scrollToProject(name) { + closeActivityModal(); + const target = Array.from(document.querySelectorAll('.repo-title')).find(t => t.innerText.trim() === name.trim())?.closest('.accordion-header'); + if (target) { + let p = target.parentElement; + while (p && p !== document.body) { if (p.classList.contains('continent-group') || p.classList.contains('country-group')) p.classList.add('active'); p = p.parentElement; } + target.parentElement.classList.add('active'); + const pos = target.getBoundingClientRect().top + window.pageYOffset - 220; + window.scrollTo({ top: pos, behavior: 'smooth' }); + target.style.backgroundColor = 'var(--primary-lv-1)'; + setTimeout(() => target.style.backgroundColor = '', 2000); } } +// --- 크롤링 및 인증 제어 --- +async function syncData() { + if (isCrawling) { + if (confirm("크롤링을 중단하시겠습니까?")) { + const res = await fetch('/stop-sync'); + if ((await res.json()).success) document.getElementById('syncBtn').innerText = "중단 요청 중..."; + } + return; + } + const modal = document.getElementById('authModal'); + if (modal) { + document.getElementById('authId').value = ''; document.getElementById('authPw').value = ''; + document.getElementById('authErrorMessage').style.display = 'none'; + modal.style.display = 'flex'; document.getElementById('authId').focus(); + } +} + +function closeAuthModal() { document.getElementById('authModal').style.display = 'none'; } + +async function submitAuth() { + const id = document.getElementById('authId').value, pw = document.getElementById('authPw').value, err = document.getElementById('authErrorMessage'); + try { + const res = await fetch('/auth/crawl', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({user_id:id, password:pw}) }); + const data = await res.json(); + if (data.success) { closeAuthModal(); startCrawlProcess(); } + else { err.innerText = "크롤링을 할 수 없습니다."; err.style.display = 'block'; } + } catch { err.innerText = "서버 연결 실패"; err.style.display = 'block'; } +} + +async function startCrawlProcess() { + isCrawling = true; + const btn = document.getElementById('syncBtn'), logC = document.getElementById('logConsole'), logB = document.getElementById('logBody'); + btn.classList.add('loading'); btn.style.backgroundColor = 'var(--error-color)'; btn.innerHTML = ` 크롤링 중단`; + logC.style.display = 'block'; logB.innerHTML = '
>>> 엔진 초기화 중...
'; + try { + const res = await fetch(`/sync`); + const reader = res.body.getReader(), decoder = new TextDecoder(); + while (true) { + const { done, value } = await reader.read(); if (done) break; + decoder.decode(value).split('\n').forEach(line => { + if (line.startsWith('data: ')) { + const p = JSON.parse(line.substring(6)); + if (p.type === 'log') { + const div = document.createElement('div'); div.innerText = `[${new Date().toLocaleTimeString()}] ${p.message}`; + logB.appendChild(div); logC.scrollTop = logC.scrollHeight; + } else if (p.type === 'done') { init(); alert(`동기화 종료`); logC.style.display = 'none'; } + } + }); + } + } catch { alert("스트림 끊김"); } + finally { isCrawling = false; btn.classList.remove('loading'); btn.style.backgroundColor = ''; btn.innerHTML = ` 데이터 동기화 (크롤링)`; } +} + document.addEventListener('DOMContentLoaded', init); diff --git a/log_debug.png b/log_debug.png deleted file mode 100644 index dcb29f1..0000000 Binary files a/log_debug.png and /dev/null differ diff --git a/migrate_db_history.py b/migrate_db_history.py index 054d09b..b77629e 100644 --- a/migrate_db_history.py +++ b/migrate_db_history.py @@ -4,7 +4,7 @@ import os def get_db(): return pymysql.connect( host='localhost', user='root', password='45278434', - database='crawling', charset='utf8mb4' + database=os.getenv('DB_NAME', 'PM_proto'), charset='utf8mb4' ) def migrate_to_timeseries(): diff --git a/migrate_normalized.py b/migrate_normalized.py index 4649db4..b8cc930 100644 --- a/migrate_normalized.py +++ b/migrate_normalized.py @@ -4,7 +4,7 @@ import os def get_db(): return pymysql.connect( host='localhost', user='root', password='45278434', - database='crawling', charset='utf8mb4', + database=os.getenv('DB_NAME', 'PM_proto'), charset='utf8mb4', cursorclass=pymysql.cursors.DictCursor ) diff --git a/server.py b/server.py index de2b8d0..fd96d9e 100644 --- a/server.py +++ b/server.py @@ -1,37 +1,33 @@ import os import sys - -# 한글 환경 및 Tesseract 경로 강제 설정 -os.environ["PYTHONIOENCODING"] = "utf-8" -os.environ["TESSDATA_PREFIX"] = r"C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tessdata" - -from fastapi import FastAPI +import re +import asyncio +import pymysql +from datetime import datetime +from pydantic import BaseModel +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse, FileResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates + from analyze import analyze_file_content -from crawler_service import run_crawler_service -import asyncio -from fastapi import Request +from crawler_service import run_crawler_service, crawl_stop_event + +# --- 환경 설정 --- +os.environ["PYTHONIOENCODING"] = "utf-8" +# Tesseract 경로는 환경에 따라 다를 수 있으므로 환경변수 우선 사용 권장 +TESSDATA_PREFIX = os.getenv("TESSDATA_PREFIX", r"C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tessdata") +os.environ["TESSDATA_PREFIX"] = TESSDATA_PREFIX app = FastAPI(title="Project Master Overseas API") templates = Jinja2Templates(directory="templates") -# --- 유틸리티: 동기 함수를 스레드 풀에서 실행 --- -async def run_in_threadpool(func, *args): - loop = asyncio.get_event_loop() - return await loop.run_in_executor(None, func, *args) - -# 정적 파일 및 미들웨어 설정 +# 정적 파일 마운트 app.mount("/style", StaticFiles(directory="style"), name="style") app.mount("/js", StaticFiles(directory="js"), name="js") app.mount("/sample_files", StaticFiles(directory="sample"), name="sample_files") -@app.get("/sample.png") -async def get_sample_img(): - return FileResponse("sample.png") - app.add_middleware( CORSMiddleware, allow_origins=["*"], @@ -40,88 +36,29 @@ app.add_middleware( allow_headers=["*"], ) +# --- 데이터 모델 --- +class AuthRequest(BaseModel): + user_id: str + password: str -# --- HTML 라우팅 --- -import pymysql - +# --- 유틸리티 함수 --- def get_db_connection(): + """MySQL 데이터베이스 연결을 반환 (환경변수 기반)""" return pymysql.connect( - host='localhost', - user='root', - password='45278434', - database='crawling', + host=os.getenv('DB_HOST', 'localhost'), + user=os.getenv('DB_USER', 'root'), + password=os.getenv('DB_PASSWORD', '45278434'), + database=os.getenv('DB_NAME', 'PM_proto'), charset='utf8mb4', cursorclass=pymysql.cursors.DictCursor ) -@app.get("/available-dates") -async def get_available_dates(): - """ - 히스토리 테이블에서 유니크한 크롤링 날짜 목록을 반환 - """ - try: - conn = get_db_connection() - try: - with conn.cursor() as cursor: - cursor.execute("SELECT DISTINCT crawl_date FROM projects_history ORDER BY crawl_date DESC") - rows = cursor.fetchall() - dates = [row['crawl_date'].strftime("%Y.%m.%d") for row in rows if row['crawl_date']] - return dates - finally: - conn.close() - except Exception as e: - return {"error": str(e)} - -@app.get("/project-data") -async def get_project_data(date: str = None): - """ - 특정 날짜의 데이터를 JOIN하여 반환 - """ - try: - conn = get_db_connection() - try: - with conn.cursor() as cursor: - if not date or date == "-": - cursor.execute("SELECT MAX(crawl_date) as last_date FROM projects_history") - target_date_row = cursor.fetchone() - target_date = target_date_row['last_date'] - else: - target_date = date.replace(".", "-") - - if not target_date: - return {"projects": [], "last_updated": "-"} - - # 마스터 정보와 히스토리 정보를 JOIN - sql = """ - SELECT m.project_nm, m.short_nm, m.department, m.master, - h.recent_log, h.file_count, m.continent, m.country - FROM projects_master m - JOIN projects_history h ON m.project_id = h.project_id - WHERE h.crawl_date = %s - ORDER BY m.project_id ASC - """ - cursor.execute(sql, (target_date,)) - rows = cursor.fetchall() - - projects = [] - for row in rows: - display_name = row['short_nm'] if row['short_nm'] and row['short_nm'].strip() else row['project_nm'] - projects.append([ - display_name, - row['department'], - row['master'], - row['recent_log'], - row['file_count'], - row['continent'], - row['country'] - ]) - - return {"projects": projects, "last_updated": target_date.strftime("%Y.%m.%d") if hasattr(target_date, 'strftime') else str(target_date).replace("-", ".")} - finally: - conn.close() - except Exception as e: - return {"error": str(e)} +async def run_in_threadpool(func, *args): + """동기 함수를 비차단 방식으로 실행""" + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, func, *args) +# --- HTML 라우팅 --- @app.get("/") async def root(request: Request): return templates.TemplateResponse("index.html", {"request": request}) @@ -131,37 +68,119 @@ async def get_dashboard(request: Request): return templates.TemplateResponse("dashboard.html", {"request": request}) @app.get("/mailTest") -@app.get("/mailTest.html") async def get_mail_test(request: Request): return templates.TemplateResponse("mailTest.html", {"request": request}) -# --- 데이터 API --- -@app.get("/attachments") -async def get_attachments(): - sample_path = "sample" - if not os.path.exists(sample_path): - os.makedirs(sample_path) - files = [] - for f in os.listdir(sample_path): - f_path = os.path.join(sample_path, f) - if os.path.isfile(f_path): - files.append({ - "name": f, - "size": f"{os.path.getsize(f_path) / 1024:.1f} KB" - }) - return files +# --- 분석 및 수집 API --- +@app.get("/available-dates") +async def get_available_dates(): + """히스토리 날짜 목록 반환""" + try: + with get_db_connection() as conn: + with conn.cursor() as cursor: + cursor.execute("SELECT DISTINCT crawl_date FROM projects_history ORDER BY crawl_date DESC") + rows = cursor.fetchall() + return [row['crawl_date'].strftime("%Y.%m.%d") for row in rows if row['crawl_date']] + except Exception as e: + return {"error": str(e)} -@app.get("/analyze-file") -async def analyze_file(filename: str): - """ - 분석 서비스(analyze.py) 호출 - 스레드 풀에서 비차단 방식으로 실행 - """ - return await run_in_threadpool(analyze_file_content, filename) +@app.get("/project-data") +async def get_project_data(date: str = None): + """특정 날짜의 프로젝트 정보 JOIN 반환""" + try: + target_date = date.replace(".", "-") if date and date != "-" else None + with get_db_connection() as conn: + with conn.cursor() as cursor: + if not target_date: + cursor.execute("SELECT MAX(crawl_date) as last_date FROM projects_history") + res = cursor.fetchone() + target_date = res['last_date'] + + if not target_date: return {"projects": []} + + sql = """ + SELECT m.project_nm, m.short_nm, m.department, m.master, + h.recent_log, h.file_count, m.continent, m.country + FROM projects_master m + JOIN projects_history h ON m.project_id = h.project_id + WHERE h.crawl_date = %s ORDER BY m.project_id ASC + """ + cursor.execute(sql, (target_date,)) + rows = cursor.fetchall() + + projects = [] + for r in rows: + name = r['short_nm'] if r['short_nm'] and r['short_nm'].strip() else r['project_nm'] + projects.append([name, r['department'], r['master'], r['recent_log'], r['file_count'], r['continent'], r['country']]) + return {"projects": projects} + except Exception as e: + return {"error": str(e)} + +@app.get("/project-activity") +async def get_project_activity(date: str = None): + """활성도 분석 API""" + try: + with get_db_connection() as conn: + with conn.cursor() as cursor: + if not date or date == "-": + cursor.execute("SELECT MAX(crawl_date) as last_date FROM projects_history") + res = cursor.fetchone() + target_date_val = res['last_date'] if res['last_date'] else datetime.now().date() + else: + target_date_val = datetime.strptime(date.replace(".", "-"), "%Y-%m-%d").date() + + target_date_dt = datetime.combine(target_date_val, datetime.min.time()) + sql = """ + SELECT m.project_id, m.project_nm, m.short_nm, h.recent_log, h.file_count + FROM projects_master m + LEFT JOIN projects_history h ON m.project_id = h.project_id AND h.crawl_date = %s + """ + cursor.execute(sql, (target_date_val,)) + rows = cursor.fetchall() + + analysis = {"summary": {"active": 0, "warning": 0, "stale": 0, "unknown": 0}, "details": []} + for r in rows: + log, files = r['recent_log'], r['file_count'] + status, days = "unknown", 999 + if log and log != "데이터 없음" and files and files > 0: + match = re.search(r'(\d{4})\.(\d{2})\.(\d{2})', log) + if match: + diff = (target_date_dt - datetime.strptime(match.group(0), "%Y.%m.%d")).days + status = "active" if diff <= 7 else "warning" if diff <= 14 else "stale" + days = diff + analysis["summary"][status] += 1 + analysis["details"].append({"name": r['short_nm'] or r['project_nm'], "status": status, "days_ago": days}) + return analysis + except Exception as e: + return {"error": str(e)} + +@app.post("/auth/crawl") +async def auth_crawl(req: AuthRequest): + """크롤링 인증""" + if req.user_id == os.getenv("PM_USER_ID") and req.password == os.getenv("PM_PASSWORD"): + return {"success": True} + return {"success": False, "message": "크롤링을 할 수 없습니다."} @app.get("/sync") async def sync_data(): - """ - 크롤링 서비스(crawler_service.py) 호출 - """ - print(">>> /sync request received") return StreamingResponse(run_crawler_service(), media_type="text_event-stream") + +@app.get("/stop-sync") +async def stop_sync(): + crawl_stop_event.set() + return {"success": True} + +@app.get("/attachments") +async def get_attachments(): + path = "sample" + if not os.path.exists(path): os.makedirs(path) + return [{"name": f, "size": f"{os.path.getsize(os.path.join(path, f))/1024:.1f} KB"} + for f in os.listdir(path) if os.path.isfile(os.path.join(path, f))] + +@app.get("/analyze-file") +async def analyze_file(filename: str): + return await run_in_threadpool(analyze_file_content, filename) + +@app.get("/sample.png") +async def get_sample_img(): + return FileResponse("sample.png") diff --git a/server_reboot.log b/server_reboot.log deleted file mode 100644 index 8a8f47f..0000000 Binary files a/server_reboot.log and /dev/null differ diff --git a/server_revert.log b/server_revert.log deleted file mode 100644 index 9554606..0000000 Binary files a/server_revert.log and /dev/null differ diff --git a/server_startup.log b/server_startup.log deleted file mode 100644 index 6432ab7..0000000 Binary files a/server_startup.log and /dev/null differ diff --git a/sheet.csv b/sheet.csv deleted file mode 100644 index 775627a..0000000 --- a/sheet.csv +++ /dev/null @@ -1,42 +0,0 @@ -[PM Overseas 프로젝트 현황],,2026.03.04,,,,,,<<활동로그가 없는 프로젝트 (8),, -,,,,,,,,,, -No.,프로젝트 명,담당부서,담당자,종료(예정)일,최근 활동로그,과업개요 작성 유무,파일 수,비고,, -1,라오스 ITTC 관개 교육센터 PMC,수자원1부,방노성,2025.12.20,"2026.01.29, 폴더 삭제",O,16,2026.01.29 로그는 테스트 활동 추정,종료(예정)일 지남,진행 -2,라오스 비엔티안 메콩강 관리 2차 DD,수자원1부,방노성,2026.05.31,"2025.12.07, 파일업로드",X,260,탭 1개에 모든파일 업로드,, -3,미얀마 만달레이 철도 개량 감리 CS,철도사업부,김태헌,2027.11.17,"2025.11.17, 폴더이름변경",O,298,,, -4,베트남 푸옥호아 양수 발전 FS,수력부,이철호,2025.11.30,"2026.02.23, 폴더이름변경",O,139,준공도서 3월 작성예정,종료(예정)일 지남,준공 -5,사우디아라비아 아시르 지잔 고속도로 FS,도로부,공태원,2025.11.21,"2026.02.09, 파일다운로드",O,73,,종료(예정)일 지남,준공 -6,우즈베키스탄 지방 도로 복원 MP,도로부,장진영,2029.04.28,X,X,0,,, -7,우즈베키스탄 타슈켄트 철도 FS,철도사업부,김태헌,2026.03.20,"2026.02.05, 파일업로드",O,51,,, -8,이라크 Habbaniyah Shuaiba AirBase PD,도로부,강동구,2026.12.31,X,X,0,,, -9,메콩유역 수자원 관리 기후적응 MP,수자원1부,정귀한,2025.12.31,X,X,0,,종료(예정)일 지남,준공 -10,캄보디아 반테 민체이 관개 홍수저감 MP,수자원1부,이대주,2026.08.28,"2025.12.07, 파일업로드",X,44,,, -11,캄보디아 시엠립 하수처리 개선 DD,물환경사업1부,변역근,2028.12.18,"2026.02.06, AI 요약",O,221,,, -12,키르기스스탄 잘랄아바드 상수도 계획 MP,물환경사업1부,변기상,2025.12.31,"2026.02.12, 파일업로드",X,60,,종료(예정)일 지남,준공 -13,파키스탄 펀잡 홍수 방재 PMC,수자원1부,방노성,2027.12.31,"2025.12.08, 폴더삭제",O,0,,, -14,파키스탄 KP 아보타바드 상수도 PMC,물환경사업2부,변기상,2026.12.31,"2026.02.26, 파일업로드",O,240,,, -15,파키스탄 CAREC 도로 감리 DD,도로부,황효섭,2026.10.26,X,X,0,,, -16,필리핀 홍수 복원 InFRA2 DD,수자원1부,이대주,2026.08.07,"2025.12.01, 폴더삭제",O,6,최근로그 >> 폴더자동삭제(파일 개수 미달),, -17,필리핀 홍수 관리 Package5B MP,수자원1부,이희철,2026.05.31,"2025.12.02, 폴더이름변경",O,14,,, -18,필리핀 PGN 해상교량 BID2 IDC,구조부,이상희,2026.05.31,"2026.02.11, 파일다운로드",O,631,,, -19,가나 테치만 상수도 확장 DS,물환경사업2부,-,2029.04.25,X,X,0,책임자 및 담당자 설정X,, -20,기니 벼 재배단지 PMC,수자원1부,이대주,2028.12.20,"2025.12.08, 파일업로드",O,43,최근로그 >> 폴더자동삭제(파일 개수 미달),, -21,우간다 벼 재배단지 PMC,수자원1부,방노성,2028.12.20,"2025.12.08, 파일업로드",O,52,,, -22,우간다 부수쿠마 분뇨 자원화 2단계 PMC,물환경사업2부,변기상,2026.12.31,"2026.02.05, 파일업로드",X,9,,, -23,에티오피아 지하수 관개 환경설계 DD,물환경사업2부,변기상,2026.06.23,X,X,0,,, -24,에티오피아 도도타군 관개 PMC,수자원1부,방노성,2026.12.31,"2025.12.01, 폴더이름변경",O,144,탭 1개에 모든파일 업로드 // 최근로그 >> 폴더자동삭제(파일 개수 미달),, -25,에티오피아 Adeaa-Becho 지하수 관개 MP,수자원1부,방노성,2026.07.31,"2025.11.21, 파일업로드",O,146,최근로그 >> 폴더자동삭제(파일 개수 미달),, -26,탄자니아 Iringa 상하수도 개선 CS,물환경사업1부,백운영,2029.06.08,"2026.02.03, 폴더생성",X,0,,, -27,탄자니아 Dodoma 하수 설계감리 DD,물환경사업2부,변기상,2027.07.08,"2026.02.04, 폴더삭제",X,32,,, -28,탄자니아 잔지바르 쌀 생산 PMC,수자원1부,방노성,2027.12.20,"2025.12.08, 파일 업로드",O,23,,, -29,탄자니아 도도마 유수율 상수도개선 PMC,물환경사업1부,박순석,2026.12.31,"2026.02.12, 부관리자권한추가",X,35,,, -30,아르헨티나 SALDEORO 수력발전 28MW DD,플랜트1부,양정모,2026.01.31,X,X,0,,종료(예정)일 지남,준공 -31,온두라스 LaPaz Danli 상수도 CS,물환경사업2부,-,2027.02.23,"2026.01.29, 파일 삭제",O,60,"책임자 및 담당자 설정 X, 실 관리부서는 해외사업부, 더미파일 다수",, -32,볼리비아 에스꼬마 차라짜니 도로 CS,도로부,전홍찬,2029.12.15,"2026.02.06, 파일업로드",X,1,,, -33,볼리비아 마모레 교량도로 FS,도로부,황효섭,2025.10.17,"2026.02.06, 파일업로드",X,120,,종료(예정)일 지남,준공 -34,볼리비아 Bombeo-Colomi 도로설계 DD,도로부,황효섭,2026.07.24,"2025.12.05, 파일삭제",O,48,"더미파일(폴더유지용) 12개, 실 관리부서는 해외사업부",, -35,콜롬비아 AI 폐기물 FS,플랜트1부,서재희,2026.02.27,X,X,0,,종료(예정)일 지남, -36,파라과이 도로 통행료 현대화 MP,교통계획부,오제훈,2025.10.24,"2025.02.25, 폴더삭제",X,0,,종료(예정)일 지남,준공 -37,페루 Barranca 상하수도 확장 DD,물환경사업2부,변기상,2026.03.08,"2025.11.14, 파일업로드",O,44,"더미파일(폴더유지용) 27개, 실 관리부서는 해외사업부",, -38,엘살바도르 태평양 철도 FS,철도사업부,김태헌,2025.12.31,"2026.02.24, 폴더자동삭제",X,101,,종료(예정)일 지남,준공 -39,필리핀 사무소,해외사업부,한형남,,"2026.03.04, 파일다운로드",과업개요 페이지 없음,817,,, \ No newline at end of file diff --git a/style/dashboard.css b/style/dashboard.css index 611bbef..38ce943 100644 --- a/style/dashboard.css +++ b/style/dashboard.css @@ -1,91 +1,72 @@ +:root { + --topbar-h: 36px; + --header-h: 56px; + --activity-h: 110px; + --fixed-total-h: calc(var(--topbar-h) + var(--header-h) + var(--activity-h)); + + --primary-color: #1E5149; + --primary-lv-0: #f0f7f4; + --primary-lv-1: #e1eee9; + --border-color: #e5e7eb; + --bg-muted: #F9FAFB; + --text-main: #111827; + --text-sub: #6B7280; + --error-color: #F21D0D; +} + /* Portal (Index) */ .portal-container { display: flex; flex-direction: column; align-items: center; justify-content: center; - height: calc(100vh - 36px); + height: calc(100vh - var(--topbar-h)); background: var(--bg-muted); - padding: var(--space-lg); - margin-top: 36px; + padding: 32px; + margin-top: var(--topbar-h); } -.portal-header { - text-align: center; - margin-bottom: 50px; -} +.portal-header { text-align: center; margin-bottom: 50px; } +.portal-header h1 { font-size: 28px; color: var(--primary-color); margin-bottom: 10px; font-weight: 800; } +.portal-header p { color: var(--text-sub); font-size: 15px; } +.button-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 30px; width: 100%; max-width: 800px; } +.portal-card { background: #fff; border: 1px solid var(--border-color); border-radius: 12px; padding: 40px; text-align: center; transition: all 0.3s ease; width: 100%; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); display: flex; flex-direction: column; align-items: center; gap: 20px; cursor: pointer; text-decoration: none; } +.portal-card:hover { transform: translateY(-8px); border-color: var(--primary-color); box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); } +.portal-card i { font-size: 48px; color: var(--primary-color); } +.portal-card h3 { font-size: 20px; color: var(--text-main); margin: 0; } +.portal-card p { font-size: 14px; color: var(--text-sub); margin: 0; } -.portal-header h1 { - font-size: 28px; - color: var(--primary-color); - margin-bottom: 10px; -} - -.portal-header p { - color: var(--text-sub); - font-size: 15px; -} - -.button-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 30px; - width: 100%; - max-width: 800px; -} - -.portal-card { - background: #fff; - border: 1px solid var(--border-color); - border-radius: 12px; - padding: 40px; - text-align: center; - transition: 0.3s; - width: 100%; - box-shadow: var(--box-shadow); - display: flex; - flex-direction: column; - align-items: center; - gap: 20px; -} - -.portal-card:hover { - transform: translateY(-5px); - border-color: var(--primary-color); - box-shadow: var(--box-shadow-lg); -} - -/* Dashboard List & Console */ +/* Dashboard Layout */ header { - position: fixed; - top: 36px; - left: 0; - right: 0; - z-index: 1000; - background: #fff; - display: flex; - justify-content: space-between; - align-items: center; - padding: var(--space-md) var(--space-lg); - border-bottom: 1px solid var(--border-color); - box-shadow: 0 2px 4px rgba(0,0,0,0.05); + position: fixed; top: var(--topbar-h); left: 0; right: 0; z-index: 1001; + background: #fff; height: var(--header-h); display: flex; justify-content: space-between; align-items: center; padding: 0 32px; border-bottom: 1px solid #f5f5f5; } -.main-content { - margin-top: 100px; - padding: var(--space-lg); - max-width: 1400px; - margin-left: auto; - margin-right: auto; +.activity-dashboard-wrapper { + position: fixed; top: calc(var(--topbar-h) + var(--header-h)); left: 0; right: 0; z-index: 1000; + background: #fff; height: var(--activity-h); border-bottom: 1px solid var(--border-color); box-shadow: 0 4px 6px rgba(0,0,0,0.03); } +.activity-dashboard { max-width: 1200px; margin: 0 auto; height: 100%; display: flex; gap: 15px; padding: 10px 32px 20px 32px; } +.activity-card { flex: 1; padding: 12px 15px; border-radius: 8px; cursor: pointer; transition: all 0.2s ease; display: flex; flex-direction: column; justify-content: center; gap: 2px; border-left: 5px solid transparent; } +.activity-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.08); } +.activity-card.active { background: #e8f5e9; border-left-color: #4DB251; } +.activity-card.warning { background: #fff8e1; border-left-color: #FFBF00; } +.activity-card.stale { background: #ffebee; border-left-color: #F21D0D; } +.activity-card.unknown { background: #f5f5f5; border-left-color: #9e9e9e; } +.activity-card .label { font-size: 11px; font-weight: 600; opacity: 0.7; } +.activity-card .count { font-size: 20px; font-weight: 800; } + +.main-content { margin-top: var(--fixed-total-h); padding: 32px; max-width: 1400px; margin-left: auto; margin-right: auto; } + +/* 로그 콘솔 - 초기 디자인 복구 (Sticky Terminal 스타일) */ .log-console { position: sticky; - top: 100px; + top: var(--fixed-total-h); z-index: 999; background: #000; color: #0f0; - font-family: monospace; + font-family: 'Consolas', 'Monaco', monospace; padding: 15px; margin-bottom: 20px; border-radius: 4px; @@ -104,212 +85,72 @@ header { font-weight: bold; } -.accordion-container { - border-top: 1px solid var(--border-color); +/* 모달 정중앙 배치 */ +.activity-modal-overlay { + position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; + background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(4px); z-index: 3000; + display: flex; align-items: center; justify-content: center; padding: 20px; } -.accordion-list-header, -.accordion-header { - display: grid; - grid-template-columns: 2.5fr 1fr 1fr 0.8fr 2fr; - gap: var(--space-md); - padding: var(--space-md) var(--space-lg); - align-items: center; - cursor: pointer; +.activity-modal-content { + background: #fff; width: 600px; max-height: 85vh; border-radius: 12px; + display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 25px 50px -12px rgba(0,0,0,0.5); } -.accordion-list-header { - position: sticky; - top: 100px; - background: var(--bg-muted); - z-index: 10; - font-size: 11px; - font-weight: 700; - color: var(--text-sub); - border-bottom: 1px solid var(--text-main); - cursor: default; +.auth-modal-content { + background: #fff; width: 400px; border-radius: 16px; padding: 40px; + text-align: center; box-shadow: 0 25px 50px -12px rgba(0,0,0,0.5); + display: flex; flex-direction: column; gap: 25px; } -.accordion-item { - border-bottom: 1px solid var(--border-color); -} +.modal-header { padding: 20px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; } +.modal-header h3 { margin: 0; font-size: 16px; color: var(--primary-color); } +.close-btn { background: none; border: none; font-size: 24px; cursor: pointer; color: var(--text-sub); } +.modal-body { padding: 20px; overflow-y: auto; } +.modal-row { cursor: pointer; border-bottom: 1px solid #f5f5f5; } +.modal-row:hover { background: var(--primary-lv-0); } -.accordion-item:hover { - background: var(--primary-lv-0); -} +/* 인증 모달 내부 요소 */ +.auth-header i { font-size: 40px; color: var(--primary-color); margin-bottom: 15px; } +.auth-header h3 { font-size: 22px; color: var(--text-main); margin: 0; font-weight: 800; } +.auth-header p { font-size: 14px; color: var(--text-sub); margin-top: 8px; } +.auth-body { display: flex; flex-direction: column; gap: 15px; text-align: left; } +.input-group { display: flex; flex-direction: column; gap: 6px; } +.input-group label { font-size: 12px; font-weight: 700; color: var(--text-sub); } +.input-group input { padding: 12px 16px; border: 1px solid var(--border-color); border-radius: 8px; font-size: 14px; transition: 0.2s; } +.input-group input:focus { outline: none; border-color: var(--primary-color); box-shadow: 0 0 0 3px var(--primary-lv-1); } +.error-text { color: var(--error-color); font-size: 13px; font-weight: 600; margin-top: 10px; text-align: center; } +.auth-footer { display: flex; gap: 10px; margin-top: 10px; } +.auth-footer button { flex: 1; padding: 12px; border-radius: 8px; font-weight: 700; cursor: pointer; transition: 0.2s; border: none; } +.cancel-btn { background: #f3f4f6; color: var(--text-sub); } +.login-btn { background: var(--primary-color); color: #fff; } -.repo-title { - font-weight: 700; - color: var(--primary-color); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} +/* Accordion Layout */ +.accordion-list-header { position: sticky; top: var(--fixed-total-h); background: #fff; z-index: 900; font-size: 11px; font-weight: 700; color: var(--text-sub); padding: 14px 24px; border-bottom: 2px solid var(--primary-color); box-shadow: 0 4px 10px rgba(0,0,0,0.05); display: grid; grid-template-columns: 2.5fr 1fr 1fr 0.8fr 2fr; gap: 16px; align-items: center; } +.accordion-header { display: grid; grid-template-columns: 2.5fr 1fr 1fr 0.8fr 2fr; gap: 16px; padding: 16px 24px; align-items: center; cursor: pointer; border-bottom: 1px solid var(--border-color); transition: background 0.1s; } +.accordion-item:hover .accordion-header { background: var(--primary-lv-0); } +.repo-title { font-weight: 700; color: var(--primary-color); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.repo-dept, .repo-admin { font-size: 12px; color: var(--text-main); } +.repo-files { text-align: center; font-weight: 600; } +.repo-log { font-size: 11px; color: var(--text-sub); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.accordion-body { display: none; padding: 24px; background: var(--bg-muted); border-bottom: 1px solid var(--border-color); } +.accordion-item.active .accordion-body { display: block; } +.status-warning { background: #fffcf0; } +.status-error { background: #fff5f4; } +.warning-text { color: var(--error-color) !important; font-weight: 700; } -.repo-files { - text-align: center; - font-weight: 600; -} +.continent-group, .country-group { margin-bottom: 15px; } +.continent-header, .country-header { background: #fff; padding: 14px 20px; border: 1px solid var(--border-color); border-radius: 8px; display: flex; justify-content: space-between; align-items: center; cursor: pointer; font-weight: 700; } +.continent-header { background: var(--primary-color); color: white; border: none; font-size: 15px; } +.country-header { font-size: 14px; color: var(--text-main); margin-top: 8px; } +.continent-body, .country-body { display: none; padding: 10px 0 10px 15px; } +.active>.continent-body, .active>.country-body { display: block; } -.repo-log { - font-size: 11px; - color: var(--text-sub); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.accordion-body { - display: none; - padding: var(--space-lg); - background: var(--bg-muted); - border-top: 1px solid var(--border-color); -} - -.accordion-item.active .accordion-body { - display: block; -} - -.status-warning { - background: #fff9e6; -} - -.status-error { - background: #fee9e7; -} - -.warning-text { - color: #f21d0d !important; - font-weight: 700; -} - -/* Multi-level Groups */ -.continent-group, -.country-group { - margin-bottom: 10px; -} - -.continent-header, -.country-header { - background: #fff; - padding: 12px 20px; - border: 1px solid var(--border-color); - border-radius: 8px; - display: flex; - justify-content: space-between; - align-items: center; - cursor: pointer; - font-weight: 700; - transition: all 0.2s; -} - -.continent-header { - background: var(--primary-color); - color: white; - border: none; - font-size: 15px; -} - -.country-header { - font-size: 14px; - color: var(--text-main); - margin-top: 5px; -} - -.continent-body, -.country-body { - display: none; - padding: 10px 0 10px 20px; -} - -.active>.continent-body, -.active>.country-body { - display: block; -} - -/* Detail Views */ -.detail-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 20px; -} - -.detail-section h4 { - font-size: 13px; - margin-bottom: 10px; - color: var(--text-main); - border-left: 3px solid var(--primary-color); - padding-left: 8px; -} - -.data-table { - width: 100%; - border-collapse: collapse; - font-size: 12px; -} - -.data-table th, -.data-table td { - padding: 8px; - border-bottom: 1px solid var(--border-color); - text-align: left; -} - -.data-table th { - color: var(--text-sub); - font-weight: 600; -} - -/* Sync Button & Admin Info */ -.sync-btn { - display: flex; - align-items: center; - gap: var(--space-sm); - background-color: var(--primary-color); - color: #fff; - padding: 8px 16px; - border-radius: var(--radius-lg); - font-size: 13px; - font-weight: 600; - cursor: pointer; - border: none; - box-shadow: var(--box-shadow); -} - -.sync-btn:hover { - background-color: var(--primary-lv-8); -} - -.sync-btn.loading .spinner { - display: inline-block; -} - -.admin-info { - font-size: 13px; - color: var(--text-sub); - margin-left: var(--space-md); - padding: 6px 12px; - background: var(--bg-muted); - border-radius: var(--radius-sm); - border: 1px solid var(--border-color); -} - -.admin-info strong { - color: var(--primary-color); - font-weight: 700; -} - -.base-date-info { - font-size: 13px; - color: var(--text-sub); - background: #f8f9fa; - padding: 6px 15px; - border-radius: 6px; - border: 1px solid var(--border-color); -} - -.base-date-info strong { - color: #333; - font-weight: 700; - margin-left: 5px; -} +.data-table { width: 100%; border-collapse: collapse; font-size: 12px; } +.data-table th, .data-table td { padding: 10px 8px; border-bottom: 1px solid var(--border-color); text-align: left; } +.data-table th { color: var(--text-sub); font-weight: 600; background: #fcfcfc; } +.sync-btn { display: flex; align-items: center; gap: 8px; background-color: var(--primary-color); color: #fff; padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 600; cursor: pointer; border: none; transition: 0.2s; } +.admin-info { font-size: 12px; color: var(--text-sub); margin-left: 16px; padding: 6px 12px; background: #f8f9fa; border-radius: 4px; border: 1px solid var(--border-color); } +.admin-info strong { color: var(--primary-color); font-weight: 700; } +.base-date-info { font-size: 13px; color: var(--text-sub); background: #fdfdfd; padding: 6px 15px; border-radius: 6px; border: 1px solid var(--border-color); } +.base-date-info strong { color: #333; font-weight: 700; } diff --git a/templates/dashboard.html b/templates/dashboard.html index debd3a6..96fca23 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -42,7 +42,14 @@
- + +
+
+ +
+
+ + + + + + + - \ No newline at end of file + diff --git a/test_main_filtered.py b/test_main_filtered.py deleted file mode 100644 index ceed5ba..0000000 --- a/test_main_filtered.py +++ /dev/null @@ -1,33 +0,0 @@ -import asyncio, os, json, queue, threading -from crawler_service import crawler_thread_worker -from dotenv import load_dotenv - -load_dotenv() - -async def run_main_test(): - user_id = os.getenv("PM_USER_ID") - password = os.getenv("PM_PASSWORD") - msg_queue = queue.Queue() - - thread = threading.Thread(target=crawler_thread_worker, args=(msg_queue, user_id, password)) - thread.start() - - print(">>> 메인 워커 실행 중 (필리핀 사무소)...") - try: - while True: - msg_raw = await asyncio.to_thread(msg_queue.get, timeout=300) - if msg_raw is None: break - - msg = json.loads(msg_raw) - if msg["type"] == "log": - print(f"[LOG] {msg['message']}") - elif msg["type"] == "done": - print(f"\n[DONE] 최종 결과: {msg['data']}") - break - except queue.Empty: - print(">>> 타임아웃") - finally: - thread.join() - -if __name__ == "__main__": - asyncio.run(run_main_test())