first commit
This commit is contained in:
commit
7349b743e3
21 files changed
+1527
No files matched your search
@@ -0,0 +1,2 @@
|
||||
build/
|
||||
config.json
|
||||
@@ -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
|
||||
)
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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_;
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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 = {
|
||||
{" ", " "}, {"&", "&"}, {"<", "<"}, {">", ">"},
|
||||
{""", "\""}, {"'", "'"}, {"'", "'"}};
|
||||
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;
|
||||
}
|
||||
@@ -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); }
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user