MiniMing Claude Sonnet 4.6 commited on
Commit
cf3fcde
·
1 Parent(s): 650b84f

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>

Files changed (3) hide show
  1. crawler/run.py +17 -15
  2. db/connection.py +37 -23
  3. tests/test_db.py +70 -38
crawler/run.py CHANGED
@@ -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 init_db, get_conn, upsert_job, insert_skills, deactivate_expired_jobs
 
 
 
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
- # 전체 크롤 완료 후 만료 공고 비활성화 + URL 링크 검증
178
  with get_conn() as conn:
179
- expired = deactivate_expired_jobs(conn)
180
- logger.info(
181
- f"만료 공고 비활성화 "
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__":
db/connection.py CHANGED
@@ -137,37 +137,51 @@ def insert_skills(conn: sqlite3.Connection, job_id: int, skills: list[str]) -> N
137
  )
138
 
139
 
140
- def deactivate_expired_jobs(conn: sqlite3.Connection) -> dict[str, int]:
141
- """만료 공고를 is_active=0으로 표시.
142
-
143
- 두 가지 기준으로 비활성화:
144
- 1. deadline_date 기준: 마감일이 오늘 이전인 공고 (명시적 만료)
145
- 2. 비활성 staleness 기준: 마감일 정보가 없고 7일 크롤 다시 발견되지 않은 공고
146
- - upsert_job()은 공고를 발견할 때마다 updated_at을 갱신하므로,
147
- updated_at이 오래된 공고 = 사이트에서 사라진 으로 추정
148
-
149
- 반환: {"by_deadline": N, "by_staleness": M} (비활성화된 건수)
 
 
 
 
 
 
150
  """
151
- # 1) 마감일 지난 공고
152
- cur_deadline = conn.execute(
153
  """
154
  UPDATE jobs
155
  SET is_active = 0, updated_at = CURRENT_TIMESTAMP
156
- WHERE is_active = 1
157
- AND deadline_date IS NOT NULL
158
- AND deadline_date < date('now')
159
- """
 
160
  )
161
- # 2) 마감일 없이 7일 이상 미발견 공고
162
- cur_stale = conn.execute(
 
 
 
 
 
 
 
 
 
 
163
  """
164
  UPDATE jobs
165
  SET is_active = 0, updated_at = CURRENT_TIMESTAMP
166
  WHERE is_active = 1
167
- AND updated_at < datetime('now', '-7 days')
 
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
 
 
 
tests/test_db.py CHANGED
@@ -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
- # ── deactivate_expired_jobs ───────────────────────────────────────
299
 
300
- class TestDeactivateExpiredJobs:
301
- def test_deactivates_past_deadline(self):
302
- """마감일이 지난 공고 비활성화."""
303
  conn = _make_in_memory_conn()
304
- upsert_job(conn, _sample_job(source_id="1", deadline_date="2020-01-01"))
 
305
  conn.commit()
306
- result = deactivate_expired_jobs(conn)
 
307
  conn.commit()
308
- assert result["by_deadline"] >= 1
 
309
  row = conn.execute("SELECT is_active FROM jobs WHERE source_id='1'").fetchone()
310
  assert row["is_active"] == 0
311
 
312
- def test_keeps_future_deadline_active(self):
313
- """마감일아직 남은 공고는 유지."""
314
  conn = _make_in_memory_conn()
315
- upsert_job(conn, _sample_job(source_id="2", deadline_date="2099-12-31"))
316
  conn.commit()
317
- deactivate_expired_jobs(conn)
 
 
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 test_deactivates_stale_no_deadline(self):
323
- """마감일 없이 7일 이상 발견되지 않은 공고는 비활성화."""
324
  conn = _make_in_memory_conn()
325
- upsert_job(conn, _sample_job(source_id="3", deadline_date=None))
 
 
326
  conn.commit()
327
- # updated_at을 8일 전으로 강제 설정
328
- conn.execute(
329
- "UPDATE jobs SET updated_at = datetime('now', '-8 days') WHERE source_id='3'"
330
- )
331
  conn.commit()
332
- result = deactivate_expired_jobs(conn)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
333
  conn.commit()
334
- assert result["by_staleness"] >= 1
335
- row = conn.execute("SELECT is_active FROM jobs WHERE source_id='3'").fetchone()
 
 
336
  assert row["is_active"] == 0
337
 
338
- def test_keeps_recent_no_deadline_active(self):
339
- """마감일 없어도 최근(1일)에 발견된 공고는 유지."""
340
  conn = _make_in_memory_conn()
341
- upsert_job(conn, _sample_job(source_id="4", deadline_date=None))
342
  conn.commit()
343
  deactivate_expired_jobs(conn)
344
  conn.commit()
345
- row = conn.execute("SELECT is_active FROM jobs WHERE source_id='4'").fetchone()
346
  assert row["is_active"] == 1
347
 
348
- def test_already_inactive_not_double_counted(self):
349
- """이미 비활성인 공고는 카운트에 포함되지 않음 (rowcount=0)."""
350
  conn = _make_in_memory_conn()
351
- upsert_job(conn, _sample_job(source_id="5", deadline_date="2020-01-01"))
352
- conn.execute("UPDATE jobs SET is_active=0 WHERE source_id='5'")
353
  conn.commit()
354
- result = deactivate_expired_jobs(conn)
355
- # 이미 0인 행은 UPDATE 영향 없음
356
- assert result["by_deadline"] == 0
357
- assert result["by_staleness"] == 0
358
 
359
- def test_returns_correct_counts(self):
360
- """반환 딕셔너리에 by_deadline / by_staleness 키가 있음."""
361
  conn = _make_in_memory_conn()
362
  result = deactivate_expired_jobs(conn)
363
- assert "by_deadline" in result
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)