diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..d106508 --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +from .setup import P diff --git a/info.yaml b/info.yaml new file mode 100644 index 0000000..d3e2c7c --- /dev/null +++ b/info.yaml @@ -0,0 +1,7 @@ +title: "FreeGame" +version: "0.0.1.0" +package_name: "ff_freegame" +developer: "Codex" +description: "FreeGame deals draft plugin for FlaskFarm" +home: "" +more: "" diff --git a/logic.py b/logic.py new file mode 100644 index 0000000..603a7d6 --- /dev/null +++ b/logic.py @@ -0,0 +1,215 @@ +# -*- coding: utf-8 -*- +import threading +import traceback + +import requests +from flask import jsonify, render_template +from plugin import PluginModuleBase +from framework import F, Job, scheduler + +from .model import ModelFetchLog, ModelFreeGameItem, ModelSetting +from . import scraper +from .setup import P + + +logger = P.logger +package_name = P.package_name +_fetch_lock = threading.Lock() + +SOURCE_LABELS = { + "epic": "Epic", + "steam": "Steam", + "gog": "GOG", + "indiegala": "IndieGala", + "stove": "STOVE", + "cheapshark": "CheapShark", +} +def _truthy(value): + return str(value).lower() == "true" + + +def _enabled_sources(): + enabled = [] + for source in SOURCE_LABELS: + if _truthy(ModelSetting.get(f"source_{source}_enabled")): + enabled.append(source) + return enabled + + +def _split_source_payload(results): + grouped = {source: [] for source in SOURCE_LABELS} + for source, items in (results or {}).items(): + normalized_source = "indiegala" if source == "indiegala_free" else source + if normalized_source not in grouped: + continue + for item in items or []: + is_free = bool(item.get("is_free_period")) or float(item.get("current_price") or 0) == 0 + if not is_free: + continue + if str(item.get("platform") or "") not in ["", normalized_source]: + continue + item["platform"] = normalized_source + grouped[normalized_source].append(item) + return grouped + + +def _discord_send(webhook_url, games): + lines = [] + for game in games[:10]: + title = game.get("title") or "Unknown" + platform = SOURCE_LABELS.get(game.get("platform"), game.get("platform")) + store_url = game.get("store_url") or "" + line = f"**{title}** ({platform})" + if store_url: + line += f"\n{store_url}" + lines.append(line) + content = "**무료 게임 알림**\n\n" + "\n\n".join(lines) + requests.post(webhook_url, json={"content": content}, timeout=10).raise_for_status() + + +def _telegram_send(bot_token, chat_id, games): + lines = [] + for game in games[:10]: + title = game.get("title") or "Unknown" + platform = SOURCE_LABELS.get(game.get("platform"), game.get("platform")) + store_url = game.get("store_url") or "" + line = f"{title} ({platform})" + if store_url: + line += f"\n{store_url}" + lines.append(line) + requests.post( + f"https://api.telegram.org/bot{bot_token}/sendMessage", + json={ + "chat_id": chat_id, + "text": "무료 게임 알림\n\n" + "\n\n".join(lines), + "parse_mode": "HTML", + "disable_web_page_preview": True, + }, + timeout=10, + ).raise_for_status() + + +class Logic(PluginModuleBase): + db_default = { + "auto_start": "False", + "auto_interval": "0 */2 * * *", + "notify_discord_webhook": "", + "notify_telegram_bot_token": "", + "notify_telegram_chat_id": "", + "notify_enabled": "False", + "source_epic_enabled": "True", + "source_steam_enabled": "True", + "source_gog_enabled": "True", + "source_indiegala_enabled": "True", + "source_stove_enabled": "True", + "source_cheapshark_enabled": "True", + } + + def __init__(self, PM): + super().__init__(PM, name="main", first_menu="setting") + + def plugin_load(self): + if _truthy(ModelSetting.get("auto_start")): + self.scheduler_start() + + def process_menu(self, sub, req): + arg = ModelSetting.to_dict() + arg["package_name"] = package_name + arg["scheduler"] = str(F.scheduler.is_include(package_name)) + arg["is_running"] = str(F.scheduler.is_running(package_name)) + arg["source_labels"] = SOURCE_LABELS + arg["platform_counts"] = ModelFreeGameItem.get_platform_counts() + if sub == "list": + return render_template("ff_freegame_main_list.html", arg=arg) + if sub == "log": + return render_template("log.html", package=package_name) + return render_template("ff_freegame_main_setting.html", arg=arg) + + def process_ajax(self, sub, req): + try: + if sub == "setting_save": + ret, _ = ModelSetting.setting_save(req) + ret["ret"] = "success" + return jsonify(ret) + if sub == "scheduler_toggle": + if req.form["scheduler"] == "true": + self.scheduler_start() + else: + self.scheduler_stop() + return jsonify({"ret": "success"}) + if sub == "execute_once": + threading.Thread(target=self.scheduler_function, daemon=True).start() + return jsonify({"ret": "success"}) + if sub == "web_list": + return jsonify(ModelFreeGameItem.web_list(req)) + if sub == "platform_counts": + return jsonify({"ret": "success", "data": ModelFreeGameItem.get_platform_counts()}) + return jsonify({"ret": "error", "log": f"unsupported ajax: {sub}"}) + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + return jsonify({"ret": "error", "log": str(e)}) + + def scheduler_start(self): + try: + interval = ModelSetting.get("auto_interval") or "0 */2 * * *" + job = Job(package_name, package_name, interval, self.scheduler_function, "FreeGame fetch", True) + scheduler.add_job_instance(job) + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + + def scheduler_stop(self): + try: + scheduler.remove_job(package_name) + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + + def scheduler_function(self): + if not _fetch_lock.acquire(blocking=False): + logger.info("FreeGame fetch skipped: already running") + return + try: + enabled_sources = set(_enabled_sources()) + results = scraper.fetch_all() + grouped = _split_source_payload(results) + fresh_free_games = [] + + for legacy_source in ["humble", "fanatical", "gmg", "directgames"]: + ModelFreeGameItem.replace_source_items(legacy_source, []) + + for source, items in grouped.items(): + if source not in enabled_sources: + continue + ModelFreeGameItem.replace_source_items(source, items) + ModelFetchLog(source, "ok", "", len(items)).save() + logger.info("FreeGame source=%s saved=%d", source, len(items)) + fresh_free_games.extend(items) + + disabled_sources = [source for source in SOURCE_LABELS if source not in enabled_sources] + if disabled_sources: + ModelFreeGameItem.delete_not_in_sources(enabled_sources) + + logger.info("FreeGame fetch completed: free_candidates=%d enabled_sources=%d", len(fresh_free_games), len(enabled_sources)) + self._notify(fresh_free_games) + except Exception as e: + logger.error("Exception:%s", e) + logger.error(traceback.format_exc()) + ModelFetchLog("all", "error", str(e), 0).save() + finally: + _fetch_lock.release() + + def _notify(self, games): + if _truthy(ModelSetting.get("notify_enabled")) is False: + return + targets = list(games or []) + if len(targets) == 0: + return + discord_webhook = ModelSetting.get("notify_discord_webhook") + telegram_bot_token = ModelSetting.get("notify_telegram_bot_token") + telegram_chat_id = ModelSetting.get("notify_telegram_chat_id") + if discord_webhook: + _discord_send(discord_webhook, targets) + if telegram_bot_token and telegram_chat_id: + _telegram_send(telegram_bot_token, telegram_chat_id, targets) diff --git a/model.py b/model.py new file mode 100644 index 0000000..2c7ef97 --- /dev/null +++ b/model.py @@ -0,0 +1,162 @@ +# -*- coding: utf-8 -*- +import json +from datetime import datetime + +from sqlalchemy import desc + +from .setup import * + + +ModelSetting = P.ModelSetting + + +class ModelFreeGameItem(ModelBase): + P = P + __tablename__ = "ff_freegame_item" + __bind_key__ = P.package_name + + id = db.Column(db.Integer, primary_key=True) + created_time = db.Column(db.DateTime) + updated_time = db.Column(db.DateTime) + external_id = db.Column(db.String) + platform = db.Column(db.String) + title = db.Column(db.String) + image_url = db.Column(db.String) + store_url = db.Column(db.String) + original_price = db.Column(db.Float) + current_price = db.Column(db.Float) + discount_pct = db.Column(db.Integer) + is_free_period = db.Column(db.Boolean) + free_end = db.Column(db.String) + rating = db.Column(db.Float) + rating_count = db.Column(db.Integer) + genres_json = db.Column(db.Text) + + def __init__(self): + now = datetime.now() + self.created_time = now + self.updated_time = now + self.genres_json = "[]" + + @property + def genres(self): + try: + return json.loads(self.genres_json or "[]") + except Exception: + return [] + + def as_dict(self): + return { + "id": self.id, + "external_id": self.external_id, + "platform": self.platform, + "title": self.title, + "image_url": self.image_url, + "store_url": self.store_url, + "original_price": self.original_price, + "current_price": self.current_price, + "discount_pct": self.discount_pct, + "is_free_period": self.is_free_period, + "free_end": self.free_end, + "rating": self.rating, + "rating_count": self.rating_count, + "genres": self.genres, + "updated_time": self.updated_time.strftime("%Y-%m-%d %H:%M:%S") if self.updated_time else "", + } + + @classmethod + def upsert(cls, data): + row = F.db.session.query(cls).filter_by( + external_id=str(data.get("external_id") or ""), + platform=str(data.get("platform") or ""), + ).first() + if row is None: + row = cls() + row.external_id = str(data.get("external_id") or "") + row.platform = str(data.get("platform") or "") + F.db.session.add(row) + row.title = str(data.get("title") or "") + row.image_url = str(data.get("image_url") or "") + row.store_url = str(data.get("store_url") or "") + row.original_price = float(data.get("original_price") or 0) + row.current_price = float(data.get("current_price") or 0) + row.discount_pct = int(data.get("discount_pct") or 0) + row.is_free_period = bool(data.get("is_free_period")) + row.free_end = str(data.get("free_end") or "") + row.rating = float(data.get("rating") or 0) + row.rating_count = int(data.get("rating_count") or 0) + row.genres_json = json.dumps(data.get("genres") or [], ensure_ascii=False) + row.updated_time = datetime.now() + return row + + @classmethod + def delete_not_in_sources(cls, sources): + with F.app.app_context(): + query = F.db.session.query(cls) + if sources: + query = query.filter(~cls.platform.in_(sources)) + deleted = query.delete(synchronize_session=False) + F.db.session.commit() + return deleted + + @classmethod + def replace_source_items(cls, source_name, items): + with F.app.app_context(): + F.db.session.query(cls).filter_by(platform=source_name).delete(synchronize_session=False) + F.db.session.commit() + for item in items: + cls.upsert(item) + F.db.session.commit() + + @classmethod + def web_list(cls, req): + with F.app.app_context(): + page = int(req.form.get("page", 1)) + search = str(req.form.get("search_word", "")).strip() + platform = str(req.form.get("platform", "all")).strip() + only_free = str(req.form.get("only_free", "true")).lower() == "true" + query = F.db.session.query(cls) + if search != "": + query = query.filter(cls.title.like(f"%{search}%")) + if platform not in ["", "all"]: + query = query.filter(cls.platform == platform) + query = query.filter((cls.is_free_period == True) | (cls.current_price == 0)) + query = query.order_by(desc(cls.discount_pct), desc(cls.updated_time)) + count = query.count() + page_size = 30 + rows = query.limit(page_size).offset((page - 1) * page_size).all() + return { + "list": [row.as_dict() for row in rows], + "paging": cls.get_paging_info(count, page, page_size), + } + + @classmethod + def get_platform_counts(cls): + with F.app.app_context(): + rows = ( + F.db.session.query(cls.platform, db.func.count(cls.id)) + .group_by(cls.platform) + .order_by(cls.platform.asc()) + .all() + ) + return [{"platform": row[0], "count": row[1]} for row in rows] + + +class ModelFetchLog(ModelBase): + P = P + __tablename__ = "ff_freegame_fetch_log" + __bind_key__ = P.package_name + + id = db.Column(db.Integer, primary_key=True) + created_time = db.Column(db.DateTime) + source = db.Column(db.String) + status = db.Column(db.String) + message = db.Column(db.Text) + count = db.Column(db.Integer) + + def __init__(self, source, status, message="", count=0): + self.created_time = datetime.now() + self.source = source + self.status = status + self.message = message + self.count = count diff --git a/scraper.py b/scraper.py new file mode 100644 index 0000000..b211f56 --- /dev/null +++ b/scraper.py @@ -0,0 +1,499 @@ +# -*- coding: utf-8 -*- +""" +Game deal scrapers: + - Epic Games Store + - CheapShark + - GOG + - IndieGala + - STOVE +""" +import logging +import re +from datetime import datetime, timezone + +import requests + +log = logging.getLogger(__name__) + +_HEADERS = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Accept": "application/json", +} +_HTML_HEADERS = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8", +} +_TIMEOUT = 20 + + +def _get(url, html=False, **kwargs): + headers = _HTML_HEADERS if html else _HEADERS + if "headers" not in kwargs: + kwargs["headers"] = headers + resp = requests.get(url, timeout=_TIMEOUT, **kwargs) + resp.raise_for_status() + return resp + + +_CS_BASE = "https://www.cheapshark.com/api/1.0" +_STORE_MAP = { + "1": "steam", + "3": "gmg", + "7": "gog", + "11": "humble", + "15": "fanatical", + "25": "epic", + "30": "indiegala", +} +_CS_DEDICATED_STORE_IDS = {"1", "7", "25", "30"} + + +def _cs_deal(d, override_platform=None): + store_id = str(d.get("storeID", "1")) + platform = override_platform or "cheapshark" + try: + disc = int(float(d.get("savings", 0))) + except Exception: + disc = 0 + try: + orig = float(d.get("normalPrice", 0)) + except Exception: + orig = 0.0 + try: + curr = float(d.get("salePrice", 0)) + except Exception: + curr = 0.0 + try: + rating = float(d.get("steamRatingPercent") or 0) + except Exception: + rating = 0.0 + try: + rc = int(d.get("steamRatingCount") or 0) + except Exception: + rc = 0 + deal_id = d.get("dealID", "") + game_id = d.get("gameID", "") + title = d.get("title", "") + is_free_period = curr == 0.0 and orig > 0.0 + return { + "external_id": f"cs_{game_id}_{store_id}", + "platform": platform, + "title": title, + "image_url": d.get("thumb", ""), + "store_url": f"https://www.cheapshark.com/redirect?dealID={deal_id}" if deal_id else "https://www.cheapshark.com", + "original_price": orig, + "current_price": curr, + "discount_pct": disc, + "is_free_period": is_free_period, + "free_start": None, + "free_end": None, + "genres": [], + "rating": rating, + "rating_count": rc, + } + + +def fetch_cheapshark_deals(min_discount=75, max_pages=3): + results, seen = [], set() + for page in range(max_pages): + try: + deals = _get(f"{_CS_BASE}/deals", params={ + "lowerPrice": 0, + "upperPrice": 9999, + "sortBy": "Savings", + "desc": 0, + "pageSize": 60, + "pageNumber": page, + "onSale": 1, + }).json() + except Exception as e: + log.warning("CheapShark page %d failed: %s", page, e) + break + if not deals: + break + for d in deals: + store_id = str(d.get("storeID", "")) + if store_id in _CS_DEDICATED_STORE_IDS: + continue + try: + disc = int(float(d.get("savings", 0))) + except Exception: + disc = 0 + if disc < min_discount: + continue + try: + orig = float(d.get("normalPrice", 0)) + except Exception: + orig = 0.0 + try: + curr = float(d.get("salePrice", 0)) + except Exception: + curr = 0.0 + if not (curr == 0.0 and orig > 0.0): + continue + key = f"{d.get('gameID', '')}_{store_id}" + if key in seen: + continue + seen.add(key) + results.append(_cs_deal(d)) + return results + + +def _fetch_cs_store(store_id: str, min_discount=30, max_pages=3, free_only=False): + platform = _STORE_MAP.get(store_id) + if not platform: + return [] + results, seen = [], set() + for page in range(max_pages): + try: + deals = _get(f"{_CS_BASE}/deals", params={ + "storeID": store_id, + "sortBy": "Savings", + "desc": 0, + "pageSize": 60, + "pageNumber": page, + "onSale": 1, + }).json() + except Exception as e: + log.warning("CheapShark store=%s page %d failed: %s", store_id, page, e) + break + if not deals: + break + for d in deals: + try: + disc = int(float(d.get("savings", 0))) + except Exception: + disc = 0 + if disc < min_discount: + continue + try: + orig = float(d.get("normalPrice", 0)) + except Exception: + orig = 0.0 + try: + curr = float(d.get("salePrice", 0)) + except Exception: + curr = 0.0 + if free_only and not (curr == 0.0 and orig > 0.0): + continue + key = f"{d.get('gameID', '')}_{store_id}" + if key in seen: + continue + seen.add(key) + results.append(_cs_deal(d, platform)) + return results + + +def fetch_steam_deals(): + return _fetch_cs_store("1", min_discount=0, free_only=True) + + +def fetch_indiegala_deals(): + return _fetch_cs_store("30", min_discount=30) + + +_EPIC_GQL_URL = "https://store-site-backend-static-ipv4.ak.epicgames.com/freeGamesPromotions" +_EPIC_INVALID_SLUGS = {"home", "", "/home", "[]"} +_DEMO_TITLE_RE = re.compile(r"\b(demo|prologue|trial|playtest|beta|free\s*to\s*play|f2p)\b", re.IGNORECASE) + + +def _epic_store_url(el): + for m in el.get("offerMappings") or []: + slug = (m.get("pageSlug") or "").strip() + if slug and slug not in _EPIC_INVALID_SLUGS: + return f"https://store.epicgames.com/ko/p/{slug}" + for m in ((el.get("catalogNs") or {}).get("mappings") or []): + slug = (m.get("pageSlug") or "").strip() + if slug and slug not in _EPIC_INVALID_SLUGS: + return f"https://store.epicgames.com/ko/p/{slug}" + slug = (el.get("productSlug") or "").strip().rstrip("/") + if slug and slug not in _EPIC_INVALID_SLUGS: + return f"https://store.epicgames.com/ko/p/{slug}" + slug = (el.get("urlSlug") or "").strip() + if slug and slug not in _EPIC_INVALID_SLUGS: + return f"https://store.epicgames.com/ko/p/{slug}" + return "https://store.epicgames.com/ko/free-games" + + +def fetch_epic_free(): + try: + data = _get(_EPIC_GQL_URL, params={"locale": "en", "country": "US", "allowCountries": "US"}).json() + except Exception as e: + log.warning("Epic fetch failed: %s", e) + return [] + elements = data.get("data", {}).get("Catalog", {}).get("searchStore", {}).get("elements", []) + results = [] + seen_titles = set() + for el in elements: + title = (el.get("title") or "").strip() + if not title or _DEMO_TITLE_RE.search(title): + continue + promo = el.get("promotions") or {} + offers = [o for g in (promo.get("promotionalOffers") or []) for o in (g.get("promotionalOffers") or [])] + upcoming = [o for g in (promo.get("upcomingPromotionalOffers") or []) for o in (g.get("promotionalOffers") or [])] + price_info = (el.get("price") or {}).get("totalPrice") or {} + decimals = (price_info.get("currencyInfo") or {}).get("decimals", 2) + divisor = 10 ** decimals + original = (price_info.get("originalPrice") or 0) / divisor + disc_price = (price_info.get("discountPrice") or price_info.get("originalPrice") or 0) / divisor + free_start = None + free_end = None + is_free_now = False + active_disc_pct = None + for o in offers: + ds = o.get("discountSetting", {}) + if ds.get("discountType") == "PERCENTAGE": + pct = ds.get("discountPercentage", 100) + if pct == 0: + is_free_now = True + free_start = _parse_dt(o.get("startDate")) + free_end = _parse_dt(o.get("endDate")) + break + active_disc_pct = pct + is_upcoming_free = False + if not is_free_now: + for o in upcoming: + ds = o.get("discountSetting", {}) + if ds.get("discountType") == "PERCENTAGE" and ds.get("discountPercentage", 100) == 0: + is_upcoming_free = True + free_start = _parse_dt(o.get("startDate")) + free_end = _parse_dt(o.get("endDate")) + break + if not is_free_now and not is_upcoming_free: + continue + title_key = title.lower() + if title_key in seen_titles: + continue + seen_titles.add(title_key) + if is_free_now: + current_price = 0.0 + discount_pct = 100 + else: + current_price = disc_price + discount_pct = active_disc_pct if active_disc_pct is not None else (round((1 - disc_price / original) * 100) if original > 0 else 0) + image_url = "" + for img in el.get("keyImages") or []: + if img.get("type") in ("DieselStoreFrontWide", "OfferImageWide", "Thumbnail"): + image_url = img.get("url", "") + break + genres = [t.get("name", "") for t in (el.get("tags") or []) if t.get("groupName") == "genre"] + results.append({ + "external_id": el.get("id") or el.get("urlSlug") or title, + "platform": "epic", + "title": title, + "image_url": image_url, + "store_url": _epic_store_url(el), + "original_price": original, + "current_price": current_price, + "discount_pct": discount_pct, + "is_free_period": is_free_now, + "free_start": free_start, + "free_end": free_end, + "genres": genres, + "rating": 0.0, + "rating_count": 0, + }) + return results + + +_GOG_CATALOG_URL = "https://catalog.gog.com/v1/catalog" + + +def fetch_gog_free(): + try: + data = _get(_GOG_CATALOG_URL, params={ + "limit": 48, + "filters": "priceRange:free,0-0", + "order": "desc:score", + "productType": "in:game", + "countryCode": "US", + "locale": "en-US", + }).json() + except Exception as e: + log.warning("GOG fetch failed: %s", e) + return [] + results = [] + for p in data.get("products", []): + title = (p.get("title") or "").strip() + if not title or _DEMO_TITLE_RE.search(title): + continue + if (p.get("productType") or "").lower() == "demo": + continue + price_info = p.get("price") or {} + final_money = price_info.get("finalMoney") or {} + base_money = price_info.get("baseMoney") or {} + try: + curr = float(final_money.get("amount") or 0) + orig = float(base_money.get("amount") or 0) + except Exception: + curr = orig = 0.0 + if curr > 0.0: + continue + slug = p.get("slug", "") + results.append({ + "external_id": f"gog_{p.get('id', slug)}", + "platform": "gog", + "title": title, + "image_url": p.get("coverHorizontal") or p.get("coverVertical") or "", + "store_url": p.get("storeLink") or (f"https://www.gog.com/en/game/{slug}" if slug else "https://www.gog.com"), + "original_price": orig, + "current_price": 0.0, + "discount_pct": 100 if orig > 0 else 0, + "is_free_period": True, + "free_start": None, + "free_end": None, + "genres": [g.get("name", "") for g in (p.get("genres") or [])], + "rating": float(p.get("reviewsRating") or 0), + "rating_count": int(p.get("reviewsCount") or 0), + }) + return results + + +_INDIEGALA_FREE_URL = "https://freebies.indiegala.com/" + + +def fetch_indiegala_free(): + try: + from bs4 import BeautifulSoup + soup = BeautifulSoup(_get(_INDIEGALA_FREE_URL, html=True).text, "html.parser") + except Exception as e: + log.warning("IndieGala freebies fetch failed: %s", e) + return [] + results = [] + for col in soup.find_all("div", class_="products-col-inner"): + try: + img = col.find("img") + if not img: + continue + image_url = img.get("data-img-src") or img.get("src") or "" + title_div = col.find("div", class_="product-title") + if title_div: + title = title_div.get_text(strip=True) + else: + title = re.sub(r"\s+product image\s*$", "", img.get("alt", ""), flags=re.IGNORECASE).strip() + if not title: + continue + link_tag = col.find("a", class_="fit-click") + if link_tag and link_tag.get("href"): + href = link_tag["href"] + store_url = href if href.startswith("http") else "https://freebies.indiegala.com" + href + else: + store_url = _INDIEGALA_FREE_URL + img_id = re.search(r"/([a-f0-9]{8}-[a-f0-9\\-]{4,})/", image_url) + external_id = f"ig_free_{img_id.group(1)}" if img_id else f"ig_free_{re.sub(r'[^a-z0-9]+', '_', title.lower()).strip('_')}" + results.append({ + "external_id": external_id, + "platform": "indiegala", + "title": title, + "image_url": image_url, + "store_url": store_url, + "original_price": 0.0, + "current_price": 0.0, + "discount_pct": 100, + "is_free_period": True, + "free_start": None, + "free_end": None, + "genres": [], + "rating": 0.0, + "rating_count": 0, + }) + except Exception: + continue + return results + + +def _is_stove_demo_game(store_url): + try: + html = _get(store_url, html=True).text + except Exception as e: + log.warning("STOVE detail fetch failed: %s", e) + return False + if "/ko/store/search?types=DEMO" in html: + return True + return bool(re.search(r">\s*DEMO\s*<", html, re.IGNORECASE)) + + +def fetch_stove_deals(): + try: + from bs4 import BeautifulSoup + resp = _get("https://store.onstove.com/ko/store/stoveindie", html=True) + soup = BeautifulSoup(resp.text, "html.parser") + except Exception as e: + log.warning("STOVE fetch failed: %s", e) + return [] + results = [] + seen = set() + for anchor in soup.find_all("a", href=True): + href = str(anchor.get("href") or "").strip() + if "/ko/games/" not in href: + continue + parent = anchor.parent + block_text = " ".join(parent.get_text(" ", strip=True).split()) if parent is not None else "" + combined_text = f"{block_text} {' '.join(anchor.get_text(' ', strip=True).split())}".strip() + if "무료" not in combined_text and "FREE" not in combined_text.upper(): + continue + game_id = href.rstrip("/").split("/")[-1] + if game_id in seen: + continue + seen.add(game_id) + title = "" + probe = parent + for _ in range(4): + if probe is None: + break + title_node = probe.find(["h1", "h2", "h3", "strong"]) + if title_node is not None: + title = " ".join(title_node.get_text(" ", strip=True).split()) + if title: + break + probe = probe.parent + if not title: + title = game_id + if _DEMO_TITLE_RE.search(title): + continue + image_url = "" + image_node = anchor.find("img") or (parent.find("img") if parent is not None else None) + if image_node is not None: + image_url = image_node.get("src") or image_node.get("data-src") or "" + store_url = href if href.startswith("http") else f"https://store.onstove.com{href}" + if _is_stove_demo_game(store_url): + continue + results.append({ + "external_id": f"stove_{game_id}", + "platform": "stove", + "title": title, + "image_url": image_url, + "store_url": store_url, + "original_price": 0.0, + "current_price": 0.0, + "discount_pct": 100, + "is_free_period": True, + "free_start": None, + "free_end": None, + "genres": [], + "rating": 0.0, + "rating_count": 0, + }) + return results + + +def fetch_all(): + return { + "epic": fetch_epic_free(), + "steam": fetch_steam_deals(), + "cheapshark": fetch_cheapshark_deals(), + "gog": fetch_gog_free(), + "indiegala": fetch_indiegala_deals(), + "indiegala_free": fetch_indiegala_free(), + "stove": fetch_stove_deals(), + } + + +def _parse_dt(s): + if not s: + return None + try: + return datetime.fromisoformat(str(s).rstrip("Z")).replace(tzinfo=timezone.utc) + except Exception: + return None diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..ce79e69 --- /dev/null +++ b/setup.py @@ -0,0 +1,32 @@ +import traceback + +from plugin import * # noqa + + +setting = { + "filepath": __file__, + "use_db": True, + "use_default_setting": True, + "home_module": "setting", + "menu": { + "uri": __package__, + "name": "FreeGame", + "list": [ + {"uri": "setting", "name": "설정"}, + {"uri": "list", "name": "목록"}, + {"uri": "log", "name": "로그"}, + ], + }, + "setting_menu": None, + "default_route": "single", +} + +P = create_plugin_instance(setting) + +try: + from .logic import Logic + + P.set_module_list([Logic]) +except Exception as e: + P.logger.error(f"Exception:{str(e)}") + P.logger.error(traceback.format_exc()) diff --git a/templates/ff_freegame_main_list.html b/templates/ff_freegame_main_list.html new file mode 100644 index 0000000..db6c73f --- /dev/null +++ b/templates/ff_freegame_main_list.html @@ -0,0 +1,294 @@ +{% extends "base.html" %} +{% block content %} + + +
+ + + {{ macros.m_button_group([['refresh_list_btn', '새로고침']]) }} +
+ +
+ + +{% endblock %} diff --git a/templates/ff_freegame_main_setting.html b/templates/ff_freegame_main_setting.html new file mode 100644 index 0000000..07a805b --- /dev/null +++ b/templates/ff_freegame_main_setting.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} +{% block content %} +
+ {{ macros.m_button_group([['globalSettingSaveBtn', '설정 저장']]) }} +
+ {{ macros.setting_input_text('auto_interval', '수집 주기', value=arg.get('auto_interval', '0 */2 * * *'), desc=['기본값: 2시간마다', '크론 표현식 사용']) }} + {{ macros.setting_checkbox('auto_start', '시작 시 자동 스케줄 등록', value=arg.get('auto_start', 'False')) }} + {{ macros.global_setting_scheduler_button(arg['scheduler'], arg['is_running']) }} + {{ macros.setting_buttons([['ff_freegame_execute_btn', '1회 실행']], left='수동 실행') }} + {{ macros.m_hr() }} + {{ macros.setting_checkbox('notify_enabled', '알림 사용', value=arg.get('notify_enabled', 'False')) }} + {{ macros.setting_input_text('notify_discord_webhook', 'Discord Webhook', value=arg.get('notify_discord_webhook', '')) }} + {{ macros.setting_input_text('notify_telegram_bot_token', 'Telegram Bot Token', value=arg.get('notify_telegram_bot_token', '')) }} + {{ macros.setting_input_text('notify_telegram_chat_id', 'Telegram Chat ID', value=arg.get('notify_telegram_chat_id', '')) }} + {{ macros.m_hr() }} + {{ macros.info_text('source_help', '활성 소스', value='체크된 무료 소스만 저장/표시됩니다.') }} + {{ macros.setting_checkbox('source_epic_enabled', 'Epic', value=arg.get('source_epic_enabled', 'True')) }} + {{ macros.setting_checkbox('source_steam_enabled', 'Steam', value=arg.get('source_steam_enabled', 'True')) }} + {{ macros.setting_checkbox('source_gog_enabled', 'GOG', value=arg.get('source_gog_enabled', 'True')) }} + {{ macros.setting_checkbox('source_indiegala_enabled', 'IndieGala', value=arg.get('source_indiegala_enabled', 'True')) }} + {{ macros.setting_checkbox('source_stove_enabled', 'STOVE', value=arg.get('source_stove_enabled', 'True')) }} + {{ macros.setting_checkbox('source_cheapshark_enabled', 'CheapShark', value=arg.get('source_cheapshark_enabled', 'True')) }} +
+
+ + +{% endblock %}