Spaces:
Runtime error
Runtime error
| # ============================================================ | |
| # 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("λ―Έμ²λ¦¬ κ³΅μ§ μμ") | |