Spaces:
Running
feat: 당일 크롤 미발견 공고 즉시 비활성화 (mark-and-sweep)
Browse files기존 문제: 마감된 공고가 7일 동안 계속 노출됨
- deactivate_expired_jobs()의 staleness 기준이 7일이라 즉시 반영 안 됨
변경 내용:
[db/connection.py]
- deactivate_unseen_jobs(conn, source_site, crawl_start_iso) 추가
- crawl_start_iso 이전에 updated_at이 머문 공고 = 오늘 크롤에서 미발견
- 해당 source_site 공고만 한정하여 즉시 is_active=0 처리
- deactivate_expired_jobs() 단순화
- 7일 staleness 기준 제거 (deactivate_unseen_jobs로 대체)
- deadline_date 초과 공고만 처리하는 보조 수단으로 축소
- 반환 타입: dict → int
[crawler/run.py]
- run_crawler() 시작 시 crawl_start(UTC) 기록
- upsert 완료 후 deactivate_unseen_jobs() 호출 → 소스별 당일 기준 즉시 비활성화
- main()에서 URL 링크 검증 제거 (당일 비활성화가 대체)
- 로그에 "당일 미발견 비활성화 N건" 출력
[tests/test_db.py]
- TestDeactivateUnseenJobs 5케이스 추가 (95/95 PASS)
- TestDeactivateExpiredJobs staleness 테스트 제거, int 반환 기준으로 업데이트
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- crawler/run.py +17 -15
- db/connection.py +37 -23
- tests/test_db.py +70 -38
|
@@ -8,6 +8,7 @@ import argparse
|
|
| 8 |
import logging
|
| 9 |
import sys
|
| 10 |
import time
|
|
|
|
| 11 |
from pathlib import Path
|
| 12 |
|
| 13 |
import requests as _requests
|
|
@@ -15,7 +16,10 @@ import requests as _requests
|
|
| 15 |
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 16 |
|
| 17 |
from crawler import WantedCrawler, SaraminCrawler, JobKoreaCrawler
|
| 18 |
-
from db.connection import
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
logging.basicConfig(
|
| 21 |
level=logging.INFO,
|
|
@@ -104,9 +108,12 @@ def run_crawler(source: str, max_pages: int) -> dict:
|
|
| 104 |
}
|
| 105 |
CrawlerClass = crawlers[source]
|
| 106 |
crawler = CrawlerClass()
|
|
|
|
|
|
|
|
|
|
| 107 |
jobs = crawler.crawl_all_categories(max_pages=max_pages)
|
| 108 |
|
| 109 |
-
stats = {"found": len(jobs), "inserted": 0, "updated": 0, "errors": 0}
|
| 110 |
|
| 111 |
with get_conn() as conn:
|
| 112 |
log_id = conn.execute(
|
|
@@ -124,6 +131,9 @@ def run_crawler(source: str, max_pages: int) -> dict:
|
|
| 124 |
logger.error(f"DB 저장 실패: {e} — {job.source_id}")
|
| 125 |
stats["errors"] += 1
|
| 126 |
|
|
|
|
|
|
|
|
|
|
| 127 |
conn.execute(
|
| 128 |
"""
|
| 129 |
UPDATE crawl_logs
|
|
@@ -171,23 +181,15 @@ def main():
|
|
| 171 |
f"발견 {stats['found']}건 / "
|
| 172 |
f"신규 {stats.get('inserted', 0)}건 / "
|
| 173 |
f"업데이트 {stats.get('updated', 0)}건 / "
|
|
|
|
| 174 |
f"오류 {stats['errors']}건 ==="
|
| 175 |
)
|
| 176 |
|
| 177 |
-
#
|
| 178 |
with get_conn() as conn:
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
f"마감일 초과: {expired['by_deadline']}건, "
|
| 183 |
-
f"7일 미발견: {expired['by_staleness']}건"
|
| 184 |
-
)
|
| 185 |
-
link_stats = validate_job_links(conn, max_checks=30, delay=1.5)
|
| 186 |
-
logger.info(
|
| 187 |
-
f"URL 링크 검증 — "
|
| 188 |
-
f"검사: {link_stats['checked']}건, "
|
| 189 |
-
f"링크 만료 비활성화: {link_stats['deactivated']}건"
|
| 190 |
-
)
|
| 191 |
|
| 192 |
|
| 193 |
if __name__ == "__main__":
|
|
|
|
| 8 |
import logging
|
| 9 |
import sys
|
| 10 |
import time
|
| 11 |
+
from datetime import datetime, timezone
|
| 12 |
from pathlib import Path
|
| 13 |
|
| 14 |
import requests as _requests
|
|
|
|
| 16 |
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 17 |
|
| 18 |
from crawler import WantedCrawler, SaraminCrawler, JobKoreaCrawler
|
| 19 |
+
from db.connection import (
|
| 20 |
+
init_db, get_conn, upsert_job, insert_skills,
|
| 21 |
+
deactivate_unseen_jobs, deactivate_expired_jobs,
|
| 22 |
+
)
|
| 23 |
|
| 24 |
logging.basicConfig(
|
| 25 |
level=logging.INFO,
|
|
|
|
| 108 |
}
|
| 109 |
CrawlerClass = crawlers[source]
|
| 110 |
crawler = CrawlerClass()
|
| 111 |
+
|
| 112 |
+
# 크롤 시작 시각 기록 (UTC) — 이 시각보다 updated_at이 이전인 공고 = 오늘 미발견
|
| 113 |
+
crawl_start = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
| 114 |
jobs = crawler.crawl_all_categories(max_pages=max_pages)
|
| 115 |
|
| 116 |
+
stats = {"found": len(jobs), "inserted": 0, "updated": 0, "deactivated": 0, "errors": 0}
|
| 117 |
|
| 118 |
with get_conn() as conn:
|
| 119 |
log_id = conn.execute(
|
|
|
|
| 131 |
logger.error(f"DB 저장 실패: {e} — {job.source_id}")
|
| 132 |
stats["errors"] += 1
|
| 133 |
|
| 134 |
+
# 오늘 크롤에서 발견 안 된 공고 즉시 비활성화
|
| 135 |
+
stats["deactivated"] = deactivate_unseen_jobs(conn, source, crawl_start)
|
| 136 |
+
|
| 137 |
conn.execute(
|
| 138 |
"""
|
| 139 |
UPDATE crawl_logs
|
|
|
|
| 181 |
f"발견 {stats['found']}건 / "
|
| 182 |
f"신규 {stats.get('inserted', 0)}건 / "
|
| 183 |
f"업데이트 {stats.get('updated', 0)}건 / "
|
| 184 |
+
f"당일 미발견 비활성화 {stats.get('deactivated', 0)}건 / "
|
| 185 |
f"오류 {stats['errors']}건 ==="
|
| 186 |
)
|
| 187 |
|
| 188 |
+
# 마감일 초과 공고 비활성화 (deadline_date 명시된 공고 한정)
|
| 189 |
with get_conn() as conn:
|
| 190 |
+
n_deadline = deactivate_expired_jobs(conn)
|
| 191 |
+
if n_deadline:
|
| 192 |
+
logger.info(f"마감일 초과 비활성화: {n_deadline}건")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
|
| 194 |
|
| 195 |
if __name__ == "__main__":
|
|
@@ -137,37 +137,51 @@ def insert_skills(conn: sqlite3.Connection, job_id: int, skills: list[str]) -> N
|
|
| 137 |
)
|
| 138 |
|
| 139 |
|
| 140 |
-
def
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
"""
|
| 151 |
-
|
| 152 |
-
cur_deadline = conn.execute(
|
| 153 |
"""
|
| 154 |
UPDATE jobs
|
| 155 |
SET is_active = 0, updated_at = CURRENT_TIMESTAMP
|
| 156 |
-
WHERE
|
| 157 |
-
AND
|
| 158 |
-
AND
|
| 159 |
-
"""
|
|
|
|
| 160 |
)
|
| 161 |
-
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
"""
|
| 164 |
UPDATE jobs
|
| 165 |
SET is_active = 0, updated_at = CURRENT_TIMESTAMP
|
| 166 |
WHERE is_active = 1
|
| 167 |
-
AND
|
|
|
|
| 168 |
"""
|
| 169 |
)
|
| 170 |
-
return
|
| 171 |
-
"by_deadline": cur_deadline.rowcount,
|
| 172 |
-
"by_staleness": cur_stale.rowcount,
|
| 173 |
-
}
|
|
|
|
| 137 |
)
|
| 138 |
|
| 139 |
|
| 140 |
+
def deactivate_unseen_jobs(
|
| 141 |
+
conn: sqlite3.Connection,
|
| 142 |
+
source_site: str,
|
| 143 |
+
crawl_start_iso: str,
|
| 144 |
+
) -> int:
|
| 145 |
+
"""해당 소스에서 이번 크롤에 발견되지 않은 공고를 즉시 비활성화.
|
| 146 |
+
|
| 147 |
+
upsert_job()은 공고를 발견할 때마다 updated_at을 현재 시각으로 갱신함.
|
| 148 |
+
따라서 crawl_start_iso 이전에 updated_at이 머물러 있는 공고
|
| 149 |
+
= 이번 크롤에서 한 번도 발견되지 않은 공고 = 사이트에서 내려진 것으로 판단.
|
| 150 |
+
|
| 151 |
+
Args:
|
| 152 |
+
source_site: 'wanted' | 'saramin' | 'jobkorea'
|
| 153 |
+
crawl_start_iso: 크롤 시작 시각 (UTC, 'YYYY-MM-DD HH:MM:SS')
|
| 154 |
+
|
| 155 |
+
반환: 비활성화된 공고 수
|
| 156 |
"""
|
| 157 |
+
cur = conn.execute(
|
|
|
|
| 158 |
"""
|
| 159 |
UPDATE jobs
|
| 160 |
SET is_active = 0, updated_at = CURRENT_TIMESTAMP
|
| 161 |
+
WHERE source_site = ?
|
| 162 |
+
AND is_active = 1
|
| 163 |
+
AND updated_at < ?
|
| 164 |
+
""",
|
| 165 |
+
(source_site, crawl_start_iso),
|
| 166 |
)
|
| 167 |
+
return cur.rowcount
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def deactivate_expired_jobs(conn: sqlite3.Connection) -> int:
|
| 171 |
+
"""마감일이 지난 공고를 is_active=0으로 표시 (deadline_date 기준).
|
| 172 |
+
|
| 173 |
+
당일 크롤 미발견 공고는 run_crawler() 내 deactivate_unseen_jobs()로 처리.
|
| 174 |
+
이 함수는 deadline_date가 명시된 공고의 마감일 초과만 처리하는 보조 수단.
|
| 175 |
+
|
| 176 |
+
반환: 비활성화된 공고 수
|
| 177 |
+
"""
|
| 178 |
+
cur = conn.execute(
|
| 179 |
"""
|
| 180 |
UPDATE jobs
|
| 181 |
SET is_active = 0, updated_at = CURRENT_TIMESTAMP
|
| 182 |
WHERE is_active = 1
|
| 183 |
+
AND deadline_date IS NOT NULL
|
| 184 |
+
AND deadline_date < date('now')
|
| 185 |
"""
|
| 186 |
)
|
| 187 |
+
return cur.rowcount
|
|
|
|
|
|
|
|
|
|
@@ -11,7 +11,7 @@ from unittest.mock import MagicMock
|
|
| 11 |
from db.connection import (
|
| 12 |
init_db, get_conn, upsert_job, insert_skills,
|
| 13 |
_is_cross_site_duplicate, _titles_are_duplicate,
|
| 14 |
-
deactivate_expired_jobs,
|
| 15 |
)
|
| 16 |
from crawler.run import validate_job_links
|
| 17 |
|
|
@@ -295,70 +295,102 @@ class TestValidateJobLinks:
|
|
| 295 |
assert "deactivated" in result
|
| 296 |
|
| 297 |
|
| 298 |
-
# ──
|
| 299 |
|
| 300 |
-
class
|
| 301 |
-
def
|
| 302 |
-
"""
|
| 303 |
conn = _make_in_memory_conn()
|
| 304 |
-
upsert_job(conn, _sample_job(
|
|
|
|
| 305 |
conn.commit()
|
| 306 |
-
|
|
|
|
| 307 |
conn.commit()
|
| 308 |
-
|
|
|
|
| 309 |
row = conn.execute("SELECT is_active FROM jobs WHERE source_id='1'").fetchone()
|
| 310 |
assert row["is_active"] == 0
|
| 311 |
|
| 312 |
-
def
|
| 313 |
-
"""
|
| 314 |
conn = _make_in_memory_conn()
|
| 315 |
-
upsert_job(conn, _sample_job(
|
| 316 |
conn.commit()
|
| 317 |
-
|
|
|
|
|
|
|
| 318 |
conn.commit()
|
|
|
|
|
|
|
| 319 |
row = conn.execute("SELECT is_active FROM jobs WHERE source_id='2'").fetchone()
|
| 320 |
assert row["is_active"] == 1
|
| 321 |
|
| 322 |
-
def
|
| 323 |
-
"""
|
| 324 |
conn = _make_in_memory_conn()
|
| 325 |
-
upsert_job(conn, _sample_job(
|
|
|
|
|
|
|
| 326 |
conn.commit()
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
"UPDATE jobs SET updated_at = datetime('now', '-8 days') WHERE source_id='3'"
|
| 330 |
-
)
|
| 331 |
conn.commit()
|
| 332 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
conn.commit()
|
| 334 |
-
|
| 335 |
-
|
|
|
|
|
|
|
| 336 |
assert row["is_active"] == 0
|
| 337 |
|
| 338 |
-
def
|
| 339 |
-
"""마감일
|
| 340 |
conn = _make_in_memory_conn()
|
| 341 |
-
upsert_job(conn, _sample_job(source_id="
|
| 342 |
conn.commit()
|
| 343 |
deactivate_expired_jobs(conn)
|
| 344 |
conn.commit()
|
| 345 |
-
row = conn.execute("SELECT is_active FROM jobs WHERE source_id='
|
| 346 |
assert row["is_active"] == 1
|
| 347 |
|
| 348 |
-
def
|
| 349 |
-
"""이미 비활성인 공고는 카운트에 포함되지 않음
|
| 350 |
conn = _make_in_memory_conn()
|
| 351 |
-
upsert_job(conn, _sample_job(source_id="
|
| 352 |
-
conn.execute("UPDATE jobs SET is_active=0 WHERE source_id='
|
| 353 |
conn.commit()
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
assert result["by_deadline"] == 0
|
| 357 |
-
assert result["by_staleness"] == 0
|
| 358 |
|
| 359 |
-
def
|
| 360 |
-
"""반환 딕셔너리에 by_deadline / by_staleness 키가 있음."""
|
| 361 |
conn = _make_in_memory_conn()
|
| 362 |
result = deactivate_expired_jobs(conn)
|
| 363 |
-
assert
|
| 364 |
-
assert "by_staleness" in result
|
|
|
|
| 11 |
from db.connection import (
|
| 12 |
init_db, get_conn, upsert_job, insert_skills,
|
| 13 |
_is_cross_site_duplicate, _titles_are_duplicate,
|
| 14 |
+
deactivate_unseen_jobs, deactivate_expired_jobs,
|
| 15 |
)
|
| 16 |
from crawler.run import validate_job_links
|
| 17 |
|
|
|
|
| 295 |
assert "deactivated" in result
|
| 296 |
|
| 297 |
|
| 298 |
+
# ── deactivate_unseen_jobs ────────────────────────────────────────
|
| 299 |
|
| 300 |
+
class TestDeactivateUnseenJobs:
|
| 301 |
+
def test_deactivates_job_not_seen_in_crawl(self):
|
| 302 |
+
"""크롤 시작 전 updated_at → 오늘 미발견 공고 비활성화."""
|
| 303 |
conn = _make_in_memory_conn()
|
| 304 |
+
upsert_job(conn, _sample_job(source_site="wanted", source_id="1"))
|
| 305 |
+
conn.execute("UPDATE jobs SET updated_at='2020-01-01 00:00:00' WHERE source_id='1'")
|
| 306 |
conn.commit()
|
| 307 |
+
|
| 308 |
+
count = deactivate_unseen_jobs(conn, "wanted", "2025-01-01 09:00:00")
|
| 309 |
conn.commit()
|
| 310 |
+
|
| 311 |
+
assert count == 1
|
| 312 |
row = conn.execute("SELECT is_active FROM jobs WHERE source_id='1'").fetchone()
|
| 313 |
assert row["is_active"] == 0
|
| 314 |
|
| 315 |
+
def test_keeps_job_seen_in_crawl(self):
|
| 316 |
+
"""크롤 시작 이후 updated_at → 활성 유지."""
|
| 317 |
conn = _make_in_memory_conn()
|
| 318 |
+
upsert_job(conn, _sample_job(source_site="wanted", source_id="2"))
|
| 319 |
conn.commit()
|
| 320 |
+
|
| 321 |
+
# crawl_start를 과거로 → 현재 updated_at(≈now)이 이후임
|
| 322 |
+
count = deactivate_unseen_jobs(conn, "wanted", "2020-01-01 00:00:00")
|
| 323 |
conn.commit()
|
| 324 |
+
|
| 325 |
+
assert count == 0
|
| 326 |
row = conn.execute("SELECT is_active FROM jobs WHERE source_id='2'").fetchone()
|
| 327 |
assert row["is_active"] == 1
|
| 328 |
|
| 329 |
+
def test_only_affects_matching_source(self):
|
| 330 |
+
"""다른 source_site 공고는 영향 없음."""
|
| 331 |
conn = _make_in_memory_conn()
|
| 332 |
+
upsert_job(conn, _sample_job(source_site="wanted", source_id="3"))
|
| 333 |
+
upsert_job(conn, _sample_job(source_site="saramin", source_id="4"))
|
| 334 |
+
conn.execute("UPDATE jobs SET updated_at='2020-01-01 00:00:00'")
|
| 335 |
conn.commit()
|
| 336 |
+
|
| 337 |
+
count = deactivate_unseen_jobs(conn, "wanted", "2025-01-01 09:00:00")
|
|
|
|
|
|
|
| 338 |
conn.commit()
|
| 339 |
+
|
| 340 |
+
assert count == 1
|
| 341 |
+
assert conn.execute("SELECT is_active FROM jobs WHERE source_id='3'").fetchone()["is_active"] == 0
|
| 342 |
+
assert conn.execute("SELECT is_active FROM jobs WHERE source_id='4'").fetchone()["is_active"] == 1
|
| 343 |
+
|
| 344 |
+
def test_already_inactive_not_counted(self):
|
| 345 |
+
"""이미 비활성인 공고는 카운트 제외."""
|
| 346 |
+
conn = _make_in_memory_conn()
|
| 347 |
+
upsert_job(conn, _sample_job(source_site="wanted", source_id="5"))
|
| 348 |
+
conn.execute("UPDATE jobs SET is_active=0, updated_at='2020-01-01' WHERE source_id='5'")
|
| 349 |
+
conn.commit()
|
| 350 |
+
|
| 351 |
+
count = deactivate_unseen_jobs(conn, "wanted", "2025-01-01 09:00:00")
|
| 352 |
+
assert count == 0
|
| 353 |
+
|
| 354 |
+
def test_returns_int(self):
|
| 355 |
+
conn = _make_in_memory_conn()
|
| 356 |
+
result = deactivate_unseen_jobs(conn, "wanted", "2025-01-01 09:00:00")
|
| 357 |
+
assert isinstance(result, int)
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
# ── deactivate_expired_jobs ───────────────────────────────────────
|
| 361 |
+
|
| 362 |
+
class TestDeactivateExpiredJobs:
|
| 363 |
+
def test_deactivates_past_deadline(self):
|
| 364 |
+
"""마감일이 지난 공고는 비활성화."""
|
| 365 |
+
conn = _make_in_memory_conn()
|
| 366 |
+
upsert_job(conn, _sample_job(source_id="10", deadline_date="2020-01-01"))
|
| 367 |
conn.commit()
|
| 368 |
+
count = deactivate_expired_jobs(conn)
|
| 369 |
+
conn.commit()
|
| 370 |
+
assert count >= 1
|
| 371 |
+
row = conn.execute("SELECT is_active FROM jobs WHERE source_id='10'").fetchone()
|
| 372 |
assert row["is_active"] == 0
|
| 373 |
|
| 374 |
+
def test_keeps_future_deadline_active(self):
|
| 375 |
+
"""마감일이 아직 남은 공고는 유지."""
|
| 376 |
conn = _make_in_memory_conn()
|
| 377 |
+
upsert_job(conn, _sample_job(source_id="11", deadline_date="2099-12-31"))
|
| 378 |
conn.commit()
|
| 379 |
deactivate_expired_jobs(conn)
|
| 380 |
conn.commit()
|
| 381 |
+
row = conn.execute("SELECT is_active FROM jobs WHERE source_id='11'").fetchone()
|
| 382 |
assert row["is_active"] == 1
|
| 383 |
|
| 384 |
+
def test_already_inactive_not_counted(self):
|
| 385 |
+
"""이미 비활성인 공고는 카운트에 포함되지 않음."""
|
| 386 |
conn = _make_in_memory_conn()
|
| 387 |
+
upsert_job(conn, _sample_job(source_id="12", deadline_date="2020-01-01"))
|
| 388 |
+
conn.execute("UPDATE jobs SET is_active=0 WHERE source_id='12'")
|
| 389 |
conn.commit()
|
| 390 |
+
count = deactivate_expired_jobs(conn)
|
| 391 |
+
assert count == 0
|
|
|
|
|
|
|
| 392 |
|
| 393 |
+
def test_returns_int(self):
|
|
|
|
| 394 |
conn = _make_in_memory_conn()
|
| 395 |
result = deactivate_expired_jobs(conn)
|
| 396 |
+
assert isinstance(result, int)
|
|
|