Improve crawler reliability and update project management rules
This commit is contained in:
@@ -21,7 +21,7 @@
|
||||
- 프로젝트 참여 인원 현황 조회
|
||||
- 사용자별 권한(관리자, 분석가, 뷰어) 부여 및 수정 기능
|
||||
|
||||
### ⑤ 공지사항 (Notice & Patch Notes)
|
||||
### ③ 공지사항 (Notice & Patch Notes)
|
||||
- 분석 모델 업데이트, 시스템 점검, 패치 내역 공유
|
||||
- 사용자 대상 공지사항 작성 및 게시판 관리
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Gitea MCP 연동 프로젝트 (Initial Integration)
|
||||
# 프로젝트 관리 규칙
|
||||
|
||||
본 저장소는 Gemini CLI와 Gitea의 성공적인 MCP(Model Context Protocol) 통합을 통해 구축된 최초의 프로젝트 공간입니다. 인공지능과 형상 관리 시스템의 유기적인 결합을 통해 실현될 혁신적인 개발 워크플로우의 시작을 기념합니다.
|
||||
|
||||
새로운 차원의 지능형 협업 여정에 동참해주셔서 대단히 반갑습니다.
|
||||
1. **언어 설정**: 영어로 생각하되, 모든 답변은 한국어로 작성한다. (일본어, 중국어는 절대 사용하지 않는다.)
|
||||
2. **수정 권한 제한**: 사용자가 명시적으로 지시한 사항 외에는 **절대 절대 절대** 코드를 임의로 수정하지 않는다.
|
||||
3. **로그 기록 철저**: 모달 오픈 여부, 수집 성공/실패 여부 등 진행 상황을 실시간 로그에 상세히 표시한다.
|
||||
|
||||
185
crawler_api.py
185
crawler_api.py
@@ -5,7 +5,8 @@ import json
|
||||
import traceback
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi.responses import StreamingResponse, FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from playwright.async_api import async_playwright
|
||||
from dotenv import load_dotenv
|
||||
|
||||
@@ -13,6 +14,9 @@ load_dotenv()
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# Mount static files (css, images etc)
|
||||
app.mount("/style", StaticFiles(directory="style"), name="style")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
@@ -21,6 +25,10 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@app.get("/")
|
||||
async def get_dashboard():
|
||||
return FileResponse("dashboard.html")
|
||||
|
||||
@app.get("/sync")
|
||||
async def sync_data():
|
||||
async def event_generator():
|
||||
@@ -31,14 +39,19 @@ async def sync_data():
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': '오류: .env 파일에 계정 정보가 없습니다.'})}\n\n"
|
||||
return
|
||||
|
||||
TIMEOUT_MS = 600000
|
||||
results = []
|
||||
|
||||
async with async_playwright() as p:
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': '브라우저 실행 중...'})}\n\n"
|
||||
browser = await p.chromium.launch(headless=True, args=["--no-sandbox", "--disable-dev-shm-usage"])
|
||||
context = await browser.new_context(viewport={'width': 1920, 'height': 1080})
|
||||
context.set_default_timeout(60000)
|
||||
browser = await p.chromium.launch(headless=True, args=[
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-blink-features=AutomationControlled"
|
||||
])
|
||||
context = await browser.new_context(
|
||||
viewport={'width': 1920, 'height': 1080},
|
||||
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"
|
||||
)
|
||||
page = await context.new_page()
|
||||
|
||||
try:
|
||||
@@ -62,98 +75,126 @@ async def sync_data():
|
||||
proj = page.locator("h4.list__contents_aria_group_body_list_item_label").nth(i)
|
||||
project_name = (await proj.inner_text()).strip()
|
||||
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': f'[{i+1}/{count}] {project_name} - 수집 시작...'})}\n\n"
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': f'[{i+1}/{count}] {project_name} - 시작'})}\n\n"
|
||||
await proj.scroll_into_view_if_needed()
|
||||
await proj.click(force=True)
|
||||
await page.wait_for_selector("div.footer", timeout=20000)
|
||||
|
||||
recent_log = "없음"
|
||||
# 프로젝트 로딩 대기 (Gitea 방식: 물리적 대기)
|
||||
await asyncio.sleep(5)
|
||||
await page.wait_for_selector("div.footer", state="visible", timeout=20000)
|
||||
|
||||
recent_log = "기존데이터유지"
|
||||
file_count = 0
|
||||
|
||||
# 1단계: 활동로그 수집
|
||||
# 1단계: 활동로그 수집 (Gitea 방식 복구 + 정밀 셀렉터)
|
||||
try:
|
||||
log_btn = page.locator("div.wrap.log-wrap > div.title.text").first
|
||||
log_btn_sel = "body > div.footer > div.left > div.wrap.log-wrap > div.title.text"
|
||||
log_btn = page.locator(log_btn_sel).first
|
||||
if await log_btn.is_visible(timeout=5000):
|
||||
await log_btn.click()
|
||||
await page.wait_for_timeout(2000) # 로그 로딩 여유
|
||||
log_content = page.locator("div.wrap.log-wrap .content-area").first
|
||||
if await log_content.is_visible(timeout=5000):
|
||||
content = await log_content.inner_text()
|
||||
lines = [l.strip() for l in content.split("\n") if len(l.strip()) > 2]
|
||||
if lines: recent_log = lines[0]
|
||||
await page.locator("body > article.archive-modal div.close").first.click()
|
||||
await page.wait_for_timeout(1000)
|
||||
except: pass
|
||||
|
||||
# 2단계: 구성(파일 수) 수집 - 안정성 대폭 강화
|
||||
try:
|
||||
sitemap_btn = page.locator("div.wrap.site-map-wrap > div").first
|
||||
if await sitemap_btn.is_visible(timeout=5000):
|
||||
await sitemap_btn.click()
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': ' - [로그] 창 열기 시도...'})}\n\n"
|
||||
await log_btn.click(force=True)
|
||||
await asyncio.sleep(5) # 로딩 충분히 대기
|
||||
|
||||
modal_sel = "article.archive-modal"
|
||||
if await page.locator(modal_sel).is_visible():
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': ' - [로그] 모달 발견. 데이터 추출 중...'})}\n\n"
|
||||
# 사용자 제공 정밀 셀렉터 기반 추출
|
||||
date_sel = "body > article.archive-modal > div > div > div.modal-body > div.log-wrap > div.log-item-wrap.log-body.scrollbar.scroll-container > div.date > div.text"
|
||||
user_sel = "body > article.archive-modal > div > div > div.modal-body > div.log-wrap > div.log-item-wrap.log-body.scrollbar.scroll-container > div.user > div.text"
|
||||
act_sel = "body > article.archive-modal > div > div > div.modal-body > div.log-wrap > div.log-item-wrap.log-body.scrollbar.scroll-container > div.activity > div.text"
|
||||
|
||||
# 데이터가 나타날 때까지 반복 대기
|
||||
success_log = False
|
||||
for _ in range(10):
|
||||
if await page.locator(date_sel).count() > 0:
|
||||
raw_date = (await page.locator(date_sel).first.inner_text()).strip()
|
||||
if raw_date and "활동시간" not in raw_date:
|
||||
success_log = True
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
|
||||
if success_log:
|
||||
user_name = (await page.locator(user_sel).first.inner_text()).strip()
|
||||
activity = (await page.locator(act_sel).first.inner_text()).strip()
|
||||
formatted_date = re.sub(r'[-/]', '.', raw_date)[:10]
|
||||
recent_log = f"{formatted_date}, {user_name}, {activity}"
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': f' - [로그] 성공: {recent_log[:30]}...'})}\n\n"
|
||||
else:
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': ' - [로그] 데이터 추출 실패'})}\n\n"
|
||||
|
||||
await page.click("article.archive-modal div.close", timeout=3000)
|
||||
await asyncio.sleep(1.5)
|
||||
except Exception as e:
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': f' - [로그] 오류: {str(e)[:20]}'})}\n\n"
|
||||
|
||||
# 2단계: 구성(파일 수) 수집 (Gitea 순회 방식 복구 + 대기 시간 대폭 연장)
|
||||
try:
|
||||
sitemap_btn_sel = "body > div.footer > div.left > div.wrap.site-map-wrap"
|
||||
sitemap_btn = page.locator(sitemap_btn_sel).first
|
||||
if await sitemap_btn.is_visible(timeout=5000):
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': ' - [구성] 진입 시도...'})}\n\n"
|
||||
await sitemap_btn.click(force=True)
|
||||
|
||||
# Gitea 방식: context.pages 직접 뒤져서 팝업 찾기
|
||||
popup_page = None
|
||||
for _ in range(20):
|
||||
for _ in range(30): # 최대 15초 대기
|
||||
for p_item in context.pages:
|
||||
if "composition-tab.html" in p_item.url:
|
||||
popup_page = p_item
|
||||
break
|
||||
try:
|
||||
if "composition" in p_item.url:
|
||||
popup_page = p_item
|
||||
break
|
||||
except: pass
|
||||
if popup_page: break
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
if popup_page:
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': f'[{i+1}/{count}] 구성 데이터 로딩 대기 중 (여유있게)...'})}\n\n"
|
||||
await popup_page.wait_for_load_state("domcontentloaded")
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': ' - [구성] 창 발견. 데이터 로딩 대기 (최대 80초)...'})}\n\n"
|
||||
target_selector = "#composition-list h6"
|
||||
success_comp = False
|
||||
|
||||
# 데이터가 로드될 때까지 점진적으로 대기 (최대 7초)
|
||||
for _ in range(7):
|
||||
h6_check = popup_page.locator("#composition-list li h6:nth-child(3)")
|
||||
if await h6_check.count() > 0:
|
||||
# 최대 80초간 끝까지 대기
|
||||
for _ in range(80):
|
||||
h6_count = await popup_page.locator(target_selector).count()
|
||||
if h6_count > 5: # 일정 개수 이상의 목록이 나타나면 로딩 시작으로 간주
|
||||
success_comp = True
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# 최종 데이터를 가져오기 전 마지막 2초 추가 대기 (완전한 렌더링 확인)
|
||||
await asyncio.sleep(2)
|
||||
if success_comp:
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': ' - [구성] 데이터 감지됨. 15초간 최종 렌더링 대기...'})}\n\n"
|
||||
await asyncio.sleep(15) # 완전한 로딩을 위한 강제 대기
|
||||
|
||||
# 유연한 데이터 수집
|
||||
locators_h6 = popup_page.locator(target_selector)
|
||||
h6_count = await locators_h6.count()
|
||||
current_total = 0
|
||||
for j in range(h6_count):
|
||||
text = (await locators_h6.nth(j).inner_text()).strip()
|
||||
nums = re.findall(r'\d+', text.split('\n')[-1])
|
||||
if nums:
|
||||
val = int(nums[0])
|
||||
if val < 5000: current_total += val
|
||||
|
||||
file_count = current_total
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': f' - [구성] 성공 ({file_count}개)'})}\n\n"
|
||||
else:
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': ' - [구성] 로딩 타임아웃'})}\n\n"
|
||||
|
||||
target_h6_locators = popup_page.locator("#composition-list li h6:nth-child(3)")
|
||||
h6_count = await target_h6_locators.count()
|
||||
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': f'[{i+1}/{count}] 총 {h6_count}개의 항목 로드됨. 합산 중...'})}\n\n"
|
||||
|
||||
current_total = 0
|
||||
for j in range(h6_count):
|
||||
text = (await target_h6_locators.nth(j).inner_text()).strip()
|
||||
last_line = text.split('\n')[-1]
|
||||
nums = re.findall(r'\d+', last_line)
|
||||
if nums:
|
||||
val = int(nums[0])
|
||||
if val < 10000: # 1만개 미만만 합산 (연도 필터링)
|
||||
current_total += val
|
||||
|
||||
file_count = current_total
|
||||
await popup_page.close()
|
||||
await page.bring_to_front()
|
||||
else:
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': ' - [구성] 팝업창 발견 실패'})}\n\n"
|
||||
except Exception as e:
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': f'!!! 구성 수집 지연: {str(e)[:30]}'})}\n\n"
|
||||
|
||||
summary_msg = f"[{i+1}/{count}] 수집 완료 - 파일: {file_count}개, 최근로그: {recent_log[:40]}..."
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': summary_msg})}\n\n"
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': f' - [구성] 오류: {str(e)[:20]}'})}\n\n"
|
||||
|
||||
results.append({"projectName": project_name, "recentLog": recent_log, "fileCount": file_count})
|
||||
|
||||
# 3단계: 복귀
|
||||
home_btn = page.locator("div.header div.title div").first
|
||||
try:
|
||||
await home_btn.click(force=True, timeout=10000)
|
||||
await page.wait_for_selector("h4.list__contents_aria_group_body_list_item_label", timeout=20000)
|
||||
except:
|
||||
await page.goto("https://overseas.projectmastercloud.com/dashboard", wait_until="domcontentloaded")
|
||||
await page.wait_for_selector("h4.list__contents_aria_group_body_list_item_label", timeout=20000)
|
||||
await page.wait_for_timeout(1500)
|
||||
# 홈 복귀
|
||||
await page.locator("div.header div.title div").first.click(force=True)
|
||||
await page.wait_for_selector("h4.list__contents_aria_group_body_list_item_label", timeout=20000)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
except Exception as e_proj:
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': f'!!! {i+1}번째 프로젝트 실패 (건너뜀)'})}\n\n"
|
||||
await page.goto("https://overseas.projectmastercloud.com/", wait_until="domcontentloaded")
|
||||
await page.wait_for_timeout(3000)
|
||||
except Exception:
|
||||
await page.goto("https://overseas.projectmastercloud.com/dashboard", wait_until="domcontentloaded")
|
||||
|
||||
yield f"data: {json.dumps({'type': 'done', 'data': results})}\n\n"
|
||||
|
||||
|
||||
@@ -306,8 +306,7 @@
|
||||
}
|
||||
|
||||
try {
|
||||
const apiHost = window.location.hostname;
|
||||
const response = await fetch(`http://${apiHost}:8001/sync`);
|
||||
const response = await fetch(`/sync`);
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
@@ -334,7 +333,10 @@
|
||||
);
|
||||
|
||||
if (target) {
|
||||
target[3] = scrapedItem.recentLog;
|
||||
// 기존 데이터 유지 마커 확인
|
||||
if (scrapedItem.recentLog !== "기존데이터유지") {
|
||||
target[3] = scrapedItem.recentLog;
|
||||
}
|
||||
target[4] = scrapedItem.fileCount;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user