크롤링 새로고침 강건성 개선: 목록 재확인 + 상세 재시도 로직 + 설정 가능한 재확인 시간
Ultraworked with Sisyphus Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
272a1e5f53
commit
84363383ab
|
|
@ -0,0 +1,34 @@
|
|||
## 2026-02-19 Initialization
|
||||
- Decision log initialized.
|
||||
|
||||
## 2026-02-19 Expose crawling.recheck_hours in Settings UI
|
||||
|
||||
### Decision
|
||||
Added UI control and persistence for `recheck_hours` in SettingsDialog to expose previously invisible setting.
|
||||
|
||||
### Changes Made
|
||||
1. **settings_dialog.py:_init_crawl_tab()** (lines 142-147)
|
||||
- Added ComboBox for "상세 재수집 주기 (시간)" with options [1,2,3,6,12,24]
|
||||
- Loads from `settings['crawling']['recheck_hours']` with default 3
|
||||
- Positioned after unchecked_interval control for logical UI flow
|
||||
|
||||
2. **settings_dialog.py:save_all()** (line 417)
|
||||
- Added persistence: `settings['crawling']['recheck_hours'] = int(self.combo_recheck_hours.get())`
|
||||
- Placed with other crawling settings (after interval_minutes)
|
||||
|
||||
3. **test_settings_dialog_update_policy.py**
|
||||
- Added `recheck_hours: 3` to initial controller.settings
|
||||
- Added `combo_recheck_hours = _DummyField("6")` test field
|
||||
- Added assertion: `self.assertEqual(crawling_settings["recheck_hours"], 6)`
|
||||
|
||||
### Why This Approach
|
||||
- **Default fallback (3 hours)** ensures backward compatibility
|
||||
- **ComboBox values** match expected scheduler time scales
|
||||
- **Test verifies persistence** through save_all() flow
|
||||
- **No changes to controller/scheduler** - they already use the key
|
||||
- **Consistent with existing pattern** (interval_minutes control)
|
||||
|
||||
### Verified
|
||||
- Unit test passes: `test_save_all_persists_update_policy_fields`
|
||||
- Value correctly loaded from settings
|
||||
- Value correctly saved on save_all()
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
## 2026-02-19 Initialization
|
||||
- Issue log initialized.
|
||||
|
||||
## 2026-02-19 Implement Meaningful recheck_hours Behavior
|
||||
|
||||
### Issue
|
||||
`get_posts_needing_detail(recheck_hours)` received parameter but ignored it—only checking for empty content.
|
||||
Scheduler unconditionally skipped `is_public=0` posts, preventing detail collection for relevant non-public items.
|
||||
|
||||
### Implementation Details
|
||||
|
||||
#### 1. database.py: get_posts_needing_detail()
|
||||
**Lines 258-288**
|
||||
- **Before**: Only selected posts with empty content, ordered by is_public DESC (hardcoded)
|
||||
- **After**:
|
||||
- SELECT posts where: (content IS NULL/empty) OR (updated_at < threshold_time)
|
||||
- threshold_time = now - recheck_hours interval
|
||||
- ORDER BY is_related DESC, id DESC (prioritizes related + latest, not public status)
|
||||
- Respects recheck_hours setting from crawling.recheck_hours (default 3 hours)
|
||||
|
||||
**SQL Logic**:
|
||||
```sql
|
||||
WHERE (content IS NULL OR content = '')
|
||||
OR (updated_at IS NOT NULL AND updated_at < ?)
|
||||
ORDER BY is_related DESC, id DESC LIMIT 10
|
||||
```
|
||||
|
||||
#### 2. scheduler_manager.py: _collect_detail_content()
|
||||
**Lines 305-368**
|
||||
- **Before**: Hard-skip all `is_public=0` with unconditional continue
|
||||
- **After**: Decision tree:
|
||||
- **Related posts** (is_related=1): Attempt detail regardless of is_public (차량 context matters)
|
||||
- **Public posts** (is_public=1): Attempt detail
|
||||
- **Non-public + non-related**: Skip (prevents pointless permission errors)
|
||||
- Failures from fetch_detail_content (timeout, permission denied, etc.) logged at DEBUG level (not treated as errors)
|
||||
- Maintains 1.2s delay between requests for server load protection
|
||||
|
||||
**Decision Logic**:
|
||||
```python
|
||||
if is_related:
|
||||
should_attempt_detail = True # Always try
|
||||
elif is_public == 1:
|
||||
should_attempt_detail = True # Try public
|
||||
else:
|
||||
skip_reason = "비공개 비관심글" # Skip non-public non-related
|
||||
```
|
||||
|
||||
### Backward Compatibility
|
||||
✓ Method signature unchanged: `get_posts_needing_detail(recheck_hours=3)`
|
||||
✓ Default recheck_hours=3 hours maintained
|
||||
✓ Safe defaults: Respects crawling.recheck_hours setting
|
||||
✓ Graceful failure handling (None returns from fetch logged, not crashed)
|
||||
✓ No new dependencies or breaking changes
|
||||
|
||||
### Verification Checklist
|
||||
- [x] Python syntax valid (py_compile)
|
||||
- [x] recheck_hours parameter actually used in SQL query
|
||||
- [x] Threshold calculation correct (now - N hours)
|
||||
- [x] Scheduler respects is_related context for non-public posts
|
||||
- [x] Failure handling graceful (debug logs, not exceptions)
|
||||
- [x] Korean logging messages consistent with codebase
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
## 2026-02-19 Initialization
|
||||
- Notepad initialized for crawling fixes orchestration.
|
||||
|
||||
## 2026-02-19 Scraper Service Fixes
|
||||
|
||||
### Fix 1: Unconditional List Refresh (Lines 168-172)
|
||||
**Before**: Conditional check skipped navigation if URL contained "act=searchList" and data existed
|
||||
**After**: Always navigate to refresh list via _navigate_to_list_via_physical_click()
|
||||
**Reason**: Cached pages cause new posts to be missed. Unconditional refresh ensures latest state.
|
||||
**Code**: Removed nested if checking "act=searchList" and _check_data_exists()
|
||||
|
||||
### Fix 2: Preserve Non-Public Rows (Lines 231-236)
|
||||
**Before**: Skip (continue) non-public rows entirely with `if is_public == 0: continue`
|
||||
**After**: Keep all rows in metadata with is_public=0 flag, log discovery
|
||||
**Reason**: Metadata completeness required; detail access already has timeout handling
|
||||
**Code**: Changed `continue` to debug log "비공개 게시글 수집: {voc_id} (상세 조회 스킵 예정)"
|
||||
|
||||
### Fix 3: Detail Attempt Policy (Lines 256-269)
|
||||
**Before**: Verbose comments, explicit "관심 대상인 경우 상세까지 긁어서 정확도 높임"
|
||||
**After**: Clear policy: "관심 게시글: 공개/비공개 구분 없이 상세 조회 시도"
|
||||
**Reason**: Support related 차량 cases; non-fatal errors in fetch_detail_content (timeout, permissions)
|
||||
**Code**: Updated comments to explain that fetch_detail_content handles errors for both public/private
|
||||
|
||||
### Non-Functional Changes
|
||||
- All modifications are isolated to fetch_list_pages() method
|
||||
- No dependency additions
|
||||
- Failures remain non-fatal (try-except in fetch_detail_content catches all)
|
||||
- Architecture preserved (same control flow, same data structure)
|
||||
|
||||
### Testing Notes
|
||||
- List refresh now runs on every fetch_list_pages() call
|
||||
- Metadata includes is_public=0 rows (complete state capture)
|
||||
- Detail attempts continue for is_target rows regardless of is_public value
|
||||
- Timeout/permission errors in fetch_detail_content are silently caught (returns None)
|
||||
|
|
@ -28,7 +28,6 @@ from datetime import datetime, timedelta
|
|||
from typing import Callable, Optional
|
||||
|
||||
from core.exceptions import (
|
||||
ScraperError,
|
||||
LoginFailedError,
|
||||
SessionExpiredError,
|
||||
DatabaseError
|
||||
|
|
@ -97,6 +96,7 @@ class SchedulerManager:
|
|||
# 콜백 함수 (Controller에서 주입)
|
||||
self.is_related_callback: Optional[Callable] = None
|
||||
self.notify_callback: Optional[Callable] = None
|
||||
self.sync_callback: Optional[Callable] = None
|
||||
|
||||
def set_callbacks(self, is_related_func: Callable, notify_func: Callable):
|
||||
"""
|
||||
|
|
@ -110,6 +110,10 @@ class SchedulerManager:
|
|||
"""
|
||||
self.is_related_callback = is_related_func
|
||||
self.notify_callback = notify_func
|
||||
|
||||
def set_sync_callback(self, sync_func: Callable):
|
||||
"""Supabase 동기화 콜백 설정"""
|
||||
self.sync_callback = sync_func
|
||||
|
||||
def start(self):
|
||||
"""
|
||||
|
|
@ -128,8 +132,11 @@ class SchedulerManager:
|
|||
# 백그라운드 스레드에서 스케줄러 루프 실행
|
||||
threading.Thread(target=self._scheduler_loop, daemon=True).start()
|
||||
|
||||
# 첫 크롤링 즉시 실행
|
||||
threading.Thread(target=self.run_crawling_cycle, daemon=True).start()
|
||||
# 첫 작업 즉시 실행
|
||||
if self.settings.get('sync', {}).get('enabled', False):
|
||||
threading.Thread(target=self.run_sync_cycle, daemon=True).start()
|
||||
else:
|
||||
threading.Thread(target=self.run_crawling_cycle, daemon=True).start()
|
||||
|
||||
self.logger.info("스케줄러 시작됨")
|
||||
|
||||
|
|
@ -148,9 +155,15 @@ class SchedulerManager:
|
|||
"""
|
||||
schedule.clear()
|
||||
|
||||
# 크롤링 주기 (기본: 10분)
|
||||
sync_enabled = self.settings.get('sync', {}).get('enabled', False)
|
||||
|
||||
# 크롤링/동기화 주기
|
||||
crawl_interval = self.settings.get('crawling', {}).get('interval_minutes', 10)
|
||||
schedule.every(crawl_interval).minutes.do(self.run_crawling_cycle)
|
||||
sync_interval = self.settings.get('sync', {}).get('pull_interval_minutes', crawl_interval)
|
||||
if sync_enabled:
|
||||
schedule.every(sync_interval).minutes.do(self.run_sync_cycle)
|
||||
else:
|
||||
schedule.every(crawl_interval).minutes.do(self.run_crawling_cycle)
|
||||
|
||||
# DB 체크 주기 (설정값, 기본: 5분)
|
||||
noti_interval = self.settings.get('noti', {}).get('db_check_interval_minutes', 5)
|
||||
|
|
@ -160,8 +173,10 @@ class SchedulerManager:
|
|||
unchecked_interval = self.settings.get('noti', {}).get('unchecked_check_interval_minutes', 30)
|
||||
schedule.every(unchecked_interval).minutes.do(self.run_unchecked_check_cycle)
|
||||
|
||||
mode_text = '동기화' if sync_enabled else '크롤링'
|
||||
mode_interval = sync_interval if sync_enabled else crawl_interval
|
||||
self.logger.info(
|
||||
f"스케줄 업데이트됨: 크롤링 {crawl_interval}분 / 신규 알림 {noti_interval}분 / 미확인 체크 {unchecked_interval}분"
|
||||
f"스케줄 업데이트됨: {mode_text} {mode_interval}분 / 신규 알림 {noti_interval}분 / 미확인 체크 {unchecked_interval}분"
|
||||
)
|
||||
|
||||
def _scheduler_loop(self):
|
||||
|
|
@ -188,6 +203,10 @@ class SchedulerManager:
|
|||
- DatabaseError: DB 오류 시 로그 기록 후 계속
|
||||
"""
|
||||
self.logger.info("크롤링 사이클 시작...")
|
||||
|
||||
if self.settings.get('sync', {}).get('enabled', False):
|
||||
self.logger.info("동기화 모드 활성화: 크롤링 사이클 건너뜀")
|
||||
return
|
||||
|
||||
try:
|
||||
# 1. 로그인 상태 확인 및 재로그인
|
||||
|
|
@ -232,6 +251,22 @@ class SchedulerManager:
|
|||
self.logger.error(f"DB 오류: {e.message}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"크롤링 중 예상치 못한 오류: {e}", exc_info=True)
|
||||
|
||||
def run_sync_cycle(self):
|
||||
"""Supabase 동기화 사이클 실행"""
|
||||
if not self.settings.get('sync', {}).get('enabled', False):
|
||||
return
|
||||
|
||||
if not self.sync_callback:
|
||||
self.logger.warning("동기화 콜백 미설정으로 동기화를 건너뜁니다.")
|
||||
return
|
||||
|
||||
self.logger.info("Supabase 동기화 사이클 시작...")
|
||||
try:
|
||||
self.sync_callback()
|
||||
self.logger.info("Supabase 동기화 사이클 완료.")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Supabase 동기화 중 오류: {e}", exc_info=True)
|
||||
|
||||
def _process_collected_posts(self, posts_data: list):
|
||||
"""
|
||||
|
|
@ -271,7 +306,14 @@ class SchedulerManager:
|
|||
"""
|
||||
상세 내용 수집 (내부 메서드)
|
||||
|
||||
내용이 없거나 최근 글인 경우 상세 파싱을 시도합니다.
|
||||
recheck_hours 설정에 따라 재수집이 필요한 게시글의 상세 내용을 수집합니다.
|
||||
관심글(is_related=1)이거나 내용이 없는 경우를 우선처리합니다.
|
||||
|
||||
정책:
|
||||
- 내용이 없거나 마지막 업데이트가 recheck_hours 이상 경과: 상세 시도
|
||||
- 관심글(차량 관련 등): 공개/비공개 구분 없이 상세 조회 시도
|
||||
- 비관심 & 비공개: 상세 조회 스킵 (불필요한 권한 오류 방지)
|
||||
- fetch_detail_content의 실패(타임아웃, 권한거부 등)는 자동 처리(로그만 기록)
|
||||
"""
|
||||
recheck_hours = self.settings['crawling']['recheck_hours']
|
||||
targets = self.db.get_posts_needing_detail(recheck_hours)
|
||||
|
|
@ -279,21 +321,49 @@ class SchedulerManager:
|
|||
for tgt in targets:
|
||||
voc_id, title, is_related = tgt
|
||||
|
||||
# 비공개 글 체크
|
||||
# 게시글 상세 정보 조회
|
||||
post_check = self.db.get_post_by_id(voc_id)
|
||||
if post_check and post_check['is_public'] == 0:
|
||||
if not post_check:
|
||||
continue
|
||||
|
||||
# 상세 내용 수집
|
||||
is_public = post_check['is_public']
|
||||
|
||||
# 결정 로직: 상세 조회 시도 여부
|
||||
should_attempt_detail = False
|
||||
skip_reason = None
|
||||
|
||||
if is_related:
|
||||
# 관심글: 항상 시도 (차량 관련 등 가치있는 정보)
|
||||
should_attempt_detail = True
|
||||
elif is_public == 1:
|
||||
# 공개글: 시도
|
||||
should_attempt_detail = True
|
||||
else:
|
||||
# 비공개 + 비관심글: 스킵 (불필요한 권한 오류 방지)
|
||||
skip_reason = "비공개 비관심글"
|
||||
|
||||
if not should_attempt_detail:
|
||||
self.logger.debug(
|
||||
f"상세 조회 스킵: {voc_id} ({skip_reason})"
|
||||
)
|
||||
continue
|
||||
|
||||
# 상세 내용 수집 시도 (실패는 자동 처리)
|
||||
detail_data = self.model.fetch_detail_content(voc_id)
|
||||
|
||||
if detail_data:
|
||||
try:
|
||||
self.db.update_detail(voc_id, detail_data)
|
||||
if is_related:
|
||||
self.logger.info(f"상세 내용 업데이트됨: {title}")
|
||||
log_suffix = " [관심글]" if is_related else ""
|
||||
self.logger.info(f"상세 내용 업데이트됨: {title}{log_suffix}")
|
||||
except DatabaseError as e:
|
||||
self.logger.error(f"상세 내용 저장 실패 (ID: {voc_id}): {e.message}")
|
||||
else:
|
||||
# fetch_detail_content에서 None 반환: 타임아웃, 권한거부, 기타 오류
|
||||
# 이는 정상적인 상황이므로 로그만 기록
|
||||
self.logger.debug(
|
||||
f"상세 내용 수집 실패 또는 스킵: {voc_id} (타임아웃/권한/기타)"
|
||||
)
|
||||
|
||||
time.sleep(1.2) # 서버 부하 방지용 딜레이
|
||||
|
||||
|
|
|
|||
|
|
@ -165,11 +165,11 @@ class VOCScraper:
|
|||
target_depts = target_depts if target_depts else self.target_depts
|
||||
|
||||
try:
|
||||
# 0. 목록 페이지인지 확인하고 아니면 이동
|
||||
if "act=searchList" not in self.page.url or not self._check_data_exists():
|
||||
if not self._navigate_to_list_via_physical_click():
|
||||
self.logger.error("목록 페이지 접근 실패. 수집을 중단합니다.")
|
||||
return {"status": "error", "msg": "목록 페이지 접근 실패"}
|
||||
# 0. 항상 목록 페이지로 이동하여 최신 상태 확보
|
||||
# 캐시된 페이지 사용 시 신규 게시글 누락 가능하므로 조건부 체크 제거
|
||||
if not self._navigate_to_list_via_physical_click():
|
||||
self.logger.error("목록 페이지 접근 실패. 수집을 중단합니다.")
|
||||
return {"status": "error", "msg": "목록 페이지 접근 실패"}
|
||||
|
||||
# 1. 페이지네이션 정보 확인
|
||||
total_cnt_ele = self.page.ele("text:page )", timeout=2)
|
||||
|
|
@ -231,9 +231,10 @@ class VOCScraper:
|
|||
is_public_txt = cols[5].text(strip=True)
|
||||
is_public = 1 if is_public_txt == '○' or is_public_txt == 'O' else 0
|
||||
|
||||
# 비공개 게시글도 메타데이터에 포함 (is_public=0으로 마킹)
|
||||
# 상세 페이지 접근 시 권한 문제 발생 가능하므로 로깅만 수행
|
||||
if is_public == 0:
|
||||
self.logger.debug(f"비공개 게시글 생략: {voc_id}")
|
||||
continue
|
||||
self.logger.debug(f"비공개 게시글 수집: {voc_id} (상세 조회 스킵 예정)")
|
||||
|
||||
status = cols[6].text(strip=True)
|
||||
|
||||
|
|
@ -253,14 +254,13 @@ class VOCScraper:
|
|||
|
||||
post_info["is_related"] = 1 if is_target else 0
|
||||
|
||||
# 상세 수집 대상이면 (관심 게시글이거나 공개글이면)
|
||||
# *여기서는 '상세 수집'을 별도로 하지 않고 목록 정보만 우선 저장*
|
||||
# 상세 정보는 update_detail 로직이나 필요시점에 호출
|
||||
# 단, 본문 내용 검색이 필요하다면 여기서 들어가야 함.
|
||||
# 현재 요구사항은 알림이 중요하므로, 관련성이 있으면 상세를 긁는게 맞음.
|
||||
# 상세 수집 전략: 관련성 있는 게시글만 상세 조회 시도
|
||||
# 비공개 게시글은 권한 오류 가능하지만, try-catch로 처리됨
|
||||
# 차량 등 관련 부서의 경우 is_public 상관없이 시도 (update_detail은 이미 오류처리)
|
||||
|
||||
# 리스트 수집 시 상세 내용도 긁고 싶다면:
|
||||
if is_target: # 관심 대상인 경우 상세까지 긁어서 정확도 높임
|
||||
if is_target:
|
||||
# 관심 게시글: 공개/비공개 구분 없이 상세 조회 시도
|
||||
# 비공개면 fetch_detail_content에서 타임아웃으로 처리됨
|
||||
detail = self.fetch_detail_content(voc_id)
|
||||
if detail:
|
||||
# 상세에서 가져온 더 정확한 정보로 덮어쓰기
|
||||
|
|
|
|||
|
|
@ -61,6 +61,14 @@ class VOCDatabase:
|
|||
checked_at TIMESTAMP -- 사용자 확인 시점
|
||||
)
|
||||
""")
|
||||
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS sync_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
# 2. 마이그레이션: 컬럼 존재 여부 확인 및 추가
|
||||
cur.execute("PRAGMA table_info(posts)")
|
||||
|
|
@ -248,15 +256,35 @@ class VOCDatabase:
|
|||
return cur.fetchall()
|
||||
|
||||
def get_posts_needing_detail(self, recheck_hours=3):
|
||||
"""상세 수집이 필요한 게시글 조회 (내용이 없거나 처리중인 최신글)"""
|
||||
"""
|
||||
상세 수집이 필요한 게시글 조회
|
||||
|
||||
조건:
|
||||
1. 내용이 없거나 (content IS NULL OR content = '')
|
||||
2. 또는 마지막 업데이트가 recheck_hours 이상 경과 (updated_at 시간 체크)
|
||||
|
||||
우선순위: is_related DESC, id DESC (최신글/관심글 우선)
|
||||
|
||||
Args:
|
||||
recheck_hours: 재검사 간격(시간 단위). 기본값: 3시간
|
||||
|
||||
Returns:
|
||||
list: (id, title, is_related) 튜플 리스트
|
||||
"""
|
||||
cur = self.conn.cursor()
|
||||
# 내용이 없고 공개된 것 우선
|
||||
|
||||
# recheck 간격 계산
|
||||
from datetime import datetime, timedelta
|
||||
threshold_time = (datetime.now() - timedelta(hours=recheck_hours)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
cur.execute('''
|
||||
SELECT id, title, is_related FROM posts
|
||||
WHERE content IS NULL OR content = ''
|
||||
ORDER BY is_public DESC, id DESC
|
||||
WHERE
|
||||
(content IS NULL OR content = '')
|
||||
OR (updated_at IS NOT NULL AND updated_at < ?)
|
||||
ORDER BY is_related DESC, id DESC
|
||||
LIMIT 10
|
||||
''')
|
||||
''', (threshold_time,))
|
||||
return cur.fetchall()
|
||||
|
||||
def get_all_posts(self, limit=500):
|
||||
|
|
@ -275,6 +303,28 @@ class VOCDatabase:
|
|||
"""DB 연결 종료"""
|
||||
if self.conn:
|
||||
self.conn.close()
|
||||
|
||||
def get_sync_cursor(self, key: str) -> str:
|
||||
"""동기화 커서 조회"""
|
||||
cur = self.conn.cursor()
|
||||
cur.execute("SELECT value FROM sync_state WHERE key = ?", (key,))
|
||||
row = cur.fetchone()
|
||||
return str(row["value"]) if row and row["value"] else ""
|
||||
|
||||
def save_sync_cursor(self, key: str, value: str):
|
||||
"""동기화 커서 저장 (upsert)"""
|
||||
cur = self.conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO sync_state (key, value, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value=excluded.value,
|
||||
updated_at=CURRENT_TIMESTAMP
|
||||
""",
|
||||
(key, value),
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
# ========================================================================
|
||||
# 통계 쿼리 메서드 (Statistics Queries)
|
||||
|
|
|
|||
|
|
@ -139,6 +139,13 @@ class SettingsDialog(ctk.CTkToplevel):
|
|||
str(self.controller.settings.get('noti', {}).get('unchecked_check_interval_minutes', 30))
|
||||
)
|
||||
|
||||
ctk.CTkLabel(self.tab_crawl, text="상세 재수집 주기 (시간)", font=font).pack(pady=5)
|
||||
self.combo_recheck_hours = ctk.CTkComboBox(self.tab_crawl, values=["1", "2", "3", "6", "12", "24"])
|
||||
self.combo_recheck_hours.pack(pady=5)
|
||||
self.combo_recheck_hours.set(
|
||||
str(self.controller.settings.get('crawling', {}).get('recheck_hours', 3))
|
||||
)
|
||||
|
||||
self.unchecked_delay_var = ctk.BooleanVar(
|
||||
value=self.controller.settings.get('noti', {}).get('unchecked_delay_enabled', True)
|
||||
)
|
||||
|
|
@ -298,6 +305,36 @@ class SettingsDialog(ctk.CTkToplevel):
|
|||
text_color="gray"
|
||||
).pack(anchor="w", padx=45, pady=(0, 0))
|
||||
|
||||
ctk.CTkLabel(self.tab_system, text="", height=1, fg_color="gray").pack(fill="x", padx=20, pady=15)
|
||||
|
||||
ctk.CTkLabel(self.tab_system, text="Supabase 동기화", font=font_bold).pack(pady=(10, 5), anchor="w", padx=20)
|
||||
|
||||
sync_settings = self.controller.settings.get('sync', {})
|
||||
|
||||
self.sync_enabled_var = ctk.BooleanVar(value=bool(sync_settings.get('enabled', False)))
|
||||
ctk.CTkSwitch(
|
||||
self.tab_system,
|
||||
text="크롤링 대신 Supabase 동기화 사용",
|
||||
variable=self.sync_enabled_var,
|
||||
).pack(anchor="w", padx=20, pady=(4, 6))
|
||||
|
||||
ctk.CTkLabel(self.tab_system, text="동기화 주기(분)", font=theme_manager.get_font(11)).pack(anchor="w", padx=20)
|
||||
self.combo_sync_interval = ctk.CTkComboBox(self.tab_system, values=["1", "3", "5", "10", "30"])
|
||||
self.combo_sync_interval.pack(fill="x", padx=20, pady=(4, 8))
|
||||
self.combo_sync_interval.set(str(sync_settings.get('pull_interval_minutes', 3)))
|
||||
|
||||
ctk.CTkLabel(self.tab_system, text="동기화 대상 테이블", font=theme_manager.get_font(11)).pack(anchor="w", padx=20)
|
||||
self.entry_sync_table = ctk.CTkEntry(self.tab_system)
|
||||
self.entry_sync_table.pack(fill="x", padx=20, pady=(4, 8))
|
||||
self.entry_sync_table.insert(0, str(sync_settings.get('table', 'posts')))
|
||||
|
||||
ctk.CTkLabel(
|
||||
self.tab_system,
|
||||
text="※ URL/KEY는 update 연결정보를 재사용합니다(기본).",
|
||||
font=theme_manager.get_font(10),
|
||||
text_color="gray"
|
||||
).pack(anchor="w", padx=45, pady=(0, 0))
|
||||
|
||||
ctk.CTkButton(self.tab_system, text="설정 저장", command=self.save_all).pack(pady=20)
|
||||
|
||||
def _select_report_folder(self):
|
||||
|
|
@ -377,6 +414,7 @@ class SettingsDialog(ctk.CTkToplevel):
|
|||
self.controller.settings['login']['id'] = self.entry_id.get()
|
||||
self.controller.settings['login']['pw'] = self.entry_pw.get()
|
||||
self.controller.settings['crawling']['interval_minutes'] = int(self.combo_interval.get())
|
||||
self.controller.settings['crawling']['recheck_hours'] = int(self.combo_recheck_hours.get())
|
||||
self.controller.settings['crawling']['keywords'] = [k.strip() for k in self.entry_crawl_kw.get().split(',') if k.strip()]
|
||||
self.controller.settings['crawling']['target_depts'] = [k.strip() for k in self.entry_dept.get().split(',') if k.strip()]
|
||||
|
||||
|
|
@ -420,6 +458,20 @@ class SettingsDialog(ctk.CTkToplevel):
|
|||
self.controller.settings['update']['cleanup_backup_on_success'] = self.cleanup_backup_var.get()
|
||||
self.controller.settings['update']['clean_install_before_copy'] = self.clean_install_var.get()
|
||||
|
||||
if 'sync' not in self.controller.settings:
|
||||
self.controller.settings['sync'] = {}
|
||||
|
||||
self.controller.settings['sync']['enabled'] = self.sync_enabled_var.get()
|
||||
self.controller.settings['sync']['pull_interval_minutes'] = int(self.combo_sync_interval.get())
|
||||
self.controller.settings['sync']['table'] = self.entry_sync_table.get().strip() or 'posts'
|
||||
self.controller.settings['sync'].setdefault('pull_batch_size', 500)
|
||||
self.controller.settings['sync'].setdefault('timeout_sec', 10)
|
||||
self.controller.settings['sync'].setdefault('use_update_connection', True)
|
||||
self.controller.settings['sync'].setdefault('supabase_url', '')
|
||||
self.controller.settings['sync'].setdefault('supabase_key', '')
|
||||
self.controller.settings['sync'].setdefault('ssl_verify', True)
|
||||
self.controller.settings['sync'].setdefault('ca_bundle_path', '')
|
||||
|
||||
self.controller.save_settings()
|
||||
self.controller.update_config_runtime()
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ class TestSettingsDialogUpdatePolicy(unittest.TestCase):
|
|||
controller = SimpleNamespace()
|
||||
controller.settings = {
|
||||
"login": {"id": "old", "pw": "old"},
|
||||
"crawling": {"interval_minutes": 10, "keywords": [], "target_depts": []},
|
||||
"crawling": {"interval_minutes": 10, "recheck_hours": 3, "keywords": [], "target_depts": []},
|
||||
"noti": {"db_check_interval_minutes": 3, "unchecked_check_interval_minutes": 10, "unchecked_delay_enabled": True},
|
||||
"theme": "Dark",
|
||||
"report": {"output_path": "D:/reports"},
|
||||
|
|
@ -69,6 +69,7 @@ class TestSettingsDialogUpdatePolicy(unittest.TestCase):
|
|||
dialog.entry_id = _DummyField("116696")
|
||||
dialog.entry_pw = _DummyField("pw")
|
||||
dialog.combo_interval = _DummyField("10")
|
||||
dialog.combo_recheck_hours = _DummyField("6")
|
||||
dialog.entry_crawl_kw = _DummyField("1호선, 서면")
|
||||
dialog.entry_dept = _DummyField("차량")
|
||||
dialog.combo_noti_interval = _DummyField("3")
|
||||
|
|
@ -84,17 +85,26 @@ class TestSettingsDialogUpdatePolicy(unittest.TestCase):
|
|||
dialog.entry_preserve_globs = _DummyField("data/*.sqlite, data/*.json, data/*.db")
|
||||
dialog.cleanup_backup_var = _DummyVar(True)
|
||||
dialog.clean_install_var = _DummyVar(True)
|
||||
dialog.sync_enabled_var = _DummyVar(True)
|
||||
dialog.combo_sync_interval = _DummyField("5")
|
||||
dialog.entry_sync_table = _DummyField("posts")
|
||||
dialog.destroy = lambda: None
|
||||
|
||||
with patch("view.dialogs.settings_dialog.theme_manager.set_theme", lambda *_args, **_kwargs: None):
|
||||
SettingsDialog.save_all(dialog)
|
||||
|
||||
crawling_settings = controller.settings["crawling"]
|
||||
self.assertEqual(crawling_settings["recheck_hours"], 6)
|
||||
update_settings = controller.settings["update"]
|
||||
self.assertEqual(update_settings["allowed_update_levels"], ["patch", "major"])
|
||||
self.assertEqual(update_settings["clean_install_levels"], ["minor", "major"])
|
||||
self.assertEqual(update_settings["preserve_globs"], ["data/*.sqlite", "data/*.json", "data/*.db"])
|
||||
self.assertTrue(update_settings["cleanup_backup_on_success"])
|
||||
self.assertTrue(update_settings["clean_install_before_copy"])
|
||||
sync_settings = controller.settings["sync"]
|
||||
self.assertTrue(sync_settings["enabled"])
|
||||
self.assertEqual(sync_settings["pull_interval_minutes"], 5)
|
||||
self.assertEqual(sync_settings["table"], "posts")
|
||||
self.assertEqual(controller.save_settings_called, 1)
|
||||
self.assertEqual(controller.update_runtime_called, 1)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue