sangsangfinder / crawling /notice_processor.py
cksleigen's picture
Initial clean deploy
54656fc
Raw
History Blame Contribute Delete
19.5 kB
# ============================================================
# notice_processor.py β€” μ‹ κ·œ 곡지 Gemini λΆ„λ₯˜ + μž„λ² λ”© 계산
# 크둀러 νŒŒμ΄ν”„λΌμΈ: auto_crawler.py μ‹€ν–‰ ν›„ μžλ™ μ‹€ν–‰
# ============================================================
import os
import re
import json
import math
import time
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from datetime import datetime
from supabase import create_client
import google.generativeai as genai
from sentence_transformers import SentenceTransformer
SUPABASE_URL = os.getenv("SUPABASE_URL")
SUPABASE_KEY = os.getenv("SUPABASE_KEY")
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
TWO_TOWER_MODEL_PATH = os.getenv("TWO_TOWER_MODEL_PATH", "models/two_tower_model_v4.pt")
BASE_MODEL_EMBED = "jhgan/ko-sroberta-multitask"
BASE_DATE = datetime(2026, 4, 30)
supabase = create_client(SUPABASE_URL, SUPABASE_KEY)
genai.configure(api_key=GEMINI_API_KEY)
gemini = genai.GenerativeModel(
"gemini-2.5-flash",
generation_config={"response_mime_type": "application/json"}
)
def clean_for_postgres(value):
"""Postgres text/jsonb values cannot contain literal NUL bytes."""
if isinstance(value, str):
return value.replace("\x00", "")
if isinstance(value, list):
return [clean_for_postgres(item) for item in value]
if isinstance(value, dict):
return {key: clean_for_postgres(item) for key, item in value.items()}
return value
# ── μΉ΄ν…Œκ³ λ¦¬ μ •μ˜ ─────────────────────────────────────────────
VALID_CATEGORIES = [
"μ·¨μ—…/μ±„μš©", "학사행정", "ν•™μƒν™œλ™/비ꡐ과",
"λŒ€μ™Έν™œλ™", "곡λͺ¨μ „/κ²½μ§„λŒ€νšŒ", "ꡭ제ꡐλ₯˜", "μ°½μ—…",
"μž₯ν•™κΈˆ", "κΈ°μˆ™μ‚¬", "ROTC"
]
CATEGORY_TYPE_MAP = {
"μ·¨μ—…/μ±„μš©": ["경영/금육/사무", "IT/정보톡신", "λ””μžμΈ/예술/방솑", "곡학/기술", "ꡐ윑/법λ₯ /곡곡", "κ΅λ‚΄μ±„μš©"],
"ν•™μƒν™œλ™/비ꡐ과": ["IT/AI/SW", "μ§„λ‘œ/μ·¨μ—…/ν˜„μž₯μ‹€μŠ΅", "λ””μžμΈ/μ½˜ν…μΈ ", "심리/상담/μžκΈ°κ³„λ°œ", "인문/μ–΄ν•™", "행사/μƒν™œ"],
"곡λͺ¨μ „/κ²½μ§„λŒ€νšŒ": ["IT/AI/SW", "μ°½μ—…/아이디어", "λ””μžμΈ/μ½˜ν…μΈ ", "κΈ€μ“°κΈ°/λ°œν‘œ/μ–΄ν•™", "μ •μ±…/μ‚¬νšŒ/ESG"],
"μ°½μ—…": ["μ°½μ—…"],
"ꡭ제ꡐλ₯˜": ["κ΅ν™˜ν•™μƒ/파견", "해외인턴/ν˜„μž₯μ‹€μŠ΅", "ν•΄μ™Έμ—°μˆ˜", "외ꡭ인학생/κΈ€λ‘œλ²Œκ΅λ₯˜", "해외봉사"],
"학사행정": ["μˆ˜μ—…/μˆ˜κ°•", "전곡/νŠΈλž™/학적", "성적/μ‘Έμ—…", "μ‹œν—˜/학사일정", "μ‹œμ„€/μ‹œμŠ€ν…œ/ν–‰μ •"],
"μž₯ν•™κΈˆ": ["μž₯ν•™κΈˆ"],
"λŒ€μ™Έν™œλ™": ["λ΄‰μ‚¬ν™œλ™", "λ©˜ν† λ§", "μ„œν¬ν„°μ¦ˆ/ν™λ³΄λŒ€μ‚¬", "기획/λ―Έλ””μ–΄"],
"ROTC": ["ROTC"],
"κΈ°μˆ™μ‚¬": ["κΈ°μˆ™μ‚¬"],
}
HALF_LIFE_MAP = {
"μ·¨μ—…/μ±„μš©": 7,
"곡λͺ¨μ „/κ²½μ§„λŒ€νšŒ": 7,
"μž₯ν•™κΈˆ": 7,
"ν•™μƒν™œλ™/비ꡐ과": 14,
"λŒ€μ™Έν™œλ™": 14,
"ꡭ제ꡐλ₯˜": 14,
"μ°½μ—…": 14,
"ROTC": 30,
"κΈ°μˆ™μ‚¬": 30,
"학사행정": 30,
}
# ── notice_score 계산 ─────────────────────────────────────────
def calc_notice_score(notice: dict) -> float:
try:
date_str = str(notice.get('posted_at') or notice.get('date', ''))[:10].replace('.', '-')
posted = datetime.strptime(date_str, '%Y-%m-%d')
days = max((datetime.now() - posted).days, 1)
except:
days = 999
views = notice.get('views', 0) or 0
category = notice.get('category', '기타')
h = HALF_LIFE_MAP.get(category, 14)
alpha = math.log(2) / h
return round(math.log(views + 1) * math.exp(-alpha * days), 4)
# ── Gemini μΉ΄ν…Œκ³ λ¦¬ λΆ„λ₯˜ ──────────────────────────────────────
def classify_with_gemini(title: str, body: str, max_retry: int = 3) -> dict:
prompt = f"""
ν•œμ„±λŒ€ν•™κ΅ 곡지사항을 μ•„λž˜ μΉ΄ν…Œκ³ λ¦¬ 쀑 ν•˜λ‚˜λ‘œ λΆ„λ₯˜ν•˜κ³ , μ„ΈλΆ€ μœ ν˜•λ„ λΆ„λ₯˜ν•΄.
λ°˜λ“œμ‹œ μ•„λž˜ 'μΉ΄ν…Œκ³ λ¦¬' λͺ©λ‘μ— μžˆλŠ” μ •ν™•ν•œ λ‹¨μ–΄λ§Œ μ‚¬μš©ν•΄.
μΉ΄ν…Œκ³ λ¦¬: {json.dumps(VALID_CATEGORIES, ensure_ascii=False)}
μΉ΄ν…Œκ³ λ¦¬λ³„ μ„ΈλΆ€ μœ ν˜•: {json.dumps(CATEGORY_TYPE_MAP, ensure_ascii=False)}
곡지 제λͺ©: {title}
곡지 λ³Έλ¬Έ: {(body or '')[:300]}
μ•„λž˜ JSON ν˜•μ‹μœΌλ‘œλ§Œ 좜λ ₯ν•΄:
{{"category": "μΉ΄ν…Œκ³ λ¦¬λͺ…", "category_type": ["μ„ΈλΆ€μœ ν˜•1"]}}
"""
for attempt in range(max_retry):
try:
res = gemini.generate_content(prompt)
raw_text = res.text.strip()
# λ§ˆν¬λ‹€μš΄ 찌꺼기 제거
if raw_text.startswith("```"):
raw_text = re.sub(r"^```(?:json)?\s*", "", raw_text)
raw_text = re.sub(r"\s*```$", "", raw_text).strip()
result = json.loads(raw_text)
category = result.get('category', '').strip()
if category in VALID_CATEGORIES:
return result
else:
print(f" [디버깅] λͺ©λ‘μ— μ—†λŠ” μΉ΄ν…Œκ³ λ¦¬ λ°˜ν™˜λ¨: '{category}' (μ‹œλ„ {attempt+1}/{max_retry})")
except json.JSONDecodeError as e:
print(f" [디버깅] JSON νŒŒμ‹± μ‹€νŒ¨ (μ‹œλ„ {attempt+1}/{max_retry}): {e} | 원본: {res.text[:100]}")
except Exception as e:
print(f" Gemini λΆ„λ₯˜ μ‹€νŒ¨ (μ‹œλ„ {attempt+1}/{max_retry}): {e}")
time.sleep(3)
return {"category": "기타", "category_type": []}
# ── μž₯ν•™κΈˆ νŒŒμ‹± ───────────────────────────────────────────────
def parse_scholarship(title: str, body: str, max_retry: int = 3) -> dict:
SEOUL_GU = ["μ’…λ‘œκ΅¬","쀑ꡬ","μš©μ‚°κ΅¬","성동ꡬ","광진ꡬ","λ™λŒ€λ¬Έκ΅¬","μ€‘λž‘κ΅¬","성뢁ꡬ","강뢁ꡬ","도봉ꡬ","노원ꡬ","은평ꡬ","μ„œλŒ€λ¬Έκ΅¬","마포ꡬ","μ–‘μ²œκ΅¬","κ°•μ„œκ΅¬","ꡬ둜ꡬ","금천ꡬ","μ˜λ“±ν¬κ΅¬","λ™μž‘κ΅¬","관악ꡬ","μ„œμ΄ˆκ΅¬","강남ꡬ","μ†‘νŒŒκ΅¬","강동ꡬ"]
GYEONGGI_SI = ["μˆ˜μ›μ‹œ","μš©μΈμ‹œ","κ³ μ–‘μ‹œ","ν™”μ„±μ‹œ","μ„±λ‚¨μ‹œ","λΆ€μ²œμ‹œ","λ‚¨μ–‘μ£Όμ‹œ","μ•ˆμ‚°μ‹œ","ν‰νƒμ‹œ","μ•ˆμ–‘μ‹œ","μ‹œν₯μ‹œ","νŒŒμ£Όμ‹œ","κΉ€ν¬μ‹œ","μ˜μ •λΆ€μ‹œ","κ΄‘μ£Όμ‹œ","ν•˜λ‚¨μ‹œ","μ–‘μ£Όμ‹œ","κ΄‘λͺ…μ‹œ","κ΅°ν¬μ‹œ","μ˜€μ‚°μ‹œ","μ΄μ²œμ‹œ","μ•ˆμ„±μ‹œ","κ΅¬λ¦¬μ‹œ","ν¬μ²œμ‹œ","μ˜μ™•μ‹œ","양평ꡰ","μ—¬μ£Όμ‹œ","λ™λ‘μ²œμ‹œ","κ³Όμ²œμ‹œ","가평ꡰ","μ—°μ²œκ΅°"]
OTHER_REGIONS = ["λΆ€μ‚°κ΄‘μ—­μ‹œ","λŒ€κ΅¬κ΄‘μ—­μ‹œ","μΈμ²œκ΄‘μ—­μ‹œ","κ΄‘μ£Όκ΄‘μ—­μ‹œ","λŒ€μ „κ΄‘μ—­μ‹œ","μšΈμ‚°κ΄‘μ—­μ‹œ","μ„Έμ’…νŠΉλ³„μžμΉ˜μ‹œ","κ°•μ›νŠΉλ³„μžμΉ˜λ„","좩청뢁도","좩청남도","μ „λΆνŠΉλ³„μžμΉ˜λ„","전라남도","경상뢁도","경상남도","μ œμ£ΌνŠΉλ³„μžμΉ˜λ„"]
seoul_list = ", ".join([f'"μ„œμšΈνŠΉλ³„μ‹œ {g}"' for g in SEOUL_GU])
gyeonggi_list = ", ".join([f'"경기도 {s}"' for s in GYEONGGI_SI])
other_list = ", ".join([f'"{r}"' for r in OTHER_REGIONS])
prompt = f"""
μ•„λž˜ ν•œμ„±λŒ€ν•™κ΅ μž₯ν•™κΈˆ 곡지λ₯Ό λΆ„μ„ν•΄μ„œ JSON으둜 λ°˜ν™˜ν•΄.
곡지 제λͺ©: {title}
곡지 λ³Έλ¬Έ: {(body or '')[:500]}
μ•„λž˜ JSON ν˜•μ‹μœΌλ‘œλ§Œ 좜λ ₯ν•΄:
{{
"is_application": true λ˜λŠ” false,
"type": "ꡐ내μž₯ν•™κΈˆ λ˜λŠ” κ΅­κ°€μž₯ν•™κΈˆ λ˜λŠ” ꡐ외μž₯ν•™κΈˆ λ˜λŠ” 근둜μž₯ν•™κΈˆ 쀑 ν•˜λ‚˜",
"income_max": null λ˜λŠ” 숫자 (μ†Œλ“λΆ„μœ„ μ œν•œ 있으면 숫자, μ—†μœΌλ©΄ null),
"income_required": true λ˜λŠ” false,
"income_priority_max": null λ˜λŠ” 숫자,
"end_date": null λ˜λŠ” "MM-DD" ν˜•μ‹,
"end_date_type": "λͺ…μ‹œ" λ˜λŠ” "μƒμ‹œ" λ˜λŠ” "λ―Έμ •",
"target_status": ["μž¬ν•™"] λ˜λŠ” ["μž¬ν•™", "νœ΄ν•™"] λ“±,
"target_grade": [1, 2, 3, 4] λ˜λŠ” νŠΉμ • ν•™λ…„λ§Œ,
"min_gpa": null λ˜λŠ” 숫자,
"region": μ§€μ—­ 쑰건 μ—†μœΌλ©΄ null, 있으면 μ•„λž˜ λͺ©λ‘ 쀑 μ •ν™•νžˆ ν•˜λ‚˜λ§Œ 선택:
μ„œμšΈ 전체: "μ„œμšΈνŠΉλ³„μ‹œ"
μ„œμšΈ νŠΉμ • ꡬ: {seoul_list}
κ²½κΈ° 전체: "경기도"
κ²½κΈ° νŠΉμ • μ‹œκ΅°: {gyeonggi_list}
기타 κ΄‘μ—­μ‹œλ„: {other_list},
"extra_info": "기타 μ€‘μš” 정보 ν•œ 쀄 μš”μ•½"
}}
is_application νŒλ‹¨ κΈ°μ€€:
- true: μ‹€μ œ μž₯ν•™κΈˆ 신청을 λ°›λŠ” 곡고 (μ‹ μ²­κΈ°κ°„, 신청방법, μ§€μ›μžκ²© 등이 λͺ…μ‹œλœ 경우)
- false: μž₯ν•™κΈˆ κ΄€λ ¨ μ•ˆλ‚΄κ³΅μ§€ (수혜자 λ°œν‘œ, μ§€κΈ‰ μ•ˆλ‚΄, μ œλ„ μ†Œκ°œ λ“±)
region κ·œμΉ™:
- μ§€μ—­ 쑰건이 μ—†μœΌλ©΄ λ°˜λ“œμ‹œ null
- 있으면 λ°˜λ“œμ‹œ μœ„ λͺ©λ‘ 쀑 ν•˜λ‚˜λ§Œ 선택 (μž„μ˜ ν˜•μ‹ μ‚¬μš© κΈˆμ§€)
"""
for attempt in range(max_retry):
try:
res = gemini.generate_content(prompt)
result = json.loads(res.text)
return result
except Exception as e:
print(f" μž₯ν•™κΈˆ νŒŒμ‹± μ‹€νŒ¨ (μ‹œλ„ {attempt+1}/{max_retry}): {e}")
time.sleep(3)
return None
# ── κΈ°μˆ™μ‚¬ νŒŒμ‹± ───────────────────────────────────────────────
def parse_dormitory(title: str, body: str, max_retry: int = 3) -> dict:
prompt = f"""
μ•„λž˜ ν•œμ„±λŒ€ν•™κ΅ κΈ°μˆ™μ‚¬ 곡지λ₯Ό λΆ„μ„ν•΄μ„œ JSON으둜 λ°˜ν™˜ν•΄.
곡지 제λͺ©: {title}
곡지 λ³Έλ¬Έ: {(body or '')[:500]}
μ•„λž˜ JSON ν˜•μ‹μœΌλ‘œλ§Œ 좜λ ₯ν•΄:
{{
"name": "κΈ°μˆ™μ‚¬ 이름 (μƒμƒλΉŒλ¦¬μ§€/μš°μ΄Œν•™μ‚¬/λ™μ†Œλ¬Έν–‰λ³΅κΈ°μˆ™μ‚¬/μ—ν”Όμ†Œλ“œ/μž„λŒ€κΈ°μˆ™μ‚¬ 쀑 ν•˜λ‚˜)",
"start_date": null λ˜λŠ” "YYYY-MM-DD",
"end_date": null λ˜λŠ” "YYYY-MM-DD",
"end_date_type": "λͺ…μ‹œ" λ˜λŠ” "λ―Έμ •",
"male_quota": null λ˜λŠ” 숫자,
"female_quota": null λ˜λŠ” 숫자,
"target_grade": [1, 2, 3, 4] λ˜λŠ” νŠΉμ • ν•™λ…„λ§Œ,
"extra_info": "기타 μ€‘μš” 정보 ν•œ 쀄 μš”μ•½"
}}
"""
for attempt in range(max_retry):
try:
res = gemini.generate_content(prompt)
result = json.loads(res.text)
return result
except Exception as e:
print(f" κΈ°μˆ™μ‚¬ νŒŒμ‹± μ‹€νŒ¨ (μ‹œλ„ {attempt+1}/{max_retry}): {e}")
time.sleep(3)
return None
# ── Two-Tower λͺ¨λΈ λ‘œλ“œ ───────────────────────────────────────
def load_model():
device = torch.device('cpu')
sbert = SentenceTransformer(BASE_MODEL_EMBED, device="cpu")
HIDDEN_DIM = 256
OUTPUT_DIM = 128
SCORE_DIM = 16
class TwoTowerModel(nn.Module):
def __init__(self):
super().__init__()
self.user_tower = nn.Sequential(
nn.Linear(768, HIDDEN_DIM), nn.ReLU(),
nn.Dropout(0.2), nn.Linear(HIDDEN_DIM, OUTPUT_DIM)
)
self.item_text_layer = nn.Sequential(nn.Linear(768, HIDDEN_DIM), nn.ReLU())
self.item_score_layer = nn.Sequential(nn.Linear(1, SCORE_DIM), nn.ReLU())
self.item_final_layer = nn.Sequential(
nn.Dropout(0.2), nn.Linear(HIDDEN_DIM + SCORE_DIM, OUTPUT_DIM)
)
def forward_item(self, x):
text_emb = x[:, :-1]
score_emb = x[:, -1:].float()
text_vec = self.item_text_layer(text_emb)
score_vec = self.item_score_layer(score_emb)
return F.normalize(self.item_final_layer(
torch.cat([text_vec, score_vec], dim=-1)), dim=-1)
model = TwoTowerModel().to(device)
if os.path.exists(TWO_TOWER_MODEL_PATH):
model.load_state_dict(torch.load(TWO_TOWER_MODEL_PATH, map_location=device))
model.eval()
return sbert, model, device
# ── μž„λ² λ”© 계산 ───────────────────────────────────────────────
def calc_embedding(notice: dict, sbert, model, device, notice_score: float) -> list:
notice_text = f"{notice.get('category','')} {notice.get('title','')} {(notice.get('body') or '')[:100]}"
text_emb = sbert.encode([notice_text], convert_to_numpy=True)
score_norm = min(notice_score / 5.0, 1.0)
item_emb = np.concatenate([text_emb[0], [score_norm]])
item_tensor = torch.tensor(item_emb, dtype=torch.float).unsqueeze(0).to(device)
with torch.no_grad():
item_vec = model.forward_item(item_tensor).cpu().numpy()[0]
return item_vec.tolist()
# ── μž₯ν•™κΈˆ ν…Œμ΄λΈ” μ €μž₯ ────────────────────────────────────────
def save_scholarship(notice_id: str, title: str, body: str, posted_at: str):
print(f" β†’ μž₯ν•™κΈˆ νŒŒμ‹± 쀑...")
result = parse_scholarship(title, body)
if not result:
print(f" β†’ μž₯ν•™κΈˆ νŒŒμ‹± μ‹€νŒ¨")
return
end_date = result.get('end_date')
if end_date:
year = (posted_at or '2026')[:4]
end_date = f"{year}-{end_date}"
try:
supabase.table("scholarships").insert({
"notice_id": notice_id,
"type": result.get('type'),
"income_max": result.get('income_max'),
"income_required": result.get('income_required', False),
"income_priority_max": result.get('income_priority_max'),
"end_date": end_date,
"end_date_type": result.get('end_date_type', 'λ―Έμ •'),
"target_status": json.dumps(result.get('target_status', ['μž¬ν•™']), ensure_ascii=False),
"target_grade": json.dumps(result.get('target_grade', [1,2,3,4]), ensure_ascii=False),
"min_gpa": result.get('min_gpa'),
"region": result.get('region'),
"extra_info": result.get('extra_info'),
"is_application": result.get('is_application', False),
}).execute()
is_app = result.get('is_application', False)
print(f" β†’ μž₯ν•™κΈˆ μ €μž₯ μ™„λ£Œ (is_application={is_app})")
except Exception as e:
print(f" β†’ μž₯ν•™κΈˆ μ €μž₯ μ‹€νŒ¨: {e}")
# ── κΈ°μˆ™μ‚¬ ν…Œμ΄λΈ” μ €μž₯ ────────────────────────────────────────
def save_dormitory(notice_id: str, title: str, body: str):
print(f" β†’ κΈ°μˆ™μ‚¬ νŒŒμ‹± 쀑...")
result = parse_dormitory(title, body)
if not result:
print(f" β†’ κΈ°μˆ™μ‚¬ νŒŒμ‹± μ‹€νŒ¨")
return
try:
supabase.table("dormitories").insert({
"notice_id": notice_id,
"name": result.get('name'),
"start_date": result.get('start_date'),
"end_date": result.get('end_date'),
"end_date_type": result.get('end_date_type', 'λ―Έμ •'),
"male_quota": result.get('male_quota'),
"female_quota": result.get('female_quota'),
"target_grade": json.dumps(result.get('target_grade', [1,2,3,4]), ensure_ascii=False),
"extra_info": result.get('extra_info'),
}).execute()
print(f" β†’ κΈ°μˆ™μ‚¬ μ €μž₯ μ™„λ£Œ (κ΄€λ¦¬μž 확인 ν•„μš”)")
except Exception as e:
print(f" β†’ κΈ°μˆ™μ‚¬ μ €μž₯ μ‹€νŒ¨: {e}")
# ── 메인 처리 ν•¨μˆ˜ ────────────────────────────────────────────
def process_notices(notices: list[dict]) -> int:
print(f"곡지 처리 μ‹œμž‘: {len(notices)}건")
sbert, model, device = load_model()
print("λͺ¨λΈ λ‘œλ“œ μ™„λ£Œ!")
success = 0
failed = 0
for i, notice in enumerate(notices):
try:
notice = clean_for_postgres(dict(notice))
title = notice.get('title', '')
body = notice.get('body', '') or ''
url = notice.get('url', '')
posted_at = str(notice.get('posted_at') or '')
print(f"\n[{i+1}/{len(notices)}] {title[:45]}")
# 1. Gemini λΆ„λ₯˜
classification = classify_with_gemini(title, body)
category = classification.get('category', '기타')
category_type = clean_for_postgres(classification.get('category_type', []))
notice['category'] = category
# 2. notice_score 계산
notice_score = calc_notice_score(notice)
# 3. μž„λ² λ”© 계산
embedding = calc_embedding(notice, sbert, model, device, notice_score)
# 4. notice_id μΆ”μΆœ
notice_id_match = re.search(r'/(\d+)/artclView\.do', url)
notice_id = notice_id_match.group(1) if notice_id_match else notice.get('notice_id')
# 5. Supabase notices μ—…λ°μ΄νŠΈ
payload = clean_for_postgres({
"source": "hansung",
"notice_id": notice_id,
"title": title,
"url": url,
"posted_at": posted_at[:10].replace('.', '-') if posted_at else None,
"posted_date_text": notice.get('date') or notice.get('posted_date_text'),
"category": category,
"category_type": category_type,
"job_types": category_type,
"body": body,
"views": notice.get('views', 0),
"notice_score": notice_score,
"embedding": json.dumps(embedding),
"raw": notice,
})
supabase.table("notices").upsert(payload, on_conflict="url").execute()
print(f" β†’ {category} {category_type} | score:{notice_score}")
# 6. μž₯ν•™κΈˆ/κΈ°μˆ™μ‚¬ μΆ”κ°€ νŒŒμ‹±
if category == 'μž₯ν•™κΈˆ' and notice_id:
save_scholarship(notice_id, title, body, posted_at)
elif category == 'κΈ°μˆ™μ‚¬' and notice_id:
save_dormitory(notice_id, title, body)
success += 1
except Exception as e:
print(f" μ‹€νŒ¨: {e}")
import traceback; traceback.print_exc()
failed += 1
time.sleep(2) # Gemini API rate limit λ°©μ§€
print(f"\n처리 μ™„λ£Œ! 성곡: {success}개 | μ‹€νŒ¨: {failed}개")
return success
# ── 단독 μ‹€ν–‰ ─────────────────────────────────────────────────
if __name__ == "__main__":
# category_type이 μ—†λŠ” μ‹ κ·œ κ³΅μ§€λ§Œ 처리
res = supabase.table("notices").select(
"id,notice_id,title,url,posted_at,body,views,category"
).is_("category_type", "null").order("posted_at", desc=True).limit(20).execute()
# category_type이 빈 배열인 것도 처리
res2 = supabase.table("notices").select(
"id,notice_id,title,url,posted_at,body,views,category,category_type"
).order("posted_at", desc=True).limit(100).execute()
unprocessed = [
n for n in (res2.data or [])
if not n.get('category_type')
]
all_notices = list({n['id']: n for n in (res.data or []) + unprocessed}.values())
if all_notices:
print(f"미처리 곡지 {len(all_notices)}건 발견")
process_notices(all_notices)
else:
print("미처리 곡지 μ—†μŒ")