first commit

This commit is contained in:
st committed 2026-08-09 16:51:01 -05:00
commit 7349b743e3
21 files changed
+1527

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
build/
config.json
+49
View File
@@ -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
)
+102
View File
@@ -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.
+20
View File
@@ -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
}
]
}
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include <string>
#include <vector>
#include <optional>
// Per-watched-link settings (mirrors the Python `link_configs` dicts).
struct LinkConfig {
std::string url;
std::string label;
std::optional<double> min_price;
std::optional<double> max_price;
std::vector<std::string> 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<LinkConfig> 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;
};
+37
View File
@@ -0,0 +1,37 @@
#pragma once
#include <string>
#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();
};
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include <string>
#include <vector>
#include <optional>
// 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<std::string>& offers,
const std::string& change_text,
bool ping,
std::optional<double> min_price,
std::optional<double> max_price,
const std::vector<std::string>& keywords);
private:
std::string bot_token_;
std::string channel_id_;
std::string webhook_url_;
std::string buildPayload(const std::string& url,
const std::vector<std::string>& offers,
const std::string& change_text,
bool ping,
std::optional<double> min_price,
std::optional<double> max_price,
const std::vector<std::string>& keywords) const;
};
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include <string>
#include <map>
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<std::string, std::string>& headers = {},
long timeout_seconds = 30);
static HttpResponse postJson(const std::string& url,
const std::string& json_body,
const std::map<std::string, std::string>& headers = {},
long timeout_seconds = 15);
};
+19
View File
@@ -0,0 +1,19 @@
#pragma once
#include <string>
#include <functional>
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<void(const std::string& ts, const std::string& msg, LogTag tag)>;
static void log(const std::string& msg, LogTag tag = LogTag::Info);
static void setSink(Sink sink);
private:
static Sink sink_;
};
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include <string>
#include <vector>
#include <optional>
// 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<std::string> checkOffers(const std::string& url);
// Client-side post-filter equivalent to python's filter_offers().
static std::vector<std::string> filterOffers(const std::vector<std::string>& offers,
std::optional<double> min_price,
std::optional<double> max_price,
const std::vector<std::string>& keywords);
private:
std::string cookie_header_;
std::string user_agent_;
static std::string cleanText(std::string text);
static std::vector<std::string> extractOfferBlocks(const std::string& html);
};
+50
View File
@@ -0,0 +1,50 @@
#pragma once
#include <thread>
#include <atomic>
#include <mutex>
#include <vector>
#include <string>
#include <optional>
#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<LinkConfig> 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<bool> stop_requested_{false};
void loop();
void runOneScan(const LinkConfig& cfg, bool isManual);
static std::string getChangeText(size_t n, const std::optional<std::vector<std::string>>& old);
};
+42
View File
@@ -0,0 +1,42 @@
#pragma once
#include <string>
#include <vector>
#include <map>
#include <deque>
#include <mutex>
#include <atomic>
#include <optional>
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<bool> running{false};
std::atomic<int> total_scans{0};
std::atomic<int> total_changes{0};
void setLastOffers(const std::string& url, std::vector<std::string> offers);
std::optional<std::vector<std::string>> getLastOffers(const std::string& url);
std::map<std::string, int> stockCounts();
void addHistory(ScanRecord rec);
std::vector<ScanRecord> getHistory();
void clearOffers();
void resetCounters();
void resetHistory();
private:
std::mutex mtx_;
std::map<std::string, std::vector<std::string>> last_offers_;
std::deque<ScanRecord> history_;
static constexpr size_t kMaxHistory = 200;
};
+82
View File
@@ -0,0 +1,82 @@
#include "Config.hpp"
#include <nlohmann/json.hpp>
#include <fstream>
#include <sstream>
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<double>();
if (j.contains("max_price") && !j["max_price"].is_null())
l.max_price = j["max_price"].get<double>();
if (j.contains("keywords") && j["keywords"].is_array())
l.keywords = j["keywords"].get<std::vector<std::string>>();
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);
}
+243
View File
@@ -0,0 +1,243 @@
#include "DiscordBot.hpp"
#include "Logger.hpp"
#include <dpp/dpp.h>
#include <sstream>
#include <algorithm>
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<double> optDouble(const dpp::slashcommand_t& event, const std::string& name) {
auto v = event.get_parameter(name);
if (std::holds_alternative<std::monostate>(v)) return std::nullopt;
if (auto* d = std::get_if<double>(&v)) return *d;
if (auto* i = std::get_if<int64_t>(&v)) return static_cast<double>(*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<std::string>(&v)) return *s;
return "";
}
std::vector<std::string> splitKeywords(const std::string& raw) {
std::vector<std::string> 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<int>(*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<struct RegisterLztSniperCommands>()) {
std::vector<dpp::slashcommand> 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);
}
+110
View File
@@ -0,0 +1,110 @@
#include "DiscordNotifier.hpp"
#include "HttpClient.hpp"
#include "Logger.hpp"
#include <nlohmann/json.hpp>
#include <ctime>
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<std::string>& offers,
const std::string& change_text,
bool ping,
std::optional<double> min_price,
std::optional<double> max_price,
const std::vector<std::string>& keywords) const {
int stock = static_cast<int>(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<std::string> 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<std::string>& offers,
const std::string& change_text,
bool ping,
std::optional<double> min_price,
std::optional<double> max_price,
const std::vector<std::string>& 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<std::string, std::string> 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);
}
+90
View File
@@ -0,0 +1,90 @@
#include "HttpClient.hpp"
#include <curl/curl.h>
#include <cstring>
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<std::string*>(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<std::string, std::string>& 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<std::string, std::string>& 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<long>(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;
}
+38
View File
@@ -0,0 +1,38 @@
#include "Logger.hpp"
#include <iostream>
#include <mutex>
#include <ctime>
#include <cstdio>
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<std::mutex> lock(g_log_mtx);
sink_ = std::move(sink);
}
void Logger::log(const std::string& msg, LogTag tag) {
std::lock_guard<std::mutex> 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);
}
+236
View File
@@ -0,0 +1,236 @@
#include "Scraper.hpp"
#include "HttpClient.hpp"
#include <algorithm>
#include <cctype>
#include <regex>
#include <set>
#include <unordered_set>
namespace {
const std::unordered_set<std::string> kInterestTags = {"div", "li", "article"};
const std::unordered_set<std::string> 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<std::string> 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<std::pair<std::string, std::string>> kEntities = {
{"&nbsp;", " "}, {"&amp;", "&"}, {"&lt;", "<"}, {"&gt;", ">"},
{"&quot;", "\""}, {"&#39;", "'"}, {"&apos;", "'"}};
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 "<div class=..." style token (without the
// leading '<' or trailing '>').
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<Candidate> walkHtml(const std::string& html) {
std::vector<Candidate> candidates;
std::vector<std::pair<std::string, size_t>> 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<std::string> Scraper::extractOfferBlocks(const std::string& html) {
// Strip <script> and <style> blocks first so their contents never leak
// into extracted text.
std::string stripped = std::regex_replace(
html, std::regex("<script[^>]*>[\\s\\S]*?</script>", std::regex::icase), " ");
stripped = std::regex_replace(
stripped, std::regex("<style[^>]*>[\\s\\S]*?</style>", std::regex::icase), " ");
std::vector<Candidate> 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<Candidate> 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<Candidate> out;
std::unordered_set<std::string> 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<std::string> result;
result.reserve(out.size());
for (auto& c : out) result.push_back(c.text);
return result;
}
std::vector<std::string> Scraper::checkOffers(const std::string& url) {
std::map<std::string, std::string> 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<std::string> offers = extractOfferBlocks(resp.body);
std::vector<std::string> cleaned;
std::unordered_set<std::string> 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<std::string> Scraper::filterOffers(const std::vector<std::string>& offers,
std::optional<double> min_price,
std::optional<double> max_price,
const std::vector<std::string>& keywords) {
std::vector<std::string> 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;
}
+185
View File
@@ -0,0 +1,185 @@
#include "SniperEngine.hpp"
#include "Logger.hpp"
#include <algorithm>
#include <set>
#include <chrono>
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<std::mutex> 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<std::vector<std::string>>& 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<std::string> raw = scraper_.checkOffers(cfg.url);
std::vector<std::string> offers = Scraper::filterOffers(raw, cfg.min_price, cfg.max_price, cfg.keywords);
auto old = state_.getLastOffers(cfg.url);
std::set<std::string> newSet(offers.begin(), offers.end());
std::set<std::string> oldSet;
if (old.has_value()) oldSet = std::set<std::string>(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<int>(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<std::mutex> 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<LinkConfig> snapshot;
{
std::lock_guard<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<LinkConfig> SniperEngine::links() {
std::lock_guard<std::mutex> 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); }
+46
View File
@@ -0,0 +1,46 @@
#include "State.hpp"
void SniperState::setLastOffers(const std::string& url, std::vector<std::string> offers) {
std::lock_guard<std::mutex> lock(mtx_);
last_offers_[url] = std::move(offers);
}
std::optional<std::vector<std::string>> SniperState::getLastOffers(const std::string& url) {
std::lock_guard<std::mutex> lock(mtx_);
auto it = last_offers_.find(url);
if (it == last_offers_.end()) return std::nullopt;
return it->second;
}
std::map<std::string, int> SniperState::stockCounts() {
std::lock_guard<std::mutex> lock(mtx_);
std::map<std::string, int> out;
for (const auto& [url, offers] : last_offers_) out[url] = static_cast<int>(offers.size());
return out;
}
void SniperState::addHistory(ScanRecord rec) {
std::lock_guard<std::mutex> lock(mtx_);
history_.push_back(std::move(rec));
while (history_.size() > kMaxHistory) history_.pop_front();
}
std::vector<ScanRecord> SniperState::getHistory() {
std::lock_guard<std::mutex> lock(mtx_);
return std::vector<ScanRecord>(history_.begin(), history_.end());
}
void SniperState::clearOffers() {
std::lock_guard<std::mutex> lock(mtx_);
last_offers_.clear();
}
void SniperState::resetCounters() {
total_scans = 0;
total_changes = 0;
}
void SniperState::resetHistory() {
std::lock_guard<std::mutex> lock(mtx_);
history_.clear();
}
+43
View File
@@ -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 <iostream>
#include <cstdlib>
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;
}