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
|
import traceback
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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 playwright.async_api import async_playwright
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
@@ -13,6 +14,9 @@ load_dotenv()
|
|||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
|
# Mount static files (css, images etc)
|
||||||
|
app.mount("/style", StaticFiles(directory="style"), name="style")
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
@@ -21,6 +25,10 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def get_dashboard():
|
||||||
|
return FileResponse("dashboard.html")
|
||||||
|
|
||||||
@app.get("/sync")
|
@app.get("/sync")
|
||||||
async def sync_data():
|
async def sync_data():
|
||||||
async def event_generator():
|
async def event_generator():
|
||||||
@@ -31,14 +39,19 @@ async def sync_data():
|
|||||||
yield f"data: {json.dumps({'type': 'log', 'message': '오류: .env 파일에 계정 정보가 없습니다.'})}\n\n"
|
yield f"data: {json.dumps({'type': 'log', 'message': '오류: .env 파일에 계정 정보가 없습니다.'})}\n\n"
|
||||||
return
|
return
|
||||||
|
|
||||||
TIMEOUT_MS = 600000
|
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
async with async_playwright() as p:
|
async with async_playwright() as p:
|
||||||
yield f"data: {json.dumps({'type': 'log', 'message': '브라우저 실행 중...'})}\n\n"
|
yield f"data: {json.dumps({'type': 'log', 'message': '브라우저 실행 중...'})}\n\n"
|
||||||
browser = await p.chromium.launch(headless=True, args=["--no-sandbox", "--disable-dev-shm-usage"])
|
browser = await p.chromium.launch(headless=True, args=[
|
||||||
context = await browser.new_context(viewport={'width': 1920, 'height': 1080})
|
"--no-sandbox",
|
||||||
context.set_default_timeout(60000)
|
"--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()
|
page = await context.new_page()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -62,98 +75,126 @@ async def sync_data():
|
|||||||
proj = page.locator("h4.list__contents_aria_group_body_list_item_label").nth(i)
|
proj = page.locator("h4.list__contents_aria_group_body_list_item_label").nth(i)
|
||||||
project_name = (await proj.inner_text()).strip()
|
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.scroll_into_view_if_needed()
|
||||||
await proj.click(force=True)
|
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
|
file_count = 0
|
||||||
|
|
||||||
# 1단계: 활동로그 수집
|
# 1단계: 활동로그 수집 (Gitea 방식 복구 + 정밀 셀렉터)
|
||||||
try:
|
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):
|
if await log_btn.is_visible(timeout=5000):
|
||||||
await log_btn.click()
|
yield f"data: {json.dumps({'type': 'log', 'message': ' - [로그] 창 열기 시도...'})}\n\n"
|
||||||
await page.wait_for_timeout(2000) # 로그 로딩 여유
|
await log_btn.click(force=True)
|
||||||
log_content = page.locator("div.wrap.log-wrap .content-area").first
|
await asyncio.sleep(5) # 로딩 충분히 대기
|
||||||
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()
|
|
||||||
|
|
||||||
|
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
|
popup_page = None
|
||||||
for _ in range(20):
|
for _ in range(30): # 최대 15초 대기
|
||||||
for p_item in context.pages:
|
for p_item in context.pages:
|
||||||
if "composition-tab.html" in p_item.url:
|
try:
|
||||||
popup_page = p_item
|
if "composition" in p_item.url:
|
||||||
break
|
popup_page = p_item
|
||||||
|
break
|
||||||
|
except: pass
|
||||||
if popup_page: break
|
if popup_page: break
|
||||||
await asyncio.sleep(0.5)
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
if popup_page:
|
if popup_page:
|
||||||
yield f"data: {json.dumps({'type': 'log', 'message': f'[{i+1}/{count}] 구성 데이터 로딩 대기 중 (여유있게)...'})}\n\n"
|
yield f"data: {json.dumps({'type': 'log', 'message': ' - [구성] 창 발견. 데이터 로딩 대기 (최대 80초)...'})}\n\n"
|
||||||
await popup_page.wait_for_load_state("domcontentloaded")
|
target_selector = "#composition-list h6"
|
||||||
|
success_comp = False
|
||||||
|
|
||||||
# 데이터가 로드될 때까지 점진적으로 대기 (최대 7초)
|
# 최대 80초간 끝까지 대기
|
||||||
for _ in range(7):
|
for _ in range(80):
|
||||||
h6_check = popup_page.locator("#composition-list li h6:nth-child(3)")
|
h6_count = await popup_page.locator(target_selector).count()
|
||||||
if await h6_check.count() > 0:
|
if h6_count > 5: # 일정 개수 이상의 목록이 나타나면 로딩 시작으로 간주
|
||||||
|
success_comp = True
|
||||||
break
|
break
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
# 최종 데이터를 가져오기 전 마지막 2초 추가 대기 (완전한 렌더링 확인)
|
if success_comp:
|
||||||
await asyncio.sleep(2)
|
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 popup_page.close()
|
||||||
await page.bring_to_front()
|
else:
|
||||||
|
yield f"data: {json.dumps({'type': 'log', 'message': ' - [구성] 팝업창 발견 실패'})}\n\n"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
yield f"data: {json.dumps({'type': 'log', 'message': f'!!! 구성 수집 지연: {str(e)[:30]}'})}\n\n"
|
yield f"data: {json.dumps({'type': 'log', 'message': f' - [구성] 오류: {str(e)[:20]}'})}\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"
|
|
||||||
|
|
||||||
results.append({"projectName": project_name, "recentLog": recent_log, "fileCount": file_count})
|
results.append({"projectName": project_name, "recentLog": recent_log, "fileCount": file_count})
|
||||||
|
|
||||||
# 3단계: 복귀
|
# 홈 복귀
|
||||||
home_btn = page.locator("div.header div.title div").first
|
await page.locator("div.header div.title div").first.click(force=True)
|
||||||
try:
|
await page.wait_for_selector("h4.list__contents_aria_group_body_list_item_label", timeout=20000)
|
||||||
await home_btn.click(force=True, timeout=10000)
|
await asyncio.sleep(2)
|
||||||
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)
|
|
||||||
|
|
||||||
except Exception as e_proj:
|
except Exception:
|
||||||
yield f"data: {json.dumps({'type': 'log', 'message': f'!!! {i+1}번째 프로젝트 실패 (건너뜀)'})}\n\n"
|
await page.goto("https://overseas.projectmastercloud.com/dashboard", wait_until="domcontentloaded")
|
||||||
await page.goto("https://overseas.projectmastercloud.com/", wait_until="domcontentloaded")
|
|
||||||
await page.wait_for_timeout(3000)
|
|
||||||
|
|
||||||
yield f"data: {json.dumps({'type': 'done', 'data': results})}\n\n"
|
yield f"data: {json.dumps({'type': 'done', 'data': results})}\n\n"
|
||||||
|
|
||||||
|
|||||||
@@ -306,8 +306,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const apiHost = window.location.hostname;
|
const response = await fetch(`/sync`);
|
||||||
const response = await fetch(`http://${apiHost}:8001/sync`);
|
|
||||||
|
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
@@ -334,7 +333,10 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (target) {
|
if (target) {
|
||||||
target[3] = scrapedItem.recentLog;
|
// 기존 데이터 유지 마커 확인
|
||||||
|
if (scrapedItem.recentLog !== "기존데이터유지") {
|
||||||
|
target[3] = scrapedItem.recentLog;
|
||||||
|
}
|
||||||
target[4] = scrapedItem.fileCount;
|
target[4] = scrapedItem.fileCount;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user