From 7349b743e3f9db86f8aca906eb5102058f5dbb5c Mon Sep 17 00:00:00 2001 From: st Date: Sun, 9 Aug 2026 16:51:01 -0500 Subject: [PATCH] first commit --- .gitignore | 2 + CMakeLists.txt | 49 ++++++++ README.md | 102 +++++++++++++++ config.example.json | 20 +++ include/Config.hpp | 36 ++++++ include/DiscordBot.hpp | 37 ++++++ include/DiscordNotifier.hpp | 34 +++++ include/HttpClient.hpp | 27 ++++ include/Logger.hpp | 19 +++ include/Scraper.hpp | 36 ++++++ include/SniperEngine.hpp | 50 ++++++++ include/State.hpp | 42 +++++++ src/Config.cpp | 82 ++++++++++++ src/DiscordBot.cpp | 243 ++++++++++++++++++++++++++++++++++++ src/DiscordNotifier.cpp | 110 ++++++++++++++++ src/HttpClient.cpp | 90 +++++++++++++ src/Logger.cpp | 38 ++++++ src/Scraper.cpp | 236 ++++++++++++++++++++++++++++++++++ src/SniperEngine.cpp | 185 +++++++++++++++++++++++++++ src/State.cpp | 46 +++++++ src/main.cpp | 43 +++++++ 21 files changed, 1527 insertions(+) create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 README.md create mode 100644 config.example.json create mode 100644 include/Config.hpp create mode 100644 include/DiscordBot.hpp create mode 100644 include/DiscordNotifier.hpp create mode 100644 include/HttpClient.hpp create mode 100644 include/Logger.hpp create mode 100644 include/Scraper.hpp create mode 100644 include/SniperEngine.hpp create mode 100644 include/State.hpp create mode 100644 src/Config.cpp create mode 100644 src/DiscordBot.cpp create mode 100644 src/DiscordNotifier.cpp create mode 100644 src/HttpClient.cpp create mode 100644 src/Logger.cpp create mode 100644 src/Scraper.cpp create mode 100644 src/SniperEngine.cpp create mode 100644 src/State.cpp create mode 100644 src/main.cpp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2edaacd --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +build/ +config.json diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..1902397 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,49 @@ +cmake_minimum_required(VERSION 3.16) +project(lzt_sniper_cpp CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +if(NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE Release) +endif() + +find_package(CURL REQUIRED) +find_package(nlohmann_json REQUIRED) +find_package(Threads REQUIRED) + +# --- D++ (Discord library) ------------------------------------------------- +# Prefer a system/pre-built install (e.g. built once and `make install`-ed, +# or a distro package if your platform has one). Falls back to fetching and +# building it from source the first time you configure the project. +find_package(DPP QUIET) +if(NOT DPP_FOUND) + include(FetchContent) + FetchContent_Declare( + DPP + GIT_REPOSITORY https://github.com/brainboxdotcc/DPP.git + GIT_TAG v10.0.35 + GIT_SHALLOW TRUE + ) + FetchContent_MakeAvailable(DPP) +endif() + +add_executable(lzt_sniper + src/main.cpp + src/Config.cpp + src/Logger.cpp + src/State.cpp + src/HttpClient.cpp + src/Scraper.cpp + src/DiscordNotifier.cpp + src/SniperEngine.cpp + src/DiscordBot.cpp +) + +target_include_directories(lzt_sniper PRIVATE include) +target_link_libraries(lzt_sniper PRIVATE + CURL::libcurl + nlohmann_json::nlohmann_json + Threads::Threads + dpp +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..b41fa66 --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# LZT Market Sniper — C++ rewrite + +A from-scratch C++ port of the original single-file `app.py` (Flask + +Selenium + a mobile web dashboard). This version is a native background +service with no browser/webpage at all — you control it entirely from +Discord with slash commands. + +## What changed vs. the Python version + +| Python (`app.py`) | C++ rewrite | +|---|---| +| Flask web server + mobile HTML/CSS/JS dashboard | Gone. Control is via Discord slash commands instead. | +| Selenium driving a real (headless) Chrome profile | `libcurl` HTTP GET of the search page's raw HTML (no JS execution — see **Scraping caveat** below). | +| Discord **webhook** only | Bot posts through its own REST API (`/channels/{id}/messages`) using the bot token; a legacy webhook URL is still supported as a fallback. | +| In-memory `state` dict, SSE stream to browser | Thread-safe `SniperState`, no streaming needed since Discord is the UI. | +| One 1185-line file | Split into `Config`, `State`, `Logger`, `HttpClient`, `Scraper`, `DiscordNotifier`, `SniperEngine`, `DiscordBot`, `main`. | + +## Layout + +``` +include/ headers (one per component) + Config.hpp JSON config load/save (bot token, links, filters, interval) + State.hpp thread-safe shared state (running flag, last offers, history) + Logger.hpp timestamped console logger + HttpClient.hpp libcurl GET/POST wrapper + Scraper.hpp page fetch + offer-card text extraction + price/keyword filter + DiscordNotifier.hpp builds/sends the stock-update embed + SniperEngine.hpp the watch loop + all mutating operations (start/stop/add/remove/pause/scan) + DiscordBot.hpp D++ slash-command bot wired into SniperEngine +src/ same files, implementations +CMakeLists.txt +config.example.json +``` + +## Building + +Dependencies: a C++17 compiler, CMake, `libcurl`, `nlohmann-json`, and +[D++](https://dpp.dev/) (the Discord library). + +```bash +# Debian/Ubuntu +sudo apt install build-essential cmake libcurl4-openssl-dev nlohmann-json3-dev + +mkdir build && cd build +cmake .. +make -j$(nproc) +``` + +CMake first looks for an installed D++ (`find_package(DPP)`); if it's not +found it downloads and builds D++ v10.0.35 from source automatically via +`FetchContent` the first time you configure (this takes a few minutes — D++ +is a fairly large library). If you'd rather build D++ once and reuse it, +follow the install instructions at https://dpp.dev/installing.html and +CMake will pick up the installed copy instead. + +## Configuring + +```bash +cp config.example.json config.json +``` + +Fill in: +- `discord_bot_token` — from the Discord Developer Portal, with the + `applications.commands` and `bot` scopes and the "Send Messages" permission. +- `discord_channel_id` — the channel the bot should post stock-change alerts to. +- `command_guild_id` *(optional)* — set this to your server's ID while + testing so slash commands sync instantly; leave blank for global commands + (Discord can take up to an hour to propagate those). +- `links` — your initial watch list (you can also manage this entirely from + Discord with `/addlink`, `/removelink`, `/pause` once the bot is running). + +Run it: + +```bash +./build/lzt_sniper config.json +``` + +## Discord commands + +- `/start` — begin watching all configured links +- `/stop` — stop watching +- `/status` — running state, total scans/changes, current stock per link +- `/links` — list watched links with pause state and filters +- `/addlink url [label] [min_price] [max_price] [keywords]` +- `/removelink url` +- `/pause url` — toggle pause on one link +- `/scan url` — force an immediate one-off scan +- `/interval seconds` — change the polling interval +- `/history` — last 15 scan results + +## Scraping caveat + +The Python version drove a real headless Chrome browser through a +logged-in profile, so it saw the fully JS-rendered, authenticated page. This +C++ version fetches the raw page HTML over HTTP instead — no JavaScript is +executed. LZT Market's search results are server-rendered, so public +listings work fine; if a page ever needs an authenticated session, copy your +browser's `Cookie:` header value into `cookie_header` in `config.json`. The +offer-card extraction (`Scraper::extractOfferBlocks`) is a heuristic text +parser tuned to reproduce the original's "any div/li/article whose text +mentions 'followers'" logic — inspect it and adjust the tag/keyword filters +in `src/Scraper.cpp` if LZT Market's markup changes. diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..87b5be9 --- /dev/null +++ b/config.example.json @@ -0,0 +1,20 @@ +{ + "discord_bot_token": "PUT_YOUR_BOT_TOKEN_HERE", + "discord_channel_id": "PUT_THE_CHANNEL_ID_TO_POST_ALERTS_IN", + "discord_webhook_url": "", + "command_guild_id": "PUT_YOUR_SERVER_ID_HERE_FOR_INSTANT_SLASH_COMMAND_SYNC", + "cookie_header": "", + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) LZTSniperCpp/1.0", + "interval_seconds": 180, + "autostart": false, + "links": [ + { + "url": "https://lzt.market/roblox/?pmax=89&title=korblox", + "label": "Korblox under $89", + "min_price": null, + "max_price": 89, + "keywords": ["korblox"], + "paused": false + } + ] +} diff --git a/include/Config.hpp b/include/Config.hpp new file mode 100644 index 0000000..27fbf0b --- /dev/null +++ b/include/Config.hpp @@ -0,0 +1,36 @@ +#pragma once +#include +#include +#include + +// Per-watched-link settings (mirrors the Python `link_configs` dicts). +struct LinkConfig { + std::string url; + std::string label; + std::optional min_price; + std::optional max_price; + std::vector keywords; + bool paused = false; +}; + +// Whole-application configuration, loaded from / saved to a JSON file on disk +// so the bot remembers its watch list and settings between restarts. +struct AppConfig { + std::string discord_bot_token; // required: bot token for the control bot + std::string discord_channel_id; // channel the bot posts stock alerts to + std::string discord_webhook_url; // optional legacy fallback (like the python script) + std::string command_guild_id; // optional: register slash commands to one guild (instant sync while testing) + std::string cookie_header; // optional "Cookie: ..." value for authenticated scraping + std::string user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) LZTSniperCpp/1.0"; + int interval_seconds = 180; + bool autostart = false; // start monitoring immediately on launch + std::vector links; + + // Loads config from `path`. Returns a default-constructed AppConfig (with + // an empty token) if the file does not exist yet. + static AppConfig load(const std::string& path); + + // Persists the current config (including live link list / pause state) + // back to `path` as pretty-printed JSON. + void save(const std::string& path) const; +}; diff --git a/include/DiscordBot.hpp b/include/DiscordBot.hpp new file mode 100644 index 0000000..1adf49a --- /dev/null +++ b/include/DiscordBot.hpp @@ -0,0 +1,37 @@ +#pragma once +#include +#include "Config.hpp" +#include "State.hpp" +#include "SniperEngine.hpp" + +// This is the whole point of the C++ rewrite request: instead of a Flask web +// dashboard you had to open in a browser, the sniper is now controlled +// entirely from Discord with slash commands: +// +// /start start watching all configured links +// /stop stop watching +// /status running state + per-link stock + totals +// /links list configured links (with pause state) +// /addlink url [label] [min_price] [max_price] [keywords] +// /removelink url +// /pause url toggle pause for one link +// /scan url force an immediate one-off scan +// /interval seconds change the polling interval +// /history last 15 scan results +// +// Uses D++ (https://dpp.dev/) for the gateway connection + slash commands. +class DiscordBot { +public: + DiscordBot(AppConfig& config, SniperState& state, SniperEngine& engine, std::string config_path); + + // Blocks forever running the gateway event loop. + void run(); + +private: + AppConfig& config_; + SniperState& state_; + SniperEngine& engine_; + std::string config_path_; + + void persist(); +}; diff --git a/include/DiscordNotifier.hpp b/include/DiscordNotifier.hpp new file mode 100644 index 0000000..4cab55d --- /dev/null +++ b/include/DiscordNotifier.hpp @@ -0,0 +1,34 @@ +#pragma once +#include +#include +#include + +// Builds and sends the same "stock update" embed the python script sent via +// webhook, but preferring the bot's own REST API (POST /channels/{id}/messages) +// since we now have a real bot token. Falls back to a legacy webhook URL if +// no channel id is configured. +class DiscordNotifier { +public: + DiscordNotifier(std::string bot_token, std::string channel_id, std::string webhook_url); + + void sendStockUpdate(const std::string& url, + const std::vector& offers, + const std::string& change_text, + bool ping, + std::optional min_price, + std::optional max_price, + const std::vector& keywords); + +private: + std::string bot_token_; + std::string channel_id_; + std::string webhook_url_; + + std::string buildPayload(const std::string& url, + const std::vector& offers, + const std::string& change_text, + bool ping, + std::optional min_price, + std::optional max_price, + const std::vector& keywords) const; +}; diff --git a/include/HttpClient.hpp b/include/HttpClient.hpp new file mode 100644 index 0000000..1745889 --- /dev/null +++ b/include/HttpClient.hpp @@ -0,0 +1,27 @@ +#pragma once +#include +#include + +struct HttpResponse { + long status = 0; + std::string body; + bool ok() const { return status >= 200 && status < 300; } +}; + +// Minimal libcurl wrapper used for both scraping (GET) and talking to the +// Discord REST API / webhooks (POST json). Kept tiny and dependency-light +// rather than reusing a full framework. +class HttpClient { +public: + static void globalInit(); // call once at process start + static void globalCleanup(); + + static HttpResponse get(const std::string& url, + const std::map& headers = {}, + long timeout_seconds = 30); + + static HttpResponse postJson(const std::string& url, + const std::string& json_body, + const std::map& headers = {}, + long timeout_seconds = 15); +}; diff --git a/include/Logger.hpp b/include/Logger.hpp new file mode 100644 index 0000000..2a77bfb --- /dev/null +++ b/include/Logger.hpp @@ -0,0 +1,19 @@ +#pragma once +#include +#include + +enum class LogTag { Info, Ok, Warn, Err }; + +// Thread-safe console logger (stand-in for the python `push_log` + log_queue/SSE). +// Also supports a "sink" callback so the Discord bot can mirror log lines into +// a Discord channel/log command without polling a queue. +class Logger { +public: + using Sink = std::function; + + static void log(const std::string& msg, LogTag tag = LogTag::Info); + static void setSink(Sink sink); + +private: + static Sink sink_; +}; diff --git a/include/Scraper.hpp b/include/Scraper.hpp new file mode 100644 index 0000000..b494bf1 --- /dev/null +++ b/include/Scraper.hpp @@ -0,0 +1,36 @@ +#pragma once +#include +#include +#include + +// Fetches an LZT Market search-result page and extracts individual offer +// "cards" as plain text, mirroring the heuristic the original Python script +// used inside the browser (`document.querySelectorAll` + innerText filtering +// for blocks that mention "followers"). +// +// IMPORTANT: the python version drove a real (headless) Chrome via Selenium, +// which meant it saw the fully JS-rendered page and re-used a logged-in +// browser profile. This C++ version fetches the raw HTML over HTTP instead +// (no JS execution). LZT Market's search results are server-rendered, so +// this works for public listings; if you need an authenticated view, set +// `cookie_header` in config.json to your browser's `Cookie:` header value. +class Scraper { +public: + explicit Scraper(std::string cookie_header = "", std::string user_agent = ""); + + // Returns cleaned, deduplicated offer text blocks for the given URL. + std::vector checkOffers(const std::string& url); + + // Client-side post-filter equivalent to python's filter_offers(). + static std::vector filterOffers(const std::vector& offers, + std::optional min_price, + std::optional max_price, + const std::vector& keywords); + +private: + std::string cookie_header_; + std::string user_agent_; + + static std::string cleanText(std::string text); + static std::vector extractOfferBlocks(const std::string& html); +}; diff --git a/include/SniperEngine.hpp b/include/SniperEngine.hpp new file mode 100644 index 0000000..db3f811 --- /dev/null +++ b/include/SniperEngine.hpp @@ -0,0 +1,50 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include "Config.hpp" +#include "State.hpp" +#include "Scraper.hpp" +#include "DiscordNotifier.hpp" + +// Owns the background watch loop (equivalent to python's `bot_loop`) plus +// all the mutating operations the Flask `/api/*` routes used to expose. +// The Discord bot's slash-command handlers call straight into this class. +class SniperEngine { +public: + SniperEngine(AppConfig& config, SniperState& state, DiscordNotifier& notifier); + ~SniperEngine(); + + bool start(std::string* error_out = nullptr); + void stop(); + bool isRunning() const; + + // Fire-and-forget manual rescan of a single URL (spawns a detached thread, + // matching python's api_scan_now()). + void scanNow(const std::string& url); + + bool addLink(LinkConfig link); + bool removeLink(const std::string& url); + bool togglePause(const std::string& url, bool* now_paused_out = nullptr); + + std::vector links(); + int intervalSeconds() const; + void setIntervalSeconds(int seconds); + +private: + AppConfig& config_; + SniperState& state_; + DiscordNotifier& notifier_; + Scraper scraper_; + + std::mutex links_mtx_; + std::thread worker_; + std::atomic stop_requested_{false}; + + void loop(); + void runOneScan(const LinkConfig& cfg, bool isManual); + static std::string getChangeText(size_t n, const std::optional>& old); +}; diff --git a/include/State.hpp b/include/State.hpp new file mode 100644 index 0000000..c882077 --- /dev/null +++ b/include/State.hpp @@ -0,0 +1,42 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include + +struct ScanRecord { + std::string ts; + std::string url; + std::string label; + int count = 0; + bool changed = false; +}; + +// Thread-safe replacement for the python `state` dict. Shared between the +// SniperEngine worker thread and the Discord bot command handlers. +class SniperState { +public: + std::atomic running{false}; + std::atomic total_scans{0}; + std::atomic total_changes{0}; + + void setLastOffers(const std::string& url, std::vector offers); + std::optional> getLastOffers(const std::string& url); + std::map stockCounts(); + + void addHistory(ScanRecord rec); + std::vector getHistory(); + + void clearOffers(); + void resetCounters(); + void resetHistory(); + +private: + std::mutex mtx_; + std::map> last_offers_; + std::deque history_; + static constexpr size_t kMaxHistory = 200; +}; diff --git a/src/Config.cpp b/src/Config.cpp new file mode 100644 index 0000000..3abddfc --- /dev/null +++ b/src/Config.cpp @@ -0,0 +1,82 @@ +#include "Config.hpp" +#include +#include +#include + +using json = nlohmann::json; + +static json linkToJson(const LinkConfig& l) { + json j; + j["url"] = l.url; + j["label"] = l.label; + j["min_price"] = l.min_price.has_value() ? json(*l.min_price) : json(nullptr); + j["max_price"] = l.max_price.has_value() ? json(*l.max_price) : json(nullptr); + j["keywords"] = l.keywords; + j["paused"] = l.paused; + return j; +} + +static LinkConfig linkFromJson(const json& j) { + LinkConfig l; + l.url = j.value("url", ""); + l.label = j.value("label", ""); + if (j.contains("min_price") && !j["min_price"].is_null()) + l.min_price = j["min_price"].get(); + if (j.contains("max_price") && !j["max_price"].is_null()) + l.max_price = j["max_price"].get(); + if (j.contains("keywords") && j["keywords"].is_array()) + l.keywords = j["keywords"].get>(); + l.paused = j.value("paused", false); + return l; +} + +AppConfig AppConfig::load(const std::string& path) { + AppConfig cfg; + std::ifstream in(path); + if (!in.is_open()) { + return cfg; // no config yet - caller should create one / prompt the user + } + std::stringstream ss; + ss << in.rdbuf(); + json j; + try { + j = json::parse(ss.str()); + } catch (const std::exception&) { + return cfg; // malformed file, fall back to defaults rather than crash + } + + cfg.discord_bot_token = j.value("discord_bot_token", ""); + cfg.discord_channel_id = j.value("discord_channel_id", ""); + cfg.discord_webhook_url = j.value("discord_webhook_url", ""); + cfg.command_guild_id = j.value("command_guild_id", ""); + cfg.cookie_header = j.value("cookie_header", ""); + cfg.user_agent = j.value("user_agent", cfg.user_agent); + cfg.interval_seconds = j.value("interval_seconds", 180); + cfg.autostart = j.value("autostart", false); + + if (j.contains("links") && j["links"].is_array()) { + for (const auto& lj : j["links"]) { + cfg.links.push_back(linkFromJson(lj)); + } + } + return cfg; +} + +void AppConfig::save(const std::string& path) const { + json j; + j["discord_bot_token"] = discord_bot_token; + j["discord_channel_id"] = discord_channel_id; + j["discord_webhook_url"] = discord_webhook_url; + j["command_guild_id"] = command_guild_id; + j["cookie_header"] = cookie_header; + j["user_agent"] = user_agent; + j["interval_seconds"] = interval_seconds; + j["autostart"] = autostart; + + json links_json = json::array(); + for (const auto& l : links) links_json.push_back(linkToJson(l)); + j["links"] = links_json; + + std::ofstream out(path); + out << j.dump(2); +} diff --git a/src/DiscordBot.cpp b/src/DiscordBot.cpp new file mode 100644 index 0000000..34b3b62 --- /dev/null +++ b/src/DiscordBot.cpp @@ -0,0 +1,243 @@ +#include "DiscordBot.hpp" +#include "Logger.hpp" +#include +#include +#include + +DiscordBot::DiscordBot(AppConfig& config, SniperState& state, SniperEngine& engine, std::string config_path) + : config_(config), state_(state), engine_(engine), config_path_(std::move(config_path)) {} + +void DiscordBot::persist() { config_.save(config_path_); } + +namespace { + +std::optional optDouble(const dpp::slashcommand_t& event, const std::string& name) { + auto v = event.get_parameter(name); + if (std::holds_alternative(v)) return std::nullopt; + if (auto* d = std::get_if(&v)) return *d; + if (auto* i = std::get_if(&v)) return static_cast(*i); + return std::nullopt; +} + +std::string optString(const dpp::slashcommand_t& event, const std::string& name) { + auto v = event.get_parameter(name); + if (auto* s = std::get_if(&v)) return *s; + return ""; +} + +std::vector splitKeywords(const std::string& raw) { + std::vector out; + std::stringstream ss(raw); + std::string item; + while (std::getline(ss, item, ',')) { + size_t a = item.find_first_not_of(' '); + size_t b = item.find_last_not_of(' '); + if (a != std::string::npos) out.push_back(item.substr(a, b - a + 1)); + } + return out; +} + +std::string shorten(const std::string& s, size_t n) { + return s.size() > n ? s.substr(0, n) + "..." : s; +} + +} // namespace + +void DiscordBot::run() { + dpp::cluster bot(config_.discord_bot_token, dpp::i_default_intents); + bot.on_log(dpp::utility::cout_logger()); + + bot.on_slashcommand([this](const dpp::slashcommand_t& event) { + const std::string cmd = event.command.get_command_name(); + + if (cmd == "start") { + std::string err; + if (engine_.start(&err)) { + event.reply("Started watching " + std::to_string(engine_.links().size()) + " link(s)."); + } else { + event.reply(dpp::message("Couldn't start: " + err).set_flags(dpp::m_ephemeral)); + } + return; + } + + if (cmd == "stop") { + engine_.stop(); + event.reply("Stopped."); + return; + } + + if (cmd == "status") { + dpp::embed embed; + embed.set_title("LZT Sniper Status") + .set_color(engine_.isRunning() ? 0x3fb950 : 0x8b949e) + .add_field("Running", engine_.isRunning() ? "yes" : "no", true) + .add_field("Total scans", std::to_string(state_.total_scans.load()), true) + .add_field("Total changes", std::to_string(state_.total_changes.load()), true) + .add_field("Interval", std::to_string(engine_.intervalSeconds()) + "s", true); + + auto counts = state_.stockCounts(); + if (counts.empty()) { + embed.add_field("Stock", "No scans yet.", false); + } else { + std::string body; + for (const auto& [url, n] : counts) body += shorten(url, 45) + " — " + std::to_string(n) + "\n"; + embed.add_field("Stock", body, false); + } + event.reply(dpp::message().add_embed(embed)); + return; + } + + if (cmd == "links") { + auto links = engine_.links(); + if (links.empty()) { + event.reply("No links configured yet. Use /addlink to add one."); + return; + } + std::string body; + for (const auto& l : links) { + body += (l.paused ? "⏸ " : "▶ ") + (l.label.empty() ? shorten(l.url, 60) : l.label) + "\n"; + body += " " + shorten(l.url, 70) + "\n"; + if (l.min_price || l.max_price || !l.keywords.empty()) { + body += " filters:"; + if (l.min_price) body += " min $" + std::to_string(*l.min_price); + if (l.max_price) body += " max $" + std::to_string(*l.max_price); + if (!l.keywords.empty()) { + body += " kw:"; + for (auto& k : l.keywords) body += " " + k; + } + body += "\n"; + } + } + event.reply(dpp::message(body.substr(0, 1900))); + return; + } + + if (cmd == "addlink") { + LinkConfig link; + link.url = optString(event, "url"); + link.label = optString(event, "label"); + link.min_price = optDouble(event, "min_price"); + link.max_price = optDouble(event, "max_price"); + link.keywords = splitKeywords(optString(event, "keywords")); + + if (link.url.empty()) { + event.reply(dpp::message("A url is required.").set_flags(dpp::m_ephemeral)); + return; + } + if (engine_.addLink(link)) { + persist(); + event.reply("Added: " + shorten(link.url, 70)); + } else { + event.reply(dpp::message("That URL is already being watched.").set_flags(dpp::m_ephemeral)); + } + return; + } + + if (cmd == "removelink") { + std::string url = optString(event, "url"); + if (engine_.removeLink(url)) { + persist(); + event.reply("Removed: " + shorten(url, 70)); + } else { + event.reply(dpp::message("URL not found in watch list.").set_flags(dpp::m_ephemeral)); + } + return; + } + + if (cmd == "pause") { + std::string url = optString(event, "url"); + bool nowPaused = false; + if (engine_.togglePause(url, &nowPaused)) { + persist(); + event.reply(nowPaused ? "Paused." : "Resumed."); + } else { + event.reply(dpp::message("URL not found in watch list.").set_flags(dpp::m_ephemeral)); + } + return; + } + + if (cmd == "scan") { + std::string url = optString(event, "url"); + engine_.scanNow(url); + event.reply("Scanning now — I'll post an update in the channel shortly."); + return; + } + + if (cmd == "interval") { + auto secondsOpt = optDouble(event, "seconds"); + if (!secondsOpt.has_value()) { + event.reply(dpp::message("Provide a number of seconds.").set_flags(dpp::m_ephemeral)); + return; + } + engine_.setIntervalSeconds(static_cast(*secondsOpt)); + persist(); + event.reply("Interval set to " + std::to_string(engine_.intervalSeconds()) + "s."); + return; + } + + if (cmd == "history") { + auto history = state_.getHistory(); + if (history.empty()) { + event.reply("No scan history yet."); + return; + } + std::string body; + size_t start = history.size() > 15 ? history.size() - 15 : 0; + for (size_t i = history.size(); i-- > start;) { + const auto& r = history[i]; + body += r.ts + " " + shorten(r.label, 35) + " stock:" + std::to_string(r.count) + + (r.changed ? " [CHANGED]" : "") + "\n"; + } + event.reply(dpp::message("```\n" + body.substr(0, 1900) + "\n```")); + return; + } + }); + + bot.on_ready([&bot, this](const dpp::ready_t&) { + if (dpp::run_once()) { + std::vector commands; + + commands.emplace_back("start", "Start watching all configured links", bot.me.id); + commands.emplace_back("stop", "Stop watching", bot.me.id); + commands.emplace_back("status", "Show current status and stock counts", bot.me.id); + commands.emplace_back("links", "List configured watch links", bot.me.id); + commands.emplace_back("history", "Show recent scan history", bot.me.id); + + dpp::slashcommand addlink("addlink", "Add a link to the watch list", bot.me.id); + addlink.add_option(dpp::command_option(dpp::co_string, "url", "LZT market search URL", true)); + addlink.add_option(dpp::command_option(dpp::co_string, "label", "Friendly name for this link", false)); + addlink.add_option(dpp::command_option(dpp::co_number, "min_price", "Minimum price filter", false)); + addlink.add_option(dpp::command_option(dpp::co_number, "max_price", "Maximum price filter", false)); + addlink.add_option(dpp::command_option(dpp::co_string, "keywords", "Comma-separated required keywords", false)); + commands.push_back(addlink); + + dpp::slashcommand removelink("removelink", "Remove a link from the watch list", bot.me.id); + removelink.add_option(dpp::command_option(dpp::co_string, "url", "URL to remove", true)); + commands.push_back(removelink); + + dpp::slashcommand pause("pause", "Toggle pause on a watched link", bot.me.id); + pause.add_option(dpp::command_option(dpp::co_string, "url", "URL to pause/resume", true)); + commands.push_back(pause); + + dpp::slashcommand scan("scan", "Force an immediate scan of one link", bot.me.id); + scan.add_option(dpp::command_option(dpp::co_string, "url", "URL to scan now", true)); + commands.push_back(scan); + + dpp::slashcommand interval("interval", "Change the polling interval", bot.me.id); + interval.add_option(dpp::command_option(dpp::co_number, "seconds", "Seconds between scan cycles", true)); + commands.push_back(interval); + + if (!config_.command_guild_id.empty()) { + // Guild-scoped registration syncs almost instantly - handy while testing. + bot.guild_bulk_command_create(commands, std::stoull(config_.command_guild_id)); + } else { + // Global registration can take up to an hour to propagate. + bot.global_bulk_command_create(commands); + } + Logger::log("Slash commands registered.", LogTag::Ok); + } + }); + + Logger::log("Connecting to Discord gateway...", LogTag::Info); + bot.start(dpp::st_wait); +} diff --git a/src/DiscordNotifier.cpp b/src/DiscordNotifier.cpp new file mode 100644 index 0000000..b6968c7 --- /dev/null +++ b/src/DiscordNotifier.cpp @@ -0,0 +1,110 @@ +#include "DiscordNotifier.hpp" +#include "HttpClient.hpp" +#include "Logger.hpp" +#include +#include + +using json = nlohmann::json; + +DiscordNotifier::DiscordNotifier(std::string bot_token, std::string channel_id, std::string webhook_url) + : bot_token_(std::move(bot_token)), channel_id_(std::move(channel_id)), webhook_url_(std::move(webhook_url)) {} + +namespace { +std::string isoTimestamp() { + std::time_t t = std::time(nullptr); + char buf[32]; + std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S", std::gmtime(&t)); + return std::string(buf); +} +} // namespace + +std::string DiscordNotifier::buildPayload(const std::string& url, + const std::vector& offers, + const std::string& change_text, + bool ping, + std::optional min_price, + std::optional max_price, + const std::vector& keywords) const { + int stock = static_cast(offers.size()); + int color; + std::string emoji; + if (change_text.find("increased") != std::string::npos) { color = 0x2ecc71; emoji = "\xF0\x9F\x9F\xA2"; } + else if (change_text.find("decreased") != std::string::npos) { color = 0xe74c3c; emoji = "\xF0\x9F\x94\xB4"; } + else if (change_text.find("changed") != std::string::npos) { color = 0xf1c40f; emoji = "\xF0\x9F\x9F\xA1"; } + else { color = 0x3498db; emoji = "\xF0\x9F\x94\xB5"; } + + json fields = json::array(); + if (stock == 0) { + fields.push_back({{"name", "No accounts found"}, {"value", "No matching offers currently listed."}, {"inline", false}}); + } else { + int i = 1; + for (const auto& offer : offers) { + if (i > 6) break; + std::string shortText = offer.size() > 800 ? offer.substr(0, 800) + "..." : offer; + fields.push_back({{"name", "Offer #" + std::to_string(i)}, + {"value", "```" + shortText + "```"}, + {"inline", false}}); + ++i; + } + } + + std::vector filterParts; + if (min_price.has_value()) filterParts.push_back("min $" + std::to_string(*min_price)); + if (max_price.has_value()) filterParts.push_back("max $" + std::to_string(*max_price)); + if (!keywords.empty()) { + std::string kw = "kw: "; + for (size_t i = 0; i < keywords.size(); ++i) { kw += keywords[i]; if (i + 1 < keywords.size()) kw += ", "; } + filterParts.push_back(kw); + } + std::string filterLine; + if (!filterParts.empty()) { + filterLine = "\nFilters: "; + for (size_t i = 0; i < filterParts.size(); ++i) { filterLine += filterParts[i]; if (i + 1 < filterParts.size()) filterLine += " | "; } + } + + json embed = { + {"title", emoji + " LZT Market — Stock Update"}, + {"description", "**" + change_text + "**" + filterLine + "\n\n[Open search page](" + url + ")"}, + {"color", color}, + {"fields", fields}, + {"footer", {{"text", "Stock: " + std::to_string(stock) + " | LZT Sniper C++ v1"}}}, + {"timestamp", isoTimestamp()}, + }; + + json payload = { + {"content", ping ? "@everyone" : ""}, + {"embeds", json::array({embed})}, + {"allowed_mentions", {{"parse", ping ? json::array({"everyone"}) : json::array()}}}, + }; + return payload.dump(); +} + +void DiscordNotifier::sendStockUpdate(const std::string& url, + const std::vector& offers, + const std::string& change_text, + bool ping, + std::optional min_price, + std::optional max_price, + const std::vector& keywords) { + std::string payload = buildPayload(url, offers, change_text, ping, min_price, max_price, keywords); + + if (!bot_token_.empty() && !channel_id_.empty()) { + std::string endpoint = "https://discord.com/api/v10/channels/" + channel_id_ + "/messages"; + std::map headers = {{"Authorization", "Bot " + bot_token_}}; + HttpResponse resp = HttpClient::postJson(endpoint, payload, headers, 15); + if (!resp.ok()) { + Logger::log("Discord channel post failed (" + std::to_string(resp.status) + "): " + resp.body, LogTag::Err); + } + return; + } + + if (!webhook_url_.empty()) { + HttpResponse resp = HttpClient::postJson(webhook_url_, payload, {}, 15); + if (!resp.ok()) { + Logger::log("Discord webhook post failed (" + std::to_string(resp.status) + "): " + resp.body, LogTag::Err); + } + return; + } + + Logger::log("No discord_channel_id or discord_webhook_url configured — skipping notification.", LogTag::Warn); +} diff --git a/src/HttpClient.cpp b/src/HttpClient.cpp new file mode 100644 index 0000000..8d66745 --- /dev/null +++ b/src/HttpClient.cpp @@ -0,0 +1,90 @@ +#include "HttpClient.hpp" +#include +#include + +void HttpClient::globalInit() { curl_global_init(CURL_GLOBAL_DEFAULT); } +void HttpClient::globalCleanup() { curl_global_cleanup(); } + +namespace { +size_t writeCallback(char* ptr, size_t size, size_t nmemb, void* userdata) { + auto* out = static_cast(userdata); + out->append(ptr, size * nmemb); + return size * nmemb; +} + +struct CurlHandle { + CURL* h; + CurlHandle() : h(curl_easy_init()) {} + ~CurlHandle() { if (h) curl_easy_cleanup(h); } +}; + +struct CurlSlist { + curl_slist* list = nullptr; + void append(const std::string& header) { list = curl_slist_append(list, header.c_str()); } + ~CurlSlist() { if (list) curl_slist_free_all(list); } +}; +} // namespace + +HttpResponse HttpClient::get(const std::string& url, + const std::map& headers, + long timeout_seconds) { + HttpResponse resp; + CurlHandle curl; + if (!curl.h) { resp.status = -1; return resp; } + + CurlSlist slist; + for (const auto& [k, v] : headers) slist.append(k + ": " + v); + + curl_easy_setopt(curl.h, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl.h, CURLOPT_HTTPHEADER, slist.list); + curl_easy_setopt(curl.h, CURLOPT_WRITEFUNCTION, writeCallback); + curl_easy_setopt(curl.h, CURLOPT_WRITEDATA, &resp.body); + curl_easy_setopt(curl.h, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl.h, CURLOPT_TIMEOUT, timeout_seconds); + curl_easy_setopt(curl.h, CURLOPT_SSL_VERIFYPEER, 1L); + curl_easy_setopt(curl.h, CURLOPT_ACCEPT_ENCODING, ""); // accept gzip/deflate + + CURLcode rc = curl_easy_perform(curl.h); + if (rc != CURLE_OK) { + resp.status = -1; + resp.body = curl_easy_strerror(rc); + return resp; + } + long code = 0; + curl_easy_getinfo(curl.h, CURLINFO_RESPONSE_CODE, &code); + resp.status = code; + return resp; +} + +HttpResponse HttpClient::postJson(const std::string& url, + const std::string& json_body, + const std::map& headers, + long timeout_seconds) { + HttpResponse resp; + CurlHandle curl; + if (!curl.h) { resp.status = -1; return resp; } + + CurlSlist slist; + slist.append("Content-Type: application/json"); + for (const auto& [k, v] : headers) slist.append(k + ": " + v); + + curl_easy_setopt(curl.h, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl.h, CURLOPT_HTTPHEADER, slist.list); + curl_easy_setopt(curl.h, CURLOPT_POSTFIELDS, json_body.c_str()); + curl_easy_setopt(curl.h, CURLOPT_POSTFIELDSIZE, static_cast(json_body.size())); + curl_easy_setopt(curl.h, CURLOPT_WRITEFUNCTION, writeCallback); + curl_easy_setopt(curl.h, CURLOPT_WRITEDATA, &resp.body); + curl_easy_setopt(curl.h, CURLOPT_TIMEOUT, timeout_seconds); + curl_easy_setopt(curl.h, CURLOPT_SSL_VERIFYPEER, 1L); + + CURLcode rc = curl_easy_perform(curl.h); + if (rc != CURLE_OK) { + resp.status = -1; + resp.body = curl_easy_strerror(rc); + return resp; + } + long code = 0; + curl_easy_getinfo(curl.h, CURLINFO_RESPONSE_CODE, &code); + resp.status = code; + return resp; +} diff --git a/src/Logger.cpp b/src/Logger.cpp new file mode 100644 index 0000000..da81612 --- /dev/null +++ b/src/Logger.cpp @@ -0,0 +1,38 @@ +#include "Logger.hpp" +#include +#include +#include +#include + +Logger::Sink Logger::sink_ = nullptr; + +static std::mutex g_log_mtx; + +static std::string timestamp() { + std::time_t t = std::time(nullptr); + char buf[16]; + std::strftime(buf, sizeof(buf), "%H:%M:%S", std::localtime(&t)); + return std::string(buf); +} + +static const char* tagName(LogTag tag) { + switch (tag) { + case LogTag::Ok: return "OK"; + case LogTag::Warn: return "WARN"; + case LogTag::Err: return "ERR"; + default: return "INFO"; + } +} + +void Logger::setSink(Sink sink) { + std::lock_guard lock(g_log_mtx); + sink_ = std::move(sink); +} + +void Logger::log(const std::string& msg, LogTag tag) { + std::lock_guard lock(g_log_mtx); + std::string ts = timestamp(); + std::fprintf(stdout, "[%s] [%-4s] %s\n", ts.c_str(), tagName(tag), msg.c_str()); + std::fflush(stdout); + if (sink_) sink_(ts, msg, tag); +} diff --git a/src/Scraper.cpp b/src/Scraper.cpp new file mode 100644 index 0000000..f2d29ce --- /dev/null +++ b/src/Scraper.cpp @@ -0,0 +1,236 @@ +#include "Scraper.hpp" +#include "HttpClient.hpp" +#include +#include +#include +#include +#include + +namespace { + +const std::unordered_set kInterestTags = {"div", "li", "article"}; +const std::unordered_set kBlockTags = { + "div", "li", "article", "p", "br", "tr", "td", "th", "h1", "h2", "h3", + "h4", "h5", "h6", "section", "header", "footer", "ul", "ol", "table"}; +const std::unordered_set kVoidTags = { + "br", "img", "input", "hr", "meta", "link", "source", "col", "area"}; + +struct Candidate { + size_t start; + size_t end; + std::string text; +}; + +std::string toLower(std::string s) { + std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return std::tolower(c); }); + return s; +} + +std::string decodeEntities(std::string s) { + static const std::vector> kEntities = { + {" ", " "}, {"&", "&"}, {"<", "<"}, {">", ">"}, + {""", "\""}, {"'", "'"}, {"'", "'"}}; + for (const auto& [from, to] : kEntities) { + size_t pos = 0; + while ((pos = s.find(from, pos)) != std::string::npos) { + s.replace(pos, from.size(), to); + pos += to.size(); + } + } + return s; +} + +// Extracts a tag name from a raw "
'). +std::string tagNameOf(const std::string& tagInner) { + size_t i = 0; + if (i < tagInner.size() && tagInner[i] == '/') ++i; + size_t start = i; + while (i < tagInner.size() && (std::isalnum((unsigned char)tagInner[i]))) ++i; + return toLower(tagInner.substr(start, i - start)); +} + +bool isClosingTag(const std::string& tagInner) { + return !tagInner.empty() && tagInner[0] == '/'; +} + +bool isSelfClosing(const std::string& tagInner) { + return !tagInner.empty() && tagInner.back() == '/'; +} + +// Walks raw (script/style-stripped) HTML, tracking a running plain-text +// buffer and a stack of {tag, textStartOffset} for div/li/article elements. +// Whenever an interest tag closes, its accumulated inner text is recorded as +// a Candidate. This approximates `element.innerText` well enough for card +// extraction without needing a full DOM/CSS layout engine. +std::vector walkHtml(const std::string& html) { + std::vector candidates; + std::vector> stack; + std::string buffer; + buffer.reserve(html.size()); + + size_t i = 0; + const size_t n = html.size(); + while (i < n) { + if (html[i] == '<') { + size_t close = html.find('>', i); + if (close == std::string::npos) break; + std::string tagInner = html.substr(i + 1, close - i - 1); + std::string name = tagNameOf(tagInner); + + if (isClosingTag(tagInner)) { + if (kBlockTags.count(name)) buffer += '\n'; + if (kInterestTags.count(name)) { + // find the nearest matching open entry (handles mildly + // malformed/mismatched nesting gracefully) + for (auto it = stack.rbegin(); it != stack.rend(); ++it) { + if (it->first == name) { + size_t startOffset = it->second; + std::string inner = buffer.substr(startOffset); + candidates.push_back({startOffset, buffer.size(), inner}); + stack.erase(std::next(it).base()); + break; + } + } + } + } else { + bool voidish = kVoidTags.count(name) || isSelfClosing(tagInner); + if (kBlockTags.count(name)) buffer += '\n'; + else buffer += ' '; + if (kInterestTags.count(name) && !voidish) { + stack.push_back({name, buffer.size()}); + } + } + i = close + 1; + } else { + size_t next = html.find('<', i); + if (next == std::string::npos) next = n; + buffer += html.substr(i, next - i); + i = next; + } + } + return candidates; +} + +} // namespace + +Scraper::Scraper(std::string cookie_header, std::string user_agent) + : cookie_header_(std::move(cookie_header)), user_agent_(std::move(user_agent)) { + if (user_agent_.empty()) { + user_agent_ = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) LZTSniperCpp/1.0"; + } +} + +std::string Scraper::cleanText(std::string text) { + text = decodeEntities(text); + text = std::regex_replace(text, std::regex("\\n{3,}"), "\n\n"); + text = std::regex_replace(text, std::regex("[ \\t]+"), " "); + // trim + size_t a = text.find_first_not_of(" \t\n\r"); + size_t b = text.find_last_not_of(" \t\n\r"); + if (a == std::string::npos) return ""; + return text.substr(a, b - a + 1); +} + +std::vector Scraper::extractOfferBlocks(const std::string& html) { + // Strip ", std::regex::icase), " "); + stripped = std::regex_replace( + stripped, std::regex("]*>[\\s\\S]*?", std::regex::icase), " "); + + std::vector raw = walkHtml(stripped); + + // Keep candidates that look like an offer card: mentions "followers", + // reasonable length, not absurdly many lines (mirrors the JS heuristic). + std::vector filtered; + std::regex followersRe("followers", std::regex::icase); + for (auto& c : raw) { + if (!std::regex_search(c.text, followersRe)) continue; + if (c.text.size() <= 40 || c.text.size() >= 2000) continue; + size_t lineCount = 1 + std::count(c.text.begin(), c.text.end(), '\n'); + if (lineCount > 80) continue; + filtered.push_back(c); + } + + // Process in document order (ascending start offset) and drop any + // candidate fully contained within an already-accepted one, and any + // exact text duplicate — matching the original `out.some(...)` logic. + std::sort(filtered.begin(), filtered.end(), + [](const Candidate& a, const Candidate& b) { return a.start < b.start; }); + + std::vector out; + std::unordered_set seenText; + for (const auto& c : filtered) { + if (seenText.count(c.text)) continue; + bool contained = false; + for (const auto& e : out) { + if (e.start <= c.start && c.end <= e.end) { contained = true; break; } + } + if (contained) continue; + out.push_back(c); + seenText.insert(c.text); + } + + std::vector result; + result.reserve(out.size()); + for (auto& c : out) result.push_back(c.text); + return result; +} + +std::vector Scraper::checkOffers(const std::string& url) { + std::map headers; + headers["User-Agent"] = user_agent_; + headers["Accept"] = "text/html,application/xhtml+xml"; + if (!cookie_header_.empty()) headers["Cookie"] = cookie_header_; + + HttpResponse resp = HttpClient::get(url, headers, 30); + if (!resp.ok()) { + throw std::runtime_error("HTTP " + std::to_string(resp.status) + " fetching " + url); + } + + if (resp.body.find("No accounts found") != std::string::npos) { + return {}; + } + + std::vector offers = extractOfferBlocks(resp.body); + std::vector cleaned; + std::unordered_set seen; + for (auto& o : offers) { + std::string c = cleanText(o); + if (!c.empty() && !seen.count(c)) { + seen.insert(c); + cleaned.push_back(c); + } + } + return cleaned; +} + +std::vector Scraper::filterOffers(const std::vector& offers, + std::optional min_price, + std::optional max_price, + const std::vector& keywords) { + std::vector result; + std::regex priceRe("\\$?\\s*(\\d+(?:\\.\\d+)?)"); + for (const auto& offer : offers) { + if (min_price.has_value() || max_price.has_value()) { + std::smatch m; + if (std::regex_search(offer, m, priceRe)) { + double p = std::stod(m[1].str()); + if (min_price.has_value() && p < *min_price) continue; + if (max_price.has_value() && p > *max_price) continue; + } + } + if (!keywords.empty()) { + std::string lower = toLower(offer); + bool allFound = true; + for (const auto& kw : keywords) { + if (lower.find(toLower(kw)) == std::string::npos) { allFound = false; break; } + } + if (!allFound) continue; + } + result.push_back(offer); + } + return result; +} diff --git a/src/SniperEngine.cpp b/src/SniperEngine.cpp new file mode 100644 index 0000000..cb0b5e3 --- /dev/null +++ b/src/SniperEngine.cpp @@ -0,0 +1,185 @@ +#include "SniperEngine.hpp" +#include "Logger.hpp" +#include +#include +#include + +SniperEngine::SniperEngine(AppConfig& config, SniperState& state, DiscordNotifier& notifier) + : config_(config), state_(state), notifier_(notifier), + scraper_(config.cookie_header, config.user_agent) {} + +SniperEngine::~SniperEngine() { + stop(); + if (worker_.joinable()) worker_.join(); +} + +bool SniperEngine::start(std::string* error_out) { + if (state_.running) { + if (error_out) *error_out = "Already running"; + return false; + } + { + std::lock_guard lock(links_mtx_); + if (config_.links.empty()) { + if (error_out) *error_out = "No links configured. Use /addlink first."; + return false; + } + } + if (worker_.joinable()) worker_.join(); + + state_.clearOffers(); + state_.resetCounters(); + state_.resetHistory(); + stop_requested_ = false; + state_.running = true; + + worker_ = std::thread(&SniperEngine::loop, this); + return true; +} + +void SniperEngine::stop() { + stop_requested_ = true; + state_.running = false; +} + +bool SniperEngine::isRunning() const { return state_.running; } + +std::string SniperEngine::getChangeText(size_t n, const std::optional>& old) { + if (!old.has_value()) return "Initial scan - " + std::to_string(n) + " offer(s) found"; + size_t o = old->size(); + if (n > o) return "Stock increased: " + std::to_string(o) + " to " + std::to_string(n); + if (n < o) return "Stock decreased: " + std::to_string(o) + " to " + std::to_string(n); + return "Offers changed, stock still " + std::to_string(n); +} + +void SniperEngine::runOneScan(const LinkConfig& cfg, bool isManual) { + std::string shortUrl = cfg.url.size() > 55 ? cfg.url.substr(0, 55) + "..." : cfg.url; + try { + Logger::log((isManual ? "Manual scan: " : "Checking: ") + shortUrl); + std::vector raw = scraper_.checkOffers(cfg.url); + std::vector offers = Scraper::filterOffers(raw, cfg.min_price, cfg.max_price, cfg.keywords); + + auto old = state_.getLastOffers(cfg.url); + std::set newSet(offers.begin(), offers.end()); + std::set oldSet; + if (old.has_value()) oldSet = std::set(old->begin(), old->end()); + bool changed = !old.has_value() || (newSet != oldSet); + + if (!isManual) { + state_.total_scans++; + ScanRecord rec; + rec.url = cfg.url; + rec.label = cfg.label.empty() ? shortUrl : cfg.label; + rec.count = static_cast(offers.size()); + rec.changed = changed && old.has_value(); + state_.addHistory(rec); + } + + if (changed || isManual) { + std::string changeText = getChangeText(offers.size(), old); + bool ping = old.has_value(); + state_.setLastOffers(cfg.url, offers); + if (changed && old.has_value()) state_.total_changes++; + + LogTag tag = changeText.find("increased") != std::string::npos ? LogTag::Ok + : changeText.find("decreased") != std::string::npos ? LogTag::Warn + : LogTag::Info; + Logger::log(" -> " + changeText, tag); + + notifier_.sendStockUpdate(cfg.url, offers, changeText, isManual ? false : ping, + cfg.min_price, cfg.max_price, cfg.keywords); + } else { + Logger::log(" -> No change. Stock: " + std::to_string(offers.size())); + } + } catch (const std::exception& e) { + Logger::log(" -> Error: " + std::string(e.what()), LogTag::Err); + } +} + +void SniperEngine::loop() { + int interval = config_.interval_seconds; + { + std::lock_guard lock(links_mtx_); + Logger::log("Started — watching " + std::to_string(config_.links.size()) + + " link(s) every " + std::to_string(interval / 60) + " min.", LogTag::Ok); + } + + while (state_.running && !stop_requested_) { + std::vector snapshot; + { + std::lock_guard lock(links_mtx_); + snapshot = config_.links; + } + + for (const auto& cfg : snapshot) { + if (!state_.running || stop_requested_) break; + if (cfg.paused) { + std::string shortUrl = cfg.url.size() > 50 ? cfg.url.substr(0, 50) + "..." : cfg.url; + Logger::log("Skipping (paused): " + shortUrl, LogTag::Warn); + continue; + } + runOneScan(cfg, /*isManual=*/false); + } + + for (int i = 0; i < interval; ++i) { + if (!state_.running || stop_requested_) break; + std::this_thread::sleep_for(std::chrono::seconds(1)); + } + } + state_.running = false; + Logger::log("Checker stopped.", LogTag::Warn); +} + +void SniperEngine::scanNow(const std::string& url) { + LinkConfig cfg; + { + std::lock_guard lock(links_mtx_); + auto it = std::find_if(config_.links.begin(), config_.links.end(), + [&](const LinkConfig& l) { return l.url == url; }); + if (it == config_.links.end()) { + Logger::log("Manual scan requested for unknown URL: " + url, LogTag::Err); + return; + } + cfg = *it; + } + std::thread([this, cfg]() { runOneScan(cfg, /*isManual=*/true); }).detach(); +} + +bool SniperEngine::addLink(LinkConfig link) { + std::lock_guard lock(links_mtx_); + for (const auto& l : config_.links) { + if (l.url == link.url) return false; // already watching this url + } + config_.links.push_back(std::move(link)); + return true; +} + +bool SniperEngine::removeLink(const std::string& url) { + std::lock_guard lock(links_mtx_); + auto before = config_.links.size(); + config_.links.erase(std::remove_if(config_.links.begin(), config_.links.end(), + [&](const LinkConfig& l) { return l.url == url; }), + config_.links.end()); + return config_.links.size() != before; +} + +bool SniperEngine::togglePause(const std::string& url, bool* now_paused_out) { + std::lock_guard lock(links_mtx_); + for (auto& l : config_.links) { + if (l.url == url) { + l.paused = !l.paused; + if (now_paused_out) *now_paused_out = l.paused; + return true; + } + } + return false; +} + +std::vector SniperEngine::links() { + std::lock_guard lock(links_mtx_); + return config_.links; +} + +int SniperEngine::intervalSeconds() const { return config_.interval_seconds; } + +void SniperEngine::setIntervalSeconds(int seconds) { config_.interval_seconds = std::max(15, seconds); } diff --git a/src/State.cpp b/src/State.cpp new file mode 100644 index 0000000..91eecd1 --- /dev/null +++ b/src/State.cpp @@ -0,0 +1,46 @@ +#include "State.hpp" + +void SniperState::setLastOffers(const std::string& url, std::vector offers) { + std::lock_guard lock(mtx_); + last_offers_[url] = std::move(offers); +} + +std::optional> SniperState::getLastOffers(const std::string& url) { + std::lock_guard lock(mtx_); + auto it = last_offers_.find(url); + if (it == last_offers_.end()) return std::nullopt; + return it->second; +} + +std::map SniperState::stockCounts() { + std::lock_guard lock(mtx_); + std::map out; + for (const auto& [url, offers] : last_offers_) out[url] = static_cast(offers.size()); + return out; +} + +void SniperState::addHistory(ScanRecord rec) { + std::lock_guard lock(mtx_); + history_.push_back(std::move(rec)); + while (history_.size() > kMaxHistory) history_.pop_front(); +} + +std::vector SniperState::getHistory() { + std::lock_guard lock(mtx_); + return std::vector(history_.begin(), history_.end()); +} + +void SniperState::clearOffers() { + std::lock_guard lock(mtx_); + last_offers_.clear(); +} + +void SniperState::resetCounters() { + total_scans = 0; + total_changes = 0; +} + +void SniperState::resetHistory() { + std::lock_guard lock(mtx_); + history_.clear(); +} diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..46ed196 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,43 @@ +#include "Config.hpp" +#include "State.hpp" +#include "Scraper.hpp" +#include "DiscordNotifier.hpp" +#include "SniperEngine.hpp" +#include "DiscordBot.hpp" +#include "HttpClient.hpp" +#include "Logger.hpp" +#include +#include + +int main(int argc, char** argv) { + std::string configPath = argc > 1 ? argv[1] : "config.json"; + + AppConfig config = AppConfig::load(configPath); + if (config.discord_bot_token.empty()) { + std::cerr << "No discord_bot_token found in " << configPath << ".\n" + << "Copy config.example.json to " << configPath + << " and fill in discord_bot_token / discord_channel_id, then re-run.\n"; + return 1; + } + + HttpClient::globalInit(); + + SniperState state; + DiscordNotifier notifier(config.discord_bot_token, config.discord_channel_id, config.discord_webhook_url); + SniperEngine engine(config, state, notifier); + DiscordBot bot(config, state, engine, configPath); + + Logger::log("LZT Market Sniper (C++) starting up.", LogTag::Ok); + + if (config.autostart && !config.links.empty()) { + std::string err; + if (!engine.start(&err)) { + Logger::log("autostart failed: " + err, LogTag::Err); + } + } + + bot.run(); // blocks forever servicing the Discord gateway + + HttpClient::globalCleanup(); + return 0; +}