import os, time, threading, json, re, requests, warnings, email.utils from datetime import datetime from zoneinfo import ZoneInfo from curl_cffi import requests as curl_requests from bs4 import BeautifulSoup, XMLParsedAsHTMLWarning from http.server import HTTPServer, BaseHTTPRequestHandler from huggingface_hub import HfApi, hf_hub_download from openai import OpenAI from groq import Groq warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning) # --- KONFIGURASI GROUNDED MEI 2026 --- MY_TZ = ZoneInfo("Asia/Kuala_Lumpur") PORT = int(os.getenv("PORT", "7860")) WEBHOOK_URL = os.getenv("DISCORD_WEBHOOK", "").strip() HF_TOKEN = os.getenv("HF_TOKEN", "").strip() GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY", "").strip() GROQ_API_KEY = os.getenv("GROQ_API_KEY", "").strip() NVIDIA_API_KEY = os.getenv("NVIDIA_API_KEY", "").strip() HF_DATASET = os.getenv("HFDATASET", "").strip() UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36" api_hf = HfApi(token=HF_TOKEN) nvidia_client = OpenAI(base_url="https://integrate.api.nvidia.com/v1", api_key=NVIDIA_API_KEY) google_client = OpenAI(api_key=GOOGLE_API_KEY, base_url="https://generativelanguage.googleapis.com/v1beta/openai/") groq_client = Groq(api_key=GROQ_API_KEY) FEEDS = [ {"name": "Amanz", "url": "https://cms.amanz.my/feed", "color": 15158332, "icon": "https://amanz.my/wp-content/uploads/2021/01/amanz-logo.png", "interval": 600, "use_proxy": True}, {"name": "Phys.org", "url": "https://phys.org/rss-feed/", "color": 27558, "icon": "https://i.imgur.com/8YvA5fG.png", "interval": 600, "use_proxy": True}, {"name": "Defense One", "url": "https://www.defenseone.com/rss/all/", "color": 10038562, "icon": "https://www.defenseone.com/favicon.ico", "interval": 600, "use_proxy": True}, {"name": "Defence Blog", "url": "https://defence-blog.com/feed/", "color": 7372944, "icon": "https://defence-blog.com/favicon.ico", "interval": 600, "use_proxy": True}, {"name": "Quanta Magazine", "url": "https://www.quantamagazine.org/feed/", "color": 1150410, "icon": "https://www.quantamagazine.org/favicon.ico", "interval": 600, "use_proxy": True} ] LOCAL_MEM = {} state_lock = threading.Lock() def log(msg): print(f"[{datetime.now(MY_TZ).strftime('%H:%M:%S')}] {msg}", flush=True) def clip(text, limit): text = str(text or "").strip(); return text if len(text) <= limit else text[:limit - 1] + "…" def safe_name(name): return re.sub(r"[^a-zA-Z0-9_.-]", "_", name) def normalize_link(url): return (url or "").strip().split("#")[0].split("?")[0].rstrip("/") def _resolve(url): return url.strip() def load_state(feed_name): fname = f"state_{safe_name(feed_name)}.json" with state_lock: if feed_name in LOCAL_MEM: return LOCAL_MEM[feed_name].copy() try: path = hf_hub_download(repo_id=HF_DATASET, filename=fname, repo_type="dataset", token=HF_TOKEN, force_download=True) with open(path, "r", encoding="utf-8") as f: data = json.load(f) log(f"šŸ“‚ {feed_name}: State loaded dari {path} | seen={len(data.get('seen_links',[]))} | last={data.get('seen_links',['?'])[-1][-8:] if data.get('seen_links') else 'kosong'}") with state_lock: LOCAL_MEM[feed_name] = data return data except: return {"seen_links": [], "etag": "", "last_modified": ""} def save_state(feed_name, state): fname = f"state_{safe_name(feed_name)}.json" with state_lock: LOCAL_MEM[feed_name] = state.copy() try: with open(fname, "w", encoding="utf-8") as f: json.dump(state, f, ensure_ascii=False, indent=2) api_hf.upload_file(path_or_fileobj=fname, path_in_repo=fname, repo_id=HF_DATASET, repo_type="dataset", token=HF_TOKEN) log(f"šŸ’¾ State {feed_name} disimpan.") except Exception as e: log(f"āŒ Gagal simpan state: {e}") # --- FETCH FEED YANG TELAH DIPERBAIKI --- def fetch_feed(feed, state): url = feed["url"] sep = "&" if "?" in url else "?" cache_buster = f"{sep}t={int(time.time())}" use_proxy_setting = feed.get("use_proxy", "auto") if use_proxy_setting is True: urls_to_try = [ {"url": f"https://corsproxy.io/?{url}{cache_buster}", "type": "Proxy (CORS Proxy)"}, {"url": f"https://api.allorigins.win/raw?url={url}{cache_buster}", "type": "Proxy (AllOrigins)"}, {"url": f"https://api.codetabs.com/v1/proxy?quest={url}{cache_buster}", "type": "Proxy (Codetabs)"}, {"url": f"https://api.rss2json.com/v1/api.json?rss_url={url}", "type": "Proxy (RSS2JSON)"}, {"url": url, "type": "Direct (Selesa)"} ] elif use_proxy_setting == "auto": urls_to_try = [ {"url": url, "type": "Direct"}, {"url": url + cache_buster, "type": "Direct (Cache Buster)"}, {"url": f"https://api.codetabs.com/v1/proxy?quest={url}{cache_buster}", "type": "Proxy (Codetabs)"}, {"url": f"https://corsproxy.io/?{url}{cache_buster}", "type": "Proxy (CORS Proxy)"}, {"url": f"https://api.allorigins.win/raw?url={url}{cache_buster}", "type": "Proxy (AllOrigins)"} ] else: urls_to_try = [ {"url": url, "type": "Direct"}, {"url": url + cache_buster, "type": "Direct (Cache Buster)"} ] for item in urls_to_try: try_url = item["url"] type_url = item["type"] log(f"šŸ” {feed['name']}: Mencuba {type_url} ({try_url})") try: headers = {} if type_url in ("Direct", "Direct (Selesa)") and state.get("etag"): headers["If-None-Match"] = state["etag"] r = curl_requests.get(try_url, headers=headers, impersonate="chrome124", timeout=30) if r.status_code == 304: log(f"šŸ”„ {feed['name']}: {type_url} 304, cuba URL seterusnya...") continue if r.status_code == 200: # FIX 4: Validate response — pastikan XML/RSS, bukan HTML Cloudflare content_text = r.text[:500].strip().lower() if "'] for ji in jdata["items"]: xml_parts.append("") xml_parts.append(f"{ji.get('title','')}") xml_parts.append(f"{ji.get('link','')}") xml_parts.append(f"") if ji.get("thumbnail"): xml_parts.append(f'') elif ji.get("enclosure",{}).get("link"): xml_parts.append(f'') xml_parts.append("") xml_parts.append("") class XmlResponse: def __init__(self, content): self.content = content self.status_code = 200 r = XmlResponse("".join(xml_parts).encode("utf-8")) log(f"šŸ”„ {feed['name']}: RSS2JSON → XML converted ({len(jdata['items'])} items)") except Exception as e: log(f"āš ļø {feed['name']}: RSS2JSON parse error: {e}") return r, state, "ok" log(f"āš ļø {feed['name']}: {type_url} beri respon {r.status_code}") except Exception as e: log(f"āŒ {feed['name']}: {type_url} timeout/error ({type(e).__name__})") return None, state, "failed" # --- EKSTRAKSI GAMBAR --- def extract_image(raw): media = raw.find("media:thumbnail") or raw.find("thumbnail") or \ raw.find("media:content") or raw.find("content") or \ raw.find("enclosure") if media and media.get("url"): log(f"šŸ–¼ļø Gambar jumpa (media): {media['url']}") return media["url"] desc_tag = raw.find("description") content_tag = raw.find("content:encoded") or raw.find("encoded") content_text = "" for tag in [content_tag, desc_tag]: if tag and tag.text: content_text += tag.text soup = BeautifulSoup(tag.text, "html.parser") img_tag = soup.find("img") if img_tag and img_tag.get("src"): log(f"šŸ–¼ļø Gambar jumpa (HTML): {img_tag['src']}") return img_tag["src"] if content_text: imgs = re.findall(r']+(?:src|data-src|data-lazy|data-original)\s*=\s*["\']([^"\'>\s]+)["\'][^>]*>', content_text, re.I) if imgs: priority = [u for u in imgs if any(x in u.lower() for x in ["amanz", "wp-content", "scx2.b-cdn.net", "phys"])] chosen = priority[-1].strip() if priority else imgs[-1].strip() return _resolve(chosen) link_tag = raw.find("link") link = link_tag.text if link_tag else "" if link and "http" in link: try: base = link.split("/")[0] + "//" + link.split("/")[2] return base + "/favicon.ico" except: pass return None # --- AI RUMUSAN --- def jana_rumusan_ai(tajuk, teks, nama_sumber): is_english = nama_sumber in ["Phys.org", "Defense One", "Defence Blog", "Quanta Magazine"] if not is_english: log(f"šŸ‡²šŸ‡¾ {nama_sumber}: BM detected, skip AI") return tajuk, teks or "Tiada ringkasan tersedia." log(f"🌐 {nama_sumber}: English detected, hantar ke AI...") prompt = f"""Kau adalah seorang penulis portal berita teknologi dan sains yang popular di Malaysia. Gaya penulisan kau santai, 'cool', dan senang difahami oleh orang muda tapi tetap padat dengan info. Tugas kau: 1. Terjemahkan tajuk ke Bahasa Melayu yang 'catchy' dan santai. 2. Buat rumusan 4-6 ayat. Jangan guna bahasa skema sangat (elakkan perkataan macam 'adalah', 'merupakan', 'tersebut'). 3. Gunakan bahasa percakapan profesional (contoh: guna 'buatkan' bukan 'menyebabkan', 'bagitau' bukan 'memaklumkan'). 4. Fokus terus kepada poin paling 'power' atau penting dalam berita ni. 5. Pastikan flow ayat tu nampak 'natural' macam kawan tengah ceritakan berita menarik kat kawan lain. Sumber berita: {nama_sumber} Tajuk Asal: {tajuk} Kandungan: {teks[:1200]} Balas dalam format TEPAT ini sahaja: TAJUK: [tajuk dalam BM] RUMUSAN: [4-6 ayat ringkasan padat dalam BM]""" def parse_hasil(hasil): tajuk_baru = tajuk rumusan = teks[:900] for line in hasil.split("\n"): line = line.strip() if line.upper().startswith("TAJUK:"): extracted = line[6:].strip() if extracted: tajuk_baru = extracted log(f"āœ… Tajuk BM: {clip(tajuk_baru, 50)}") elif line.upper().startswith("RUMUSAN:"): extracted = line[8:].strip() if extracted: rumusan = extracted log(f"āœ… Rumusan: {clip(rumusan, 80)}") return tajuk_baru, rumusan models = [ {"client": nvidia_client, "name": "google/gemma-4-31b-it", "label": "NVIDIA", "timeout": 45, "retries": 2}, {"client": google_client, "name": "gemma-4-26b-a4b-it", "label": "Google", "timeout": 35, "retries": 1}, {"client": groq_client, "name": "openai/gpt-oss-120b", "label": "Groq", "timeout": 25, "retries": 1} ] for m in models: for attempt in range(m["retries"]): try: log(f"šŸ¤– [{m['label']}] Cubaan {attempt+1}/{m['retries']}...") res = m["client"].chat.completions.create( model=m["name"], messages=[{"role": "user", "content": prompt}], timeout=m["timeout"] ) return parse_hasil(res.choices[0].message.content) except Exception as e: err_type = type(e).__name__ msg = str(e).lower() reject_keywords = [ "500", "internal", "bad gateway", "502", "503", "429", "rate limit", "too many", "overloaded", "quota", "balance", "insufficient", "credit", "capacity", "service_unavailable" ] if any(x in msg for x in reject_keywords): log(f"šŸ’„ {m['label']} REJECT: ({msg[:40]}). Server taknak layan, skip terus!") break if any(x in msg or x in err_type.lower() for x in ["timeout", "timed out", "deadline"]): if attempt + 1 < m["retries"]: log(f"ā³ {m['label']} LAMBAT: AI tengah fikir. Cuba sekali terakhir...") time.sleep(3) continue else: log(f"šŸ›‘ {m['label']} GAGAL: Dah tunggu lama pun senyap. Pass ke model lain...") break log(f"āš ļø {m['label']} Error: {msg[:50]}") break log("āŒ Semua AI dah mampus/reject. Guna teks asal.") return tajuk, teks # --- DISCORD PADU --- def hantar_discord(payload, image_url=None): try: url = WEBHOOK_URL.replace("discord.com", "discordapp.com") headers = {"User-Agent": UA} if image_url: try: # STEP 1: Cuba direct embed dulu payload_try = json.loads(json.dumps(payload)) if "embeds" in payload_try and payload_try["embeds"]: payload_try["embeds"][0]["image"] = {"url": image_url} resp = requests.post(url, json=payload_try, headers=headers, timeout=20) if resp.status_code in (200, 204): log(f"šŸ–¼ļø Gambar embed URL berjaya: {image_url[:80]}") return True log(f"āš ļø Direct embed gagal ({resp.status_code}), cuba upload file...") # STEP 2: Download guna curl_cffi rtest = curl_requests.get(image_url, impersonate="chrome124", timeout=15) log(f"šŸ“„ Download gambar: status={rtest.status_code}, CT={rtest.headers.get('Content-Type','?')}, size={len(rtest.content)//1024}KB") if rtest.status_code == 200 and "image" in rtest.headers.get("Content-Type", "").lower(): data = rtest.content if data.startswith(b'\x89PNG\r\n\x1a\n'): ext, mime_type = "png", "image/png" elif data.startswith(b'\xff\xd8\xff'): ext, mime_type = "jpg", "image/jpeg" elif data.startswith(b'GIF87a') or data.startswith(b'GIF89a'): ext, mime_type = "gif", "image/gif" elif data.startswith(b'RIFF') and b'WEBP' in data[8:14]: ext, mime_type = "webp", "image/webp" else: ct = rtest.headers.get("Content-Type", "").lower() ext = "webp" if "webp" in ct else "png" if "png" in ct else "gif" if "gif" in ct else "jpg" mime_type = f"image/{ext}" if ext != "jpg" else "image/jpeg" if len(rtest.content) < 5000: log(f"āš ļø Gambar terlalu kecil ({len(rtest.content)}B), skip") elif len(rtest.content) > 10 * 1024 * 1024: log(f"āš ļø Gambar besar ({len(rtest.content)//1024//1024}MB). Menggunakan Direct URL untuk bypass had Discord.") if "embeds" in payload and payload["embeds"]: payload["embeds"][0]["image"] = {"url": image_url} resp = requests.post( url, data={"payload_json": json.dumps(payload)}, headers=headers, timeout=30 ) if resp.status_code in (200, 204): log(f"šŸ–¼ļø Gambar besar berjaya dipaparkan (Direct URL)") return True else: log(f"āŒ Discord reject: {resp.status_code}") else: if "embeds" in payload and payload["embeds"]: payload["embeds"][0]["image"] = {"url": f"attachment://img.{ext}"} resp = requests.post( url, data={"payload_json": json.dumps(payload)}, files={"file": (f"img.{ext}", rtest.content, mime_type)}, headers=headers, timeout=30 ) if resp.status_code in (200, 204): log(f"šŸ–¼ļø Gambar upload file berjaya ({ext}, {len(rtest.content)//1024}KB)") return True else: log(f"āŒ Discord reject: {resp.status_code} {resp.text[:150]}") else: log(f"āŒ Gambar download gagal: status={rtest.status_code}, CT={rtest.headers.get('Content-Type','?')}") except Exception as e: log(f"āš ļø Gambar error: {e}") if image_url: log("āš ļø Gagal render gambar. Guna cara malas: paste URL terus kat mesej.") payload["content"] = f"{payload.get('content', '')}\n\nšŸ–¼ļø {image_url}" if "embeds" in payload and payload["embeds"] and "image" in payload["embeds"][0]: del payload["embeds"][0]["image"] return requests.post(url, json=payload, headers=headers, timeout=20).status_code in (200, 204) except: return False # --- CORE LOGIC --- def process_feed(feed): state = load_state(feed['name']) if "seen_links" not in state: state["seen_links"] = [] if state.get("last_link"): state["seen_links"].append(normalize_link(state["last_link"])) seen_links = state["seen_links"] r, state, status = fetch_feed(feed, state) if status == "not_modified": log(f"šŸ”„ {feed['name']}: Tiada perubahan.") return if status != "ok" or r is None: log(f"āš ļø {feed['name']}: Gagal fetch (Status: {status})") return soup = BeautifulSoup(r.content, "xml") items = soup.find_all("item") log(f"šŸ”¢ {feed['name']}: XML parsed, {len(items)} items dijumpai") new_found = [] for item in items: link = normalize_link(item.find("link").text if item.find("link") else "") if not link: continue is_seen = link in seen_links log(f"šŸ”Ž {feed['name']}: {link[-8:]} → {'SKIP' if is_seen else 'BARU'}") if is_seen: continue new_found.append({"title": item.find("title").text if item.find("title") else "Tiada Tajuk", "link": link, "desc": item.find("description").text if item.find("description") else "", "raw": item}) if not new_found: log(f"āœ… {feed['name']}: Tiada berita baru (Semua dah pernah dihantar).") return # --- FIX 1: LOGIK ANTI-SPAM SMART (HAFAL LAMA, HANTAR 7 TERBARU) --- if len(new_found) > 20: log(f"āš ļø {feed['name']}: Transisi dikesan! Jumpa {len(new_found)} berita tunggakan.") tunggakan = new_found[:-7] for art in tunggakan: if art["link"] not in seen_links: seen_links.append(art["link"]) log(f"šŸ“ {feed['name']}: {len(tunggakan)} artikel lama dihafal, {min(7, len(new_found))} terbaru akan dihantar.") if len(seen_links) > 50: seen_links = seen_links[-50:] state["seen_links"] = seen_links save_state(feed["name"], state) new_found = new_found[-7:] # ----------------------------------------------- log(f"šŸ“° {feed['name']}: Jumpa {len(new_found)} berita baru!") new_found.reverse() to_process = new_found[:7] for art in to_process: # ===================================================== # FIX 2: AMANZ — Embed style + gambar upload dari RSS # ===================================================== if feed["name"] == "Amanz": img_url = extract_image(art['raw']) desc_clean = BeautifulSoup(art["desc"], "html.parser").get_text(" ").strip() log(f"šŸ”— Amanz: {art['title'][:50]} | img={img_url[:60] if img_url else 'None'}") payload = { "content": clip(f"šŸ“° {feed['name']}: {art['title']}", 200), "embeds": [{ "author": {"name": feed["name"], "icon_url": feed["icon"]}, "title": clip(art["title"], 256), "url": art["link"], "description": clip(desc_clean, 900), "color": feed["color"], "footer": {"text": f"šŸ“° Amanz • {datetime.now(MY_TZ).strftime('%H:%M')}"} }] } sent = False if img_url: try: rimg = curl_requests.get(img_url, impersonate="chrome124", timeout=15) if rimg.status_code == 200 and "image" in rimg.headers.get("Content-Type", "").lower() and len(rimg.content) >= 5000: data = rimg.content if data.startswith(b'\x89PNG\r\n\x1a\n'): ext, mime = "png", "image/png" elif data.startswith(b'\xff\xd8\xff'): ext, mime = "jpg", "image/jpeg" elif data.startswith(b'GIF8'): ext, mime = "gif", "image/gif" elif data.startswith(b'RIFF') and b'WEBP' in data[8:14]: ext, mime = "webp", "image/webp" else: ct = rimg.headers.get("Content-Type","").lower() ext = "webp" if "webp" in ct else "png" if "png" in ct else "gif" if "gif" in ct else "jpg" mime = f"image/{ext}" if ext != "jpg" else "image/jpeg" payload["embeds"][0]["image"] = {"url": f"attachment://amanz.{ext}"} wh_url = WEBHOOK_URL.replace("discord.com", "discordapp.com") resp = requests.post( wh_url, data={"payload_json": json.dumps(payload)}, files={"file": (f"amanz.{ext}", rimg.content, mime)}, headers={"User-Agent": UA}, timeout=30 ) # FIX 3: Tambah 201 — Discord kadang reply 201 Created if resp.status_code in (200, 201, 204): log(f"šŸ–¼ļø Amanz: Embed + gambar berjaya ({ext}, {len(rimg.content)//1024}KB)") sent = True else: log(f"āš ļø Amanz: Discord reject ({resp.status_code})") else: log(f"āš ļø Amanz: Gambar download gagal/kecil (status={rimg.status_code})") except Exception as e: log(f"āš ļø Amanz: Gambar error ({e})") if not sent: sent = hantar_discord(payload) if sent: seen_links.append(art["link"]) if len(seen_links) > 50: seen_links = seen_links[-50:] state["seen_links"] = seen_links if "last_link" in state: del state["last_link"] save_state(feed["name"], state) log(f"😓 Bot tidur 15 saat sebelum post seterusnya...") time.sleep(15) else: break continue # ===================================================== img_url = extract_image(art['raw']) desc_clean = BeautifulSoup(art["desc"], "html.parser").get_text(" ").strip() tajuk_ai, teks_ai = jana_rumusan_ai(art["title"], desc_clean, feed["name"]) payload = { "content": clip(f"šŸ“° {feed['name']}: {tajuk_ai}", 200), "embeds": [{ "author": {"name": feed["name"], "icon_url": feed["icon"]}, "title": clip(tajuk_ai, 256), "url": art["link"], "description": clip(teks_ai, 900), "color": feed["color"], "footer": {"text": f"✨ Dirumuskan oleh AI • {datetime.now(MY_TZ).strftime('%H:%M')}"} }] } if hantar_discord(payload, image_url=img_url): seen_links.append(art["link"]) if len(seen_links) > 50: seen_links = seen_links[-50:] state["seen_links"] = seen_links if "last_link" in state: del state["last_link"] save_state(feed["name"], state) log(f"😓 Bot tidur 15 saat sebelum post seterusnya...") time.sleep(15) else: break # --- RUNNER --- class H(BaseHTTPRequestHandler): def do_GET(self): self.send_response(200); self.end_headers(); self.wfile.write(b"Bot 2026 Hybrid Talking Active") def log_message(self, *args): pass if __name__ == "__main__": log("šŸš€ Bot Berita Padu 2026 Hybrid dimulakan...") threading.Thread(target=lambda: HTTPServer(("0.0.0.0", PORT), H).serve_forever(), daemon=True).start() for f in FEEDS: def loop(fd=f): log(f"šŸ“” Thread {fd['name']} aktif.") while True: try: process_feed(fd) except Exception as e: log(f"šŸ’„ Error {fd['name']}: {e}") time.sleep(fd.get("interval", 1800)) threading.Thread(target=loop, daemon=True).start() while True: time.sleep(60)