berita-bot / bot.py
Pikeuli's picture
Update bot.py
c9402a3 verified
Raw
History Blame Contribute Delete
27.1 kB
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 "<html" in content_text and "<rss" not in content_text and "<feed" not in content_text and "<?xml" not in content_text:
log(f"⚠️ {feed['name']}: {type_url} beri HTML bukan RSS! Skip...")
continue
if "Direct" in type_url:
state["etag"] = r.headers.get("ETag", "")
log(f"βœ… {feed['name']}: Berjaya tembus guna {type_url}!")
# FIX 5c: RSS2JSON return JSON β€” convert ke XML supaya parser kekal sama
if "rss2json" in type_url.lower():
try:
jdata = r.json()
if jdata.get("status") == "ok" and "items" in jdata:
xml_parts = ['<?xml version="1.0" encoding="UTF-8"?><rss><channel>']
for ji in jdata["items"]:
xml_parts.append("<item>")
xml_parts.append(f"<title>{ji.get('title','')}</title>")
xml_parts.append(f"<link>{ji.get('link','')}</link>")
xml_parts.append(f"<description><![CDATA[{ji.get('description','')}]]></description>")
if ji.get("thumbnail"):
xml_parts.append(f'<media:thumbnail url="{ji["thumbnail"]}"/>')
elif ji.get("enclosure",{}).get("link"):
xml_parts.append(f'<media:thumbnail url="{ji["enclosure"]["link"]}"/>')
xml_parts.append("</item>")
xml_parts.append("</channel></rss>")
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'<img[^>]+(?: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)