From 6f98f39925840b13c575e0d1f1c2e87c023e2e43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=93=9D=E8=89=B2=E9=97=AA=E7=94=B5?= Date: Wed, 15 Jul 2026 03:40:54 +0800 Subject: [PATCH 01/10] Isolate provider profiles and document onboarding --- AGENTS.md | 2 +- README.md | 59 +- core/deploy.sh | 4 +- deploy-vps.sh | 13 +- docs/architecture.md | 21 +- docs/provider-onboarding.md | 53 ++ docs/vps-stock-monitor.md | 18 + tests/test_deploy_output.py | 51 ++ tests/test_vps_stock.py | 730 +++++++++++++++++++ tools/vps_stock.py | 1344 +++++++++++++++++++++++++++++++++++ 10 files changed, 2261 insertions(+), 34 deletions(-) create mode 100644 docs/provider-onboarding.md create mode 100644 docs/vps-stock-monitor.md create mode 100644 tests/test_vps_stock.py create mode 100644 tools/vps_stock.py diff --git a/AGENTS.md b/AGENTS.md index f9ee2d1..14ae9e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ - Work from the current repository root. Do not create or maintain another local project copy. - Resolve paths from the repository root; never hardcode the local checkout directory name. -- Keep GCloud and DMIT state isolated under `profiles/gcloud/` and `profiles/dmit/`. +- Keep every provider/server profile isolated under `profiles//`; use an explicit `VPS_PROFILE` for every existing VPS. - Treat all of `profiles/` as sensitive local state. Never stage, commit, print, or upload its contents. - Store provider-specific client YAML in `profiles//clients/`. - Store a VPS login key only in `profiles//ssh/`; never copy it to the server. diff --git a/README.md b/README.md index 26707c2..dce6f52 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Self-hosted Network Node -一套共享代理核心,两个明确入口:自动创建 Google Cloud 节点,或部署到 DMIT 等已有 Debian/Ubuntu VPS。 +一套共享代理核心,两个明确入口:自动创建 Google Cloud 节点,或部署到任意已有 Debian/Ubuntu VPS。 -One shared proxy core with two explicit deployment paths: Google Cloud or an existing Debian/Ubuntu VPS such as DMIT. +One shared proxy core with two explicit deployment paths: Google Cloud or any existing Debian/Ubuntu VPS. - Primary: VLESS + Reality - UDP fallback: Hysteria2 @@ -25,14 +25,19 @@ gcloud config set project <你的项目ID> GCP adapter 会预留静态 IP、配置云防火墙、创建 Debian VM,并通过 IAP SSH 安装服务端。 -### DMIT / 普通 VPS +### 已有 Debian/Ubuntu VPS VPS 安装 Debian/Ubuntu,并把本机公钥加入初始 root 账户,然后运行: ```bash +VPS_PROFILE=frantech \ +VPS_SSH_KEY="$HOME/.ssh/frantech_ed25519" \ ./deploy-vps.sh ``` +`VPS_PROFILE` 必须为每台 VPS 使用一个唯一名称,例如 `dmit`、`frantech`、`new-york-01`。 +不要裸跑 `./deploy-vps.sh`,这样可以避免新服务器误用已有 profile。 + VPS adapter 会执行以下安全步骤: 1. 验证初始 root 公钥登录。 @@ -43,16 +48,10 @@ VPS adapter 会执行以下安全步骤: 重跑时会直接复用已经创建的 `mt` 用户,不再依赖 root。 -如果 SSH 私钥不是默认的 `~/.ssh/id_ed25519`: - -```bash -VPS_SSH_KEY=/path/to/private-key ./deploy-vps.sh -``` - 不要把私钥复制到服务器、Git 跟踪文件或聊天中。 -首次部署后可把主机专用私钥放在 `profiles/dmit/ssh/id_rsa.pem`。后续在项目根目录直接运行 -`./deploy-vps.sh` 即可自动复用已保存的 IP 和私钥,不需要依赖仓库外路径。 +首次部署后可以把主机专用私钥放在 `profiles//ssh/`,也可以继续通过 +`VPS_SSH_KEY=/path/to/private-key` 显式指定。后续重跑时使用同一个 `VPS_PROFILE`,脚本会复用该 profile 的 IP、端口和本地密钥。 ## 目录结构 @@ -63,16 +62,17 @@ providers/ GCP、普通 VPS adapter core/ 共享部署流水线、密钥、协议安装、规则与 YAML 生成 config/ 不含密钥的配置模板 docs/ 架构说明与排障文档 -profiles/gcloud/ GCloud 独立状态与 clients/(不提交) -profiles/dmit/ DMIT 独立状态、clients/ 与 ssh/(不提交) +tools/vps_stock.py 只读 VPS 库存监控 +profiles// 每台服务器独立状态、clients/ 与 ssh/(不提交) ``` GCP 和 VPS 真正变化的只有服务器生命周期、连接方式与防火墙;协议和客户端规则只维护一份。详细 seam 和 provider interface 见 [架构说明](docs/architecture.md)。 ## 配置 -首次运行会从 `config/deploy.conf.example` 创建各自的本地状态:GCloud 位于 -`profiles/gcloud/`,DMIT 位于 `profiles/dmit/`。两个目录都已被 Git 忽略。 +首次运行会从 `config/deploy.conf.example` 创建当前 profile 的本地状态。GCloud 固定使用 +`profiles/gcloud/`;普通 VPS 使用 `VPS_PROFILE` 对应的 `profiles//`。整个 +`profiles/` 目录都已被 Git 忽略。 | 配置 | 默认值 | 说明 | |---|---|---| @@ -99,25 +99,36 @@ GCP 和 VPS 真正变化的只有服务器生命周期、连接方式与防火 - `profiles/gcloud/clients/gcloud-mac.yaml` - `profiles/gcloud/clients/gcloud-iphone.yaml` -- `profiles/dmit/clients/dmit-mac.yaml` -- `profiles/dmit/clients/dmit-iphone.yaml` +- `profiles//clients/-mac.yaml` +- `profiles//clients/-iphone.yaml` -生成器只替换当前 profile 前缀的文件,例如 DMIT 只处理 `dmit-*.yaml`,不会覆盖 -`gcloud-*.yaml`。客户端 YAML 默认权限为 `600`,因为其中含节点地址、UUID 和密码; +生成器只替换当前 profile 前缀的文件,例如 `frantech` 只处理 `frantech-*.yaml`,不会覆盖 +`dmit-*.yaml` 或 `gcloud-*.yaml`。客户端 YAML 默认权限为 `600`,因为其中含节点地址、UUID 和密码; 这可阻止同一台电脑上的其他系统用户读取。iCloud 副本用于设备同步,不作为项目源状态。 - Clash Verge:Settings → Profiles → Import - 手机:使用支持 Reality、Hysteria2 和 AnyTLS 的 Mihomo/Clash.Meta 兼容客户端 -修改 `DEVICES` 后重跑原来使用的平台入口,即可增加或撤销设备。 +修改 `DEVICES` 后重跑同一个 profile 的入口,即可增加或撤销设备。 ## 重跑与维护 - 两个入口均按幂等方式设计,会复用已有服务器和本地密钥。 -- 只重新生成 GCloud 客户端 YAML:`NETWORK_NODE_PROFILE=gcloud python3 core/gen-clash.py` -- 只重新生成 DMIT 客户端 YAML:`NETWORK_NODE_PROFILE=dmit python3 core/gen-clash.py` +- 只重新生成某个 profile 的客户端 YAML:`NETWORK_NODE_PROFILE= python3 core/gen-clash.py` - GCP 旧命令 `./deploy.sh` 仍可使用,但会提示改用 `./deploy-gcp.sh`。 - 通用排障见 [Troubleshooting](docs/troubleshooting.md)。 +- 新服务器接入和隔离规则见 [Provider Onboarding](docs/provider-onboarding.md)。 +- VPS 库存监控见 [VPS Stock Monitor](docs/vps-stock-monitor.md)。 + +## VPS 库存监控 + +从仓库根目录运行只读检查: + +```bash +python3 tools/vps_stock.py --state-file ~/.cache/network-node/vps-stock.json +``` + +库存状态放在仓库外,不会进入 Git;监控不会登录、下单或修改任何供应商账户。 ## English @@ -128,7 +139,7 @@ GCP 和 VPS 真正变化的只有服务器生命周期、连接方式与防火 ./deploy-gcp.sh # Configure an existing Debian/Ubuntu VPS -./deploy-vps.sh +VPS_PROFILE=frantech VPS_SSH_KEY="$HOME/.ssh/frantech_ed25519" ./deploy-vps.sh ``` Both entry points run the same shared pipeline: @@ -144,7 +155,7 @@ The provider adapters only own host lifecycle, connectivity, and firewall behavi For a non-default VPS key: ```bash -VPS_SSH_KEY=/path/to/private-key ./deploy-vps.sh +VPS_PROFILE= VPS_SSH_KEY=/path/to/private-key ./deploy-vps.sh ``` Never copy private keys or `.secrets.env` into Git-tracked files, onto the server, or into chat. diff --git a/core/deploy.sh b/core/deploy.sh index 87a003d..4adb0ff 100755 --- a/core/deploy.sh +++ b/core/deploy.sh @@ -47,7 +47,9 @@ run_deploy() { export REALITY_TARGET ok "$PROVIDER_DESCRIPTION 设备=[$DEVICES]" - bash "$PROJECT_DIR/core/secrets.sh" + PROFILE_NAME="$PROFILE_NAME" \ + NETWORK_NODE_STATE_DIR="$STATE_DIR" \ + bash "$PROJECT_DIR/core/secrets.sh" load_secrets provider_provision diff --git a/deploy-vps.sh b/deploy-vps.sh index 0275840..750d436 100755 --- a/deploy-vps.sh +++ b/deploy-vps.sh @@ -2,7 +2,18 @@ # Entry point: configure an already-provisioned Debian/Ubuntu VPS (DMIT, etc.). set -euo pipefail PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROFILE_NAME="${VPS_PROFILE:-dmit}" +VPS_PROFILE="${VPS_PROFILE:-}" +if [ -z "$VPS_PROFILE" ]; then + printf '请显式设置 VPS_PROFILE,例如:VPS_PROFILE=frantech ./deploy-vps.sh \n' >&2 + exit 2 +fi +case "$VPS_PROFILE" in + *[!A-Za-z0-9_-]*) + printf 'VPS_PROFILE 只能包含字母、数字、下划线和连字符:%s\n' "$VPS_PROFILE" >&2 + exit 2 + ;; +esac +PROFILE_NAME="$VPS_PROFILE" . "$PROJECT_DIR/core/common.sh" . "$PROJECT_DIR/providers/vps.sh" . "$PROJECT_DIR/core/deploy.sh" diff --git a/docs/architecture.md b/docs/architecture.md index 7aefcbc..2078c17 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,11 +1,11 @@ # Architecture -The repository keeps protocol behaviour and client routing rules in one shared core while isolating provider-specific lifecycle operations behind adapters. +The repository keeps protocol behaviour and client routing rules in one shared core while isolating provider-specific lifecycle operations and credentials behind adapters and profiles. ## Entry points - `deploy-gcp.sh` loads the Google Cloud adapter. -- `deploy-vps.sh` loads the generic Debian/Ubuntu VPS adapter. +- `deploy-vps.sh` loads the generic Debian/Ubuntu VPS adapter and requires an explicit `VPS_PROFILE`. - `deploy.sh` is a compatibility alias for the GCP entry point. All entry points hand control to `core/deploy.sh`. @@ -34,17 +34,24 @@ Provider state is isolated below `profiles/`: ```text profiles/ -├── gcloud/ +├── gcloud/ # fixed GCloud profile │ ├── deploy.conf │ ├── .secrets.env │ └── clients/gcloud-{mac,iphone}.yaml -└── dmit/ +├── dmit/ # existing VPS profile +│ ├── deploy.conf +│ ├── .secrets.env +│ ├── clients/dmit-{mac,iphone}.yaml +│ └── ssh/id_rsa.pem +└── / # one independent bundle per new VPS ├── deploy.conf ├── .secrets.env - ├── clients/dmit-{mac,iphone}.yaml - └── ssh/id_rsa.pem + ├── clients/-{mac,iphone}.yaml + └── ssh/ # optional local-only key storage ``` The entire `profiles/` tree is gitignored. This keeps host lifecycle state and credentials separate while both providers continue to consume the same protocol installer and routing-rule template. -Each provider profile owns its state, client outputs, and optional host credentials. The generator removes only YAML files prefixed with the active profile name, so running one adapter cannot overwrite another adapter's outputs. +Each profile owns its state, client outputs, and optional host credentials. The generator removes only YAML files prefixed with the active profile name, so running one adapter cannot overwrite another adapter's outputs. The VPS entry point rejects missing or unsafe profile names to prevent accidental cross-provider writes. + +The stock monitor under `tools/` is a separate read-only utility. It uses official inventory pages and recent social leads, stores its deduplication state outside the repository, and never logs into a provider account. diff --git a/docs/provider-onboarding.md b/docs/provider-onboarding.md new file mode 100644 index 0000000..9ee54e9 --- /dev/null +++ b/docs/provider-onboarding.md @@ -0,0 +1,53 @@ +# Provider Onboarding + +Use one profile per server. A profile name is a local namespace for the server's address, generated credentials, client YAML, and optional SSH key; it is not a provider account password. + +## Existing Debian/Ubuntu VPS + +1. Install Debian 12/13 64-bit or Ubuntu 24.04 LTS. +2. Confirm root SSH access with a public key. Keep the private key on the local machine only. +3. Run the generic VPS entry point with an explicit, unique profile name: + +```bash +VPS_PROFILE=frantech \ +VPS_SSH_KEY="$HOME/.ssh/frantech_ed25519" \ +./deploy-vps.sh +``` + +Use a different profile for every server, for example `dmit`, `frantech`, or `new-york-01`. Do not run `./deploy-vps.sh` without `VPS_PROFILE`. + +The first run creates `profiles//deploy.conf` and `.secrets.env`, secures the host, creates the `mt` sudo user, installs the shared protocols, and writes: + +```text +profiles//clients/-mac.yaml +profiles//clients/-iphone.yaml +``` + +After the first successful run, root login is disabled and `mt` is the maintenance user: + +```bash +ssh -i "$HOME/.ssh/frantech_ed25519" mt@ +``` + +## Adding another provider lifecycle + +Use `deploy-vps.sh` when the provider already exposes a reachable Debian/Ubuntu host. Add a new adapter under `providers/` only when the provider needs different lifecycle, authentication, or firewall operations. Keep shared protocol installation, secrets, routing, and client generation in `core/`. + +## Profile isolation checklist + +- Choose a new profile name before touching a new host. +- Keep `deploy.conf`, `.secrets.env`, `clients/`, and `ssh/` inside that profile only. +- Never copy private keys to the server, stage `profiles/`, or upload client YAML. +- Regenerate one profile's clients with: + +```bash +NETWORK_NODE_PROFILE= python3 core/gen-clash.py +``` + +- Before pushing, verify: + +```bash +git ls-files profiles +``` + +The command must print nothing. diff --git a/docs/vps-stock-monitor.md b/docs/vps-stock-monitor.md new file mode 100644 index 0000000..66c50c4 --- /dev/null +++ b/docs/vps-stock-monitor.md @@ -0,0 +1,18 @@ +# VPS Stock Monitor + +`tools/vps_stock.py` is a read-only inventory monitor. It checks public provider pages and recent social leads; it never logs in, adds a product to a cart, submits an order, or changes a server. + +Run it from the repository root: + +```bash +python3 tools/vps_stock.py --state-file ~/.cache/network-node/vps-stock.json +``` + +The state file is intentionally outside the repository. The monitor reports only new availability/lead transitions and meaningful source exceptions. Social posts and third-party catalog observations remain leads until the provider's official page is checked. + +Validation for monitor changes: + +```bash +python3 -m py_compile tools/vps_stock.py +python3 -m unittest tests.test_vps_stock +``` diff --git a/tests/test_deploy_output.py b/tests/test_deploy_output.py index d4cdd34..af2db6e 100644 --- a/tests/test_deploy_output.py +++ b/tests/test_deploy_output.py @@ -1,6 +1,9 @@ #!/usr/bin/env python3 import pathlib +import shutil import subprocess +import tempfile +import textwrap import unittest @@ -8,6 +11,16 @@ class DeployOutputTest(unittest.TestCase): + def test_vps_entrypoint_requires_explicit_profile(self): + result = subprocess.run( + ["bash", str(PROJECT_ROOT / "deploy-vps.sh")], + text=True, + capture_output=True, + check=False, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("VPS_PROFILE", result.stderr) + def test_reality_client_password_is_redacted_from_logs(self): command = ( f'. "{PROJECT_ROOT / "core" / "common.sh"}"; ' @@ -27,6 +40,44 @@ def test_reality_client_password_is_redacted_from_logs(self): ) self.assertNotIn("client-secret", result.stdout) + def test_vps_profile_is_passed_to_secret_generation_subprocess(self): + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) + shutil.copytree(PROJECT_ROOT / "core", root / "core") + shutil.copytree(PROJECT_ROOT / "config", root / "config") + command = textwrap.dedent( + f""" + set -euo pipefail + PROJECT_DIR='{root}' + PROFILE_NAME=frantech + NETWORK_NODE_STATE_DIR='{root / 'state'}' + NETWORK_NODE_CLIENTS_DIR='{root / 'clients'}' + . \"$PROJECT_DIR/core/common.sh\" + . \"$PROJECT_DIR/core/deploy.sh\" + PROVIDER_TITLE='Test VPS' + PROVIDER_DESCRIPTION='provider=test' + provider_init() {{ :; }} + provider_preflight() {{ :; }} + provider_configure() {{ + mkdir -p \"$STATE_DIR\" + cp \"$PROJECT_DIR/config/deploy.conf.example\" \"$CONF_FILE\" + }} + provider_provision() {{ setkv STATIC_IP 203.0.113.10; }} + provider_install() {{ printf 'REALITY_PUBLIC_KEY=test-public-key\\n'; }} + provider_print_summary() {{ :; }} + run_deploy + test -f \"$STATE_DIR/.secrets.env\" + test ! -e \"$PROJECT_DIR/profiles/gcloud/.secrets.env\" + """ + ) + result = subprocess.run( + ["bash", "-c", command], + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_vps_stock.py b/tests/test_vps_stock.py new file mode 100644 index 0000000..b1856c8 --- /dev/null +++ b/tests/test_vps_stock.py @@ -0,0 +1,730 @@ +import json +import io +import os +import sys +import tempfile +import time +import unittest +from datetime import date, datetime, timedelta, timezone +from pathlib import Path +from unittest.mock import patch + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) + +from vps_stock import ( # noqa: E402 + check_bwh_json, + check_counted_html, + check_frantech_html, + check_html, + check_reddit, + check_twitter_discovery, + check_twitter, + check_whmcs_offer_html, + compact_memory, + dedupe_discovery_results, + find_transitions, + filter_discovery_posts, + main, + monitorability, + prune_state_posts, + select_non_social_providers, + select_providers, + select_sources, +) + + +class VpsStockParsingTests(unittest.TestCase): + def test_buyvm_cart_parses_per_plan_available_counts(self): + provider = { + "id": "buyvm-lv", + "provider": "BuyVM", + "region": "Las Vegas, US", + "priority": "value", + "network": "standard", + "url": "https://my.frantech.ca/cart.php?gid=37", + } + html = """ +

LV RYZEN KVM 512MB

+
$3.00 USD/mo
+
0 Available
+

LV RYZEN KVM 1GB

+
$5.00 USD/mo
+
3 Available
+ """ + result = check_frantech_html(provider, 200, html) + self.assertEqual(result["status"], "available") + self.assertFalse(result["plans"][0]["available"]) + self.assertTrue(result["plans"][1]["available"]) + self.assertEqual(result["plans"][1]["price"]["amount"], 5.0) + self.assertEqual(result["plans"][1]["price"]["period"], "month") + self.assertTrue(result["plans"][1]["price"]["price_eligible"]) + + def test_buyvm_sources_cover_all_four_frantech_amd_categories(self): + sources = { + item["id"]: item["url"] + for item in select_providers() + if item["provider"] == "BuyVM" + } + self.assertEqual( + sources, + { + "buyvm-lv": "https://my.frantech.ca/cart.php?gid=37", + "buyvm-ny": "https://my.frantech.ca/cart.php?gid=38", + "buyvm-miami": "https://my.frantech.ca/cart.php?gid=48", + "buyvm-switzerland": "https://my.frantech.ca/cart.php?gid=39", + }, + ) + + def test_counted_html_treats_zero_as_out_of_stock(self): + provider = { + "id": "greencloud-us", + "provider": "GreenCloud", + "region": "US", + "priority": "value", + "network": "standard", + "url": "https://example.invalid/store", + "plan_prefixes": ["BudgetKVM"], + } + html = "BudgetKVMLA-1 0 Available BudgetKVMLA-2 3 Available" + result = check_counted_html(provider, 200, html) + self.assertEqual(result["status"], "available") + self.assertFalse(result["plans"][0]["available"]) + self.assertTrue(result["plans"][1]["available"]) + + def test_counted_html_reports_cloudflare_as_blocked(self): + provider = { + "id": "greencloud-us", + "provider": "GreenCloud", + "region": "US", + "priority": "value", + "network": "standard", + "url": "https://example.invalid/store", + "plan_prefixes": ["BudgetKVM"], + } + result = check_counted_html(provider, 403, "Just a moment") + self.assertEqual(result["status"], "blocked") + + def test_hostdare_store_counts_cover_the_three_cn2_categories(self): + provider = { + "id": "hostdare-lax-cn2-hdd", + "provider": "HostDare", + "region": "Los Angeles, US", + "priority": "cn2", + "network": "CN2", + "url": "https://bill.hostdare.com/store/premium-china-optimized-kvm-vps", + "plan_prefixes": ["CKVM"], + } + html = "CKVM1 0 Available CKVM5 2 Available CKVM8 0 Available" + result = check_counted_html(provider, 200, html) + self.assertEqual(result["status"], "available") + self.assertEqual(result["plans"][1]["count"], 2) + + def test_bwh_json_uses_public_product_and_datacenter_inventory(self): + provider = { + "id": "bwh-lax-cn2", + "provider": "BWH", + "region": "Los Angeles, US", + "priority": "cn2", + "network": "CN2 GIA", + "url": "https://bandwagonhost.com/order/get-data", + "tiers": ["ecommerce"], + "datacenters": ["USCA_6", "USCA_9"], + } + payload = { + "products": [ + { + "id": 87, + "name": "SPECIAL 20G KVM PROMO V5 - CN2 GIA ECOMMERCE", + "outOfStock": False, + "tiers": ["ecommerce"], + "datacenters": {"USCA_6": 50}, + "prices": [{"period": "Quarterly", "cents": 4999}], + }, + { + "id": 88, + "name": "Sold out CN2 plan", + "outOfStock": True, + "tiers": ["ecommerce"], + "datacenters": {"USCA_9": 55}, + "prices": [{"period": "Quarterly", "cents": 8999}], + }, + ] + } + result = check_bwh_json(provider, 200, json.dumps(payload)) + self.assertEqual(result["status"], "available") + self.assertEqual(result["plans"][0]["product_id"], 87) + self.assertTrue(result["plans"][0]["available"]) + self.assertFalse(result["plans"][1]["available"]) + self.assertEqual(result["plans"][0]["prices"][0]["amount"], 49.99) + self.assertFalse(result["plans"][0]["prices"][0]["price_eligible"]) + + def test_catalog_page_is_not_reported_as_stock(self): + provider = { + "id": "hostdare-lax-cn2", + "provider": "HostDare", + "region": "Los Angeles, US", + "priority": "cn2", + "network": "CN2", + "url": "https://example.invalid/catalog", + "stock_signal": "catalog", + } + result = check_html(provider, 200, "Order Now") + self.assertEqual(result["status"], "catalog_only") + + def test_zgovps_whmcs_offer_parses_target_annual_plan_stock(self): + provider = { + "id": "zgovps-lax-special-52", + "provider": "ZgoVPS", + "region": "Los Angeles, US", + "priority": "cn2", + "network": "GIA / 9929 / CMIN2", + "url": "https://clients.zgovps.com/index.php?/cart/special-offer/", + "plan_name": "Los Angeles AMD Optimised VPS - Specials - Starter", + "target_price": 52.0, + "target_period": "year", + } + html = """ +
+ + Los Angeles AMD Optimised VPS - Specials - Starter +
  • 1 Core AMD EPYC
  • GIA&9929&CMIN2
  • + + +
    +
    + + HongKong AMD VPS - Specials - Starter + + +
    + """ + result = check_whmcs_offer_html(provider, 200, html) + self.assertEqual(result["status"], "out_of_stock") + self.assertEqual(len(result["plans"]), 1) + self.assertEqual(result["plans"][0]["product_id"], 134) + self.assertEqual(result["plans"][0]["price"]["amount"], 52.0) + self.assertEqual(result["plans"][0]["price"]["period"], "year") + self.assertTrue(result["plans"][0]["price"]["price_eligible"]) + self.assertFalse(result["plans"][0]["available"]) + + def test_zgovps_52_dollar_offers_are_separate_default_sources(self): + sources = {item["id"]: item for item in select_providers()} + self.assertEqual(sources["zgovps-lax-special-52"]["target_price"], 52.0) + self.assertEqual(sources["zgovps-hkg-special-52"]["target_price"], 52.0) + self.assertEqual(sources["zgovps-lax-special-52"]["priority"], "cn2") + + def test_default_selection_is_the_focus_group(self): + focused = {item["id"] for item in select_providers()} + self.assertIn("buyvm-lv", focused) + self.assertIn("greencloud-store", focused) + self.assertIn("dmit-x", focused) + self.assertIn("hostdare-lax-cn2", focused) + self.assertIn("hostdare-lax-cn2-amd", focused) + self.assertIn("hostdare-lax-cn2-hdd", focused) + self.assertIn("bwh-lax-cn2", focused) + self.assertNotIn("greencloud-x", focused) + self.assertNotIn("racknerd-lax", focused) + self.assertNotIn("cloudcone-lax", focused) + + def test_all_selection_includes_low_priority_fallbacks(self): + all_ids = {item["id"] for item in select_providers(all_providers=True)} + self.assertNotIn("cloudcone-lax", all_ids) + self.assertNotIn("racknerd-lax", all_ids) + + def test_default_sources_include_social_checks_for_default_providers(self): + sources = {item["id"] for item in select_sources()} + self.assertIn("dmit-x", sources) + self.assertIn("dmit-reddit", sources) + self.assertIn("racknerd-reddit", sources) + self.assertIn("racknerd-x", sources) + self.assertIn("cloudcone-x", sources) + self.assertIn("cloudcone-reddit", sources) + self.assertNotIn("buyvm-x", sources) + self.assertNotIn("greencloud-reddit", sources) + self.assertNotIn("hostdare-x", sources) + self.assertNotIn("bwh-reddit", sources) + self.assertIn("vps-discovery-reddit-vps-restock", sources) + self.assertIn("vps-discovery-x-cn2-vps", sources) + + def test_non_social_selection_excludes_x_and_reddit_sources(self): + sources = {item["id"] for item in select_non_social_providers()} + self.assertIn("buyvm-lv", sources) + self.assertIn("greencloud-store", sources) + self.assertIn("bwh-lax-cn2", sources) + self.assertNotIn("dmit-x", sources) + self.assertNotIn("racknerd-lax", sources) + self.assertNotIn("cloudcone-lax", sources) + + def test_monitorability_describes_non_social_boundaries(self): + rows = {item["id"]: item for item in monitorability()} + self.assertEqual(rows["buyvm-lv"]["level"], "stock") + self.assertEqual(rows["buyvm-ny"]["level"], "stock") + self.assertEqual(rows["buyvm-miami"]["level"], "stock") + self.assertEqual(rows["buyvm-switzerland"]["level"], "stock") + self.assertEqual(rows["greencloud-store"]["level"], "stock") + self.assertEqual(rows["hostdare-lax-cn2"]["level"], "stock") + self.assertEqual(rows["hostdare-lax-cn2-amd"]["level"], "stock") + self.assertEqual(rows["hostdare-lax-cn2-hdd"]["level"], "stock") + self.assertEqual(rows["bwh-lax-cn2"]["level"], "stock") + self.assertEqual(rows["zgovps-lax-special-52"]["level"], "stock") + self.assertEqual(rows["zgovps-hkg-special-52"]["level"], "stock") + self.assertNotIn("dmit-x", rows) + self.assertNotIn("racknerd-lax", rows) + + def test_cn2_selection_keeps_official_cn2_and_dmit_social_sources(self): + sources = {item["id"] for item in select_sources(cn2_only=True)} + self.assertEqual( + sources, + { + "dmit-x", + "hostdare-lax-cn2", + "hostdare-lax-cn2-amd", + "hostdare-lax-cn2-hdd", + "bwh-lax-cn2", + "zgovps-lax-special-52", + "dmit-reddit", + "vps-discovery-reddit-cn2-vps", + "vps-discovery-x-cn2-vps", + }, + ) + + @patch("vps_stock.subprocess.run") + def test_twitter_check_reports_community_restock_lead(self, run): + run.return_value.returncode = 0 + run.return_value.stderr = "" + run.return_value.stdout = json.dumps( + { + "data": [ + { + "id": "123", + "text": "DMIT 洛杉矶补货了,快去看看。", + "author": {"screenName": "vps_watcher"}, + "createdAtISO": "2026-07-12T00:00:00+00:00", + }, + { + "id": "456", + "text": "DMIT 补货后已经被抢空。", + "author": {"screenName": "vps_watcher"}, + "createdAtISO": "2026-07-12T00:00:00+00:00", + }, + ] + } + ) + provider = { + "id": "dmit-x", + "provider": "DMIT", + "region": "US", + "priority": "cn2", + "network": "community X leads", + "url": "https://x.com/search?q=DMIT", + "twitter_query": "DMIT 补货", + "twitter_keywords": ["dmit"], + "twitter_official": False, + } + result = check_twitter(provider, since="2026-07-01") + self.assertEqual(result["status"], "lead") + self.assertEqual(result["confidence"], "low") + self.assertEqual([post["id"] for post in result["posts"]], ["x:123"]) + + @patch("vps_stock.subprocess.run") + def test_twitter_default_search_window_is_three_days(self, run): + run.return_value.returncode = 0 + run.return_value.stderr = "" + run.return_value.stdout = json.dumps({"data": []}) + provider = { + "id": "dmit-x", + "provider": "DMIT", + "region": "US", + "priority": "cn2", + "network": "community X leads", + "url": "https://x.com/search?q=DMIT", + "twitter_query": "DMIT", + "twitter_keywords": ["dmit"], + } + check_twitter(provider) + command = run.call_args.args[0] + since = command[command.index("--since") + 1] + self.assertEqual(since, (date.today() - timedelta(days=3)).isoformat()) + + @patch("vps_stock.subprocess.run") + def test_reddit_check_reports_recent_restock_lead(self, run): + run.return_value.returncode = 0 + run.return_value.stderr = "" + run.return_value.stdout = json.dumps( + [ + { + "id": "abc", + "title": "RackNerd VPS restock is live", + "selftext": "Limited sale inventory is available.", + "author": "vps_user", + "created_utc": 1783860000, + "url": "https://www.reddit.com/r/example/comments/abc", + }, + { + "id": "def", + "title": "RackNerd stock sold out", + "selftext": "", + "author": "vps_user", + "created_utc": 1783860000, + "url": "https://www.reddit.com/r/example/comments/def", + }, + ] + ) + provider = { + "id": "racknerd-reddit", + "provider": "RackNerd", + "region": "US", + "priority": "value", + "network": "community Reddit leads", + "url": "https://www.reddit.com/search/?q=RackNerd", + "reddit_query": "RackNerd", + "reddit_keywords": ["racknerd"], + } + result = check_reddit(provider) + self.assertEqual(result["status"], "lead") + self.assertEqual([post["id"] for post in result["posts"]], ["reddit:abc"]) + + @patch("vps_stock.subprocess.run") + def test_reddit_cli_env_restores_user_command_paths(self, run): + run.return_value.returncode = 0 + run.return_value.stderr = "" + run.return_value.stdout = "[]" + provider = { + "id": "racknerd-reddit", + "provider": "RackNerd", + "region": "US", + "priority": "value", + "network": "community Reddit leads", + "url": "https://www.reddit.com/search/?q=RackNerd", + "reddit_query": "RackNerd", + "reddit_keywords": ["racknerd"], + } + + with patch.dict(os.environ, {"PATH": "/usr/bin:/bin"}): + check_reddit(provider) + + command_path = run.call_args.kwargs["env"]["PATH"].split(os.pathsep) + self.assertIn(str(Path.home() / ".npm-global" / "bin"), command_path) + self.assertIn("/usr/local/bin", command_path) + self.assertIn("/opt/homebrew/bin", command_path) + self.assertIn("/usr/bin", command_path) + + @patch("vps_stock.subprocess.run") + def test_reddit_excludes_posts_older_than_three_days(self, run): + run.return_value.returncode = 0 + run.return_value.stderr = "" + run.return_value.stdout = json.dumps( + [ + { + "id": "recent", + "title": "RackNerd VPS restock is live", + "selftext": "Limited sale inventory is available.", + "author": "vps_user", + "created_utc": int(time.time()) - 2 * 24 * 60 * 60, + "url": "https://www.reddit.com/r/example/comments/recent", + }, + { + "id": "old", + "title": "RackNerd VPS restock is live", + "selftext": "Limited sale inventory is available.", + "author": "vps_user", + "created_utc": int(time.time()) - 4 * 24 * 60 * 60, + "url": "https://www.reddit.com/r/example/comments/old", + }, + ] + ) + provider = { + "id": "racknerd-reddit", + "provider": "RackNerd", + "region": "US", + "priority": "value", + "network": "community Reddit leads", + "url": "https://www.reddit.com/search/?q=RackNerd", + "reddit_query": "RackNerd", + "reddit_keywords": ["racknerd"], + } + result = check_reddit(provider) + self.assertEqual([post["id"] for post in result["posts"]], ["reddit:recent"]) + + @patch("vps_stock.fetch") + @patch("vps_stock.subprocess.run") + def test_reddit_falls_back_to_public_json_after_opencli_html_error(self, run, fetch): + run.return_value.returncode = 1 + run.return_value.stderr = "SyntaxError: Unexpected token '<'" + run.return_value.stdout = "blocked" + fetch.return_value = ( + 200, + json.dumps( + { + "data": { + "children": [ + { + "data": { + "id": "fallback", + "title": "RackNerd VPS restock is live", + "selftext": "Limited sale inventory is available.", + "author": "vps_user", + "created_utc": time.time() - 3600, + "url": "https://www.reddit.com/r/example/comments/fallback", + } + } + ] + } + } + ), + ) + provider = { + "id": "racknerd-reddit", + "provider": "RackNerd", + "region": "US", + "priority": "value", + "network": "community Reddit leads", + "url": "https://www.reddit.com/search/?q=RackNerd", + "reddit_query": "RackNerd", + "reddit_keywords": ["racknerd"], + } + + result = check_reddit(provider) + + self.assertEqual(result["status"], "lead") + self.assertEqual([post["id"] for post in result["posts"]], ["reddit:fallback"]) + self.assertIn("search.json", fetch.call_args.args[0]) + + @patch("vps_stock._append_memory_line") + @patch("vps_stock.check_provider") + @patch("vps_stock.select_sources") + def test_main_stdout_keeps_transition_summary_parseable(self, select_sources_mock, check_provider_mock, append_memory): + provider = { + "id": "dmit-reddit", + "provider": "DMIT", + "region": "US", + "priority": "cn2", + "network": "community Reddit leads", + "url": "https://www.reddit.com/search/?q=DMIT", + "kind": "reddit_search", + } + result = { + "id": "dmit-reddit", + "provider": "DMIT", + "region": "US", + "status": "lead", + "confidence": "low", + "posts": [{"id": "reddit:new"}], + } + select_sources_mock.return_value = [provider] + check_provider_mock.return_value = result + + with tempfile.TemporaryDirectory() as directory: + state_file = Path(directory) / "state.json" + stdout = io.StringIO() + with patch("sys.stdout", stdout): + exit_code = main(["--state-file", str(state_file)]) + + self.assertEqual(exit_code, 0) + output = json.loads(stdout.getvalue()) + self.assertIn("transitions", output) + self.assertEqual(output["transitions"][0]["event"], "social_lead") + self.assertNotIn("items", output) + + def test_new_twitter_post_is_a_transition(self): + result = { + "id": "dmit-x", + "status": "lead", + "posts": [{"id": "123"}], + } + old = { + "dmit-x": {"status": "lead", "posts": [{"id": "100"}]} + } + transitions = find_transitions([result], old) + self.assertEqual(len(transitions), 1) + self.assertEqual(transitions[0]["event"], "social_lead") + + def test_discovery_filter_keeps_recent_concrete_unlisted_vps_lead(self): + now = 2_000_000_000 + posts = [ + { + "id": "new-provider", + "title": "NovaHost VPS restock: Los Angeles CN2 GIA $5/mo", + "selftext": "2GB RAM, 40GB NVMe, 2TB traffic, currently in stock.", + "author": "vps_user", + "created_utc": now - 24 * 60 * 60, + "url": "https://www.reddit.com/r/example/comments/new-provider", + } + ] + + leads = filter_discovery_posts(posts, now=now) + + self.assertEqual(len(leads), 1) + self.assertEqual(leads[0]["id"], "reddit:new-provider") + self.assertEqual(leads[0]["label"], "待官网复核线索") + self.assertEqual(leads[0]["provider"], "NovaHost") + self.assertEqual(leads[0]["prices"][0]["monthly_equivalent"], 5.0) + self.assertIn("CN2", leads[0]["evidence"]["routes"]) + + def test_discovery_filter_rejects_noise_old_sold_out_and_over_budget_posts(self): + now = 2_000_000_000 + posts = [ + { + "id": "referral", + "title": "Cheap VPS referral code", + "selftext": "Use my invite link for a discount.", + "author": "affiliate", + "created_utc": now - 60 * 60, + "url": "https://www.reddit.com/r/example/comments/referral", + }, + { + "id": "old", + "title": "Old VPS restock $5/mo", + "selftext": "CN2 and in stock.", + "author": "vps_user", + "created_utc": now - 4 * 24 * 60 * 60, + "url": "https://www.reddit.com/r/example/comments/old", + }, + { + "id": "sold-out", + "title": "VPS restock sold out", + "selftext": "CN2, $5/mo, no stock left.", + "author": "vps_user", + "created_utc": now - 60 * 60, + "url": "https://www.reddit.com/r/example/comments/sold-out", + }, + { + "id": "expensive", + "title": "Premium VPS sale $25/mo", + "selftext": "Los Angeles, 8GB RAM, available now.", + "author": "vps_user", + "created_utc": now - 60 * 60, + "url": "https://www.reddit.com/r/example/comments/expensive", + }, + ] + + self.assertEqual(filter_discovery_posts(posts, now=now), []) + + @patch("vps_stock.subprocess.run") + def test_twitter_discovery_uses_the_shared_concrete_evidence_filter(self, run): + run.return_value.returncode = 0 + run.return_value.stderr = "" + run.return_value.stdout = json.dumps( + { + "data": [ + { + "id": "x-new-provider", + "text": "NovaHost VPS restock in Los Angeles CN2 GIA $5/mo, 2GB RAM, available now", + "author": {"screenName": "vps_watcher"}, + "createdAtISO": (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat(), + } + ] + } + ) + provider = { + "id": "vps-discovery-x-vps-restock", + "provider": "VPS discovery", + "region": "global", + "priority": "value", + "network": "community discovery leads", + "url": "https://x.com/search?q=VPS+restock", + "twitter_query": "VPS restock", + } + + result = check_twitter_discovery(provider) + + self.assertEqual(result["status"], "lead") + self.assertEqual(result["posts"][0]["id"], "x:x-new-provider") + self.assertEqual(result["posts"][0]["provider"], "NovaHost") + + def test_prune_state_posts_removes_social_posts_older_than_three_days(self): + now = 2_000_000_000 + state = { + "dmit-reddit": { + "status": "lead", + "posts": [ + {"id": "reddit:recent", "created_at": now - 2 * 24 * 60 * 60}, + {"id": "reddit:old", "created_at": now - 4 * 24 * 60 * 60}, + ], + } + } + + cleaned = prune_state_posts(state, now=now) + + self.assertEqual([post["id"] for post in cleaned["dmit-reddit"]["posts"]], ["reddit:recent"]) + self.assertEqual([post["id"] for post in state["dmit-reddit"]["posts"]], ["reddit:recent", "reddit:old"]) + + def test_prune_state_posts_revalidates_discovery_filter_rules(self): + now = 2_000_000_000 + state = { + "vps-discovery-x-vps-sale": { + "status": "lead", + "posts": [ + { + "id": "x:bad-ad", + "created_at": now - 60 * 60, + "title": "Forex copy trading sale", + "text": "$499 for 1 year, no VPS required.", + "url": "https://x.com/example/status/bad-ad", + } + ], + } + } + + cleaned = prune_state_posts(state, now=now) + + self.assertEqual(cleaned["vps-discovery-x-vps-sale"]["posts"], []) + + def test_dedupe_discovery_results_keeps_one_copy_of_a_cross_query_post(self): + results = [ + {"id": "vps-discovery-reddit-vps-sale", "status": "lead", "posts": [{"id": "reddit:same"}]}, + {"id": "vps-discovery-reddit-cheap-vps", "status": "lead", "posts": [{"id": "reddit:same"}]}, + ] + + cleaned = dedupe_discovery_results(results) + + self.assertEqual([post["id"] for post in cleaned[0]["posts"]], ["reddit:same"]) + self.assertEqual(cleaned[1]["posts"], []) + self.assertEqual(cleaned[1]["status"], "no_recent_signal") + + def test_compact_memory_keeps_only_recent_nonempty_lines(self): + content = "\n".join("run-%02d" % index for index in range(25)) + "\n" + + compacted = compact_memory(content, keep_lines=20) + + self.assertNotIn("run-04", compacted) + self.assertIn("run-05", compacted) + self.assertIn("run-24", compacted) + self.assertEqual(len(compacted.strip().splitlines()), 20) + + def test_discovery_filter_rejects_off_topic_ads_and_affiliate_articles(self): + now = 2_000_000_000 + posts = [ + { + "id": "forex-ad", + "title": "Best US30 Forex copy trading flash sale", + "selftext": "$499 for 1 year, no VPS or computer required.", + "author": "trader", + "created_utc": now - 60 * 60, + "url": "https://www.reddit.com/r/example/comments/forex-ad", + }, + { + "id": "affiliate-vps", + "title": "CN2 GIA VPS recommendations", + "selftext": "搬瓦工和 DMIT 最近补货,闭眼冲。传送门: https://t.co/example", + "author": "affiliate", + "created_utc": now - 60 * 60, + "url": "https://www.reddit.com/r/example/comments/affiliate-vps", + }, + { + "id": "serverless", + "title": "Cheap VPS infrastructure is a serverless trap", + "selftext": "Serverless costs $1.5/mo at small scale; this is a technical essay, not a VPS offer.", + "author": "engineer", + "created_utc": now - 60 * 60, + "url": "https://www.reddit.com/r/example/comments/serverless", + }, + ] + + self.assertEqual(filter_discovery_posts(posts, now=now), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/vps_stock.py b/tools/vps_stock.py new file mode 100644 index 0000000..6583fc7 --- /dev/null +++ b/tools/vps_stock.py @@ -0,0 +1,1344 @@ +#!/usr/bin/env python3 +"""Read-only stock monitor for US VPS offers. + +The monitor never logs in or adds an item to a cart. It reports ``blocked`` +when a provider's anti-bot page prevents an inventory check. +""" + +from __future__ import annotations + +import argparse +from html import unescape +import json +import os +import re +import subprocess +import sys +import time +from datetime import date, datetime, timedelta +from html.parser import HTMLParser +from itertools import chain +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Tuple +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import Request, urlopen + + +def _external_command_env() -> Dict[str, str]: + env = os.environ.copy() + command_paths = [ + str(Path.home() / ".npm-global" / "bin"), + str(Path.home() / ".local" / "bin"), + "/usr/local/bin", + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + ] + command_paths.extend(env.get("PATH", "").split(os.pathsep)) + env["PATH"] = os.pathsep.join(dict.fromkeys(path for path in command_paths if path)) + return env + + +PROVIDERS: List[Dict[str, Any]] = [ + { + "id": "buyvm-lv", + "provider": "BuyVM", + "region": "Las Vegas, US", + "kind": "frantech_html", + "url": "https://my.frantech.ca/cart.php?gid=37", + "priority": "value", + "network": "standard US West", + "focus": True, + }, + { + "id": "buyvm-ny", + "provider": "BuyVM", + "region": "New York, US", + "kind": "frantech_html", + "url": "https://my.frantech.ca/cart.php?gid=38", + "priority": "value", + "network": "standard US East", + "focus": True, + }, + { + "id": "buyvm-miami", + "provider": "BuyVM", + "region": "Miami, US", + "kind": "frantech_html", + "url": "https://my.frantech.ca/cart.php?gid=48", + "priority": "value", + "network": "standard US East", + "focus": True, + }, + { + "id": "buyvm-switzerland", + "provider": "BuyVM", + "region": "Switzerland", + "kind": "frantech_html", + "url": "https://my.frantech.ca/cart.php?gid=39", + "priority": "value", + "network": "standard Europe", + "focus": True, + }, + { + "id": "greencloud-store", + "provider": "GreenCloud", + "region": "US / Canada / Europe / Asia", + "kind": "counted_html", + "url": "https://greencloudvps.com/billing/store/", + "fetch_url": "https://r.jina.ai/https://greencloudvps.com/billing/store/", + "plan_prefixes": ["BudgetKVM"], + "priority": "value", + "network": "multi-location budget KVM", + "focus": True, + }, + { + "id": "dmit-x", + "provider": "DMIT", + "region": "US / Japan / Hong Kong", + "kind": "twitter_search", + "url": "https://x.com/search?q=DMIT", + "twitter_query": "DMIT", + "twitter_keywords": ["dmit"], + "twitter_official": False, + "priority": "cn2", + "network": "community X restock/promotion leads; verify before purchase", + "focus": True, + }, + { + "id": "hostdare-lax-cn2", + "provider": "HostDare", + "region": "Los Angeles, US", + "kind": "counted_html", + "url": "https://bill.hostdare.com/store/premium-china-optimized-nvme-kvm", + "fetch_url": "https://r.jina.ai/https://bill.hostdare.com/store/premium-china-optimized-nvme-kvm", + "plan_prefixes": ["CSSD"], + "priority": "cn2", + "network": "CN2 GIA / CU / CM optimized", + "focus": True, + }, + { + "id": "hostdare-lax-cn2-amd", + "provider": "HostDare", + "region": "Los Angeles, US", + "kind": "counted_html", + "url": "https://bill.hostdare.com/store/premium-china-optimized-amd-kvm-vps-usa", + "fetch_url": "https://r.jina.ai/https://bill.hostdare.com/store/premium-china-optimized-amd-kvm-vps-usa", + "plan_prefixes": ["CAMD"], + "priority": "cn2", + "network": "CN2 GIA / CU / CM optimized", + "focus": True, + }, + { + "id": "hostdare-lax-cn2-hdd", + "provider": "HostDare", + "region": "Los Angeles, US", + "kind": "counted_html", + "url": "https://bill.hostdare.com/store/premium-china-optimized-kvm-vps", + "fetch_url": "https://r.jina.ai/https://bill.hostdare.com/store/premium-china-optimized-kvm-vps", + "plan_prefixes": ["CKVM"], + "priority": "cn2", + "network": "CN2 GIA / CU / CM optimized", + "focus": True, + }, + { + "id": "bwh-lax-cn2", + "provider": "BWH", + "region": "Los Angeles, US", + "kind": "bwh_json", + "url": "https://bandwagonhost.com/vps-hosting.php", + "fetch_url": "https://bandwagonhost.com/order/get-data", + "tiers": ["ecommerce", "ecommerce-sla-elevated"], + "datacenters": ["USCA_5", "USCA_6", "USCA_9"], + "priority": "cn2", + "network": "CN2 GIA / CTGNet / CMIN2 / CU premium", + "focus": True, + }, + { + "id": "zgovps-lax-special-52", + "provider": "ZgoVPS", + "region": "Los Angeles, US", + "kind": "whmcs_offer_html", + "url": "https://clients.zgovps.com/index.php?/cart/special-offer/", + "plan_name": "Los Angeles AMD Optimised VPS - Specials - Starter", + "target_price": 52.0, + "target_period": "year", + "priority": "cn2", + "network": "GIA / 9929 / CMIN2, China Premium Optimised", + "focus": True, + }, + { + "id": "zgovps-hkg-special-52", + "provider": "ZgoVPS", + "region": "Hong Kong", + "kind": "whmcs_offer_html", + "url": "https://clients.zgovps.com/index.php?/cart/special-offer/", + "plan_name": "HongKong AMD VPS - Specials - Starter", + "target_price": 52.0, + "target_period": "year", + "priority": "value", + "network": "BGP, China Optimised", + "focus": True, + }, +] + + +_SOCIAL_BRANDS: List[Dict[str, Any]] = [ + { + "id": "dmit", + "provider": "DMIT", + "region": "US / Japan / Hong Kong", + "priority": "cn2", + "network": "community Reddit sale and restock leads; verify before purchase", + "query": "DMIT", + "keywords": ["dmit"], + "sources": ["reddit"], + "focus": True, + }, + { + "id": "racknerd", + "provider": "RackNerd", + "region": "Los Angeles, US", + "priority": "value", + "network": "community social sale and restock leads; verify before purchase", + "query": "RackNerd", + "keywords": ["racknerd", "rack nerd"], + "sources": ["x", "reddit"], + "focus": True, + }, + { + "id": "cloudcone", + "provider": "CloudCone", + "region": "Los Angeles, US", + "priority": "value", + "network": "community social sale and restock leads; verify before purchase", + "query": "CloudCone", + "keywords": ["cloudcone", "cloud cone"], + "sources": ["x", "reddit"], + "focus": True, + }, +] + +SOCIAL_PROVIDERS: List[Dict[str, Any]] = [] +for _brand in _SOCIAL_BRANDS: + for _source in _brand["sources"]: + SOCIAL_PROVIDERS.append( + { + "id": "%s-%s" % (_brand["id"], _source), + "provider": _brand["provider"], + "region": _brand["region"], + "kind": "%s_search" % ("twitter" if _source == "x" else "reddit"), + "url": "https://x.com/search?q=%s" % _brand["query"] if _source == "x" else "https://www.reddit.com/search/?q=%s" % _brand["query"], + "%s_query" % ("twitter" if _source == "x" else "reddit"): _brand["query"], + "%s_keywords" % ("twitter" if _source == "x" else "reddit"): _brand["keywords"], + "priority": _brand["priority"], + "network": _brand["network"], + "focus": _brand["focus"], + } + ) + + +_DISCOVERY_QUERIES = ( + ("vps-restock", "VPS restock", "value"), + ("vps-sale", "VPS sale", "value"), + ("cn2-vps", "CN2 VPS", "cn2"), + ("cheap-vps", "cheap VPS", "value"), +) +DISCOVERY_PROVIDERS: List[Dict[str, Any]] = [] +for _query_id, _query, _priority in _DISCOVERY_QUERIES: + for _source in ("reddit", "x"): + DISCOVERY_PROVIDERS.append( + { + "id": "vps-discovery-%s-%s" % (_source, _query_id), + "provider": "VPS discovery", + "region": "global", + "kind": "%s_discovery" % ("reddit" if _source == "reddit" else "twitter"), + "url": ( + "https://www.reddit.com/search/?%s" % urlencode({"q": _query}) + if _source == "reddit" + else "https://x.com/search?%s" % urlencode({"q": _query}) + ), + "%s_query" % ("reddit" if _source == "reddit" else "twitter"): _query, + "priority": _priority, + "network": "community discovery leads; verify before purchase", + "focus": True, + } + ) + + +def fetch(url: str, timeout: int = 20) -> Tuple[int, str]: + request = Request(url, headers={"User-Agent": "network-node-vps-stock/1.0"}) + try: + with urlopen(request, timeout=timeout) as response: + return int(response.status), response.read().decode("utf-8", "replace") + except HTTPError as exc: + body = exc.read().decode("utf-8", "replace") + return int(exc.code), body + except (URLError, TimeoutError, OSError) as exc: + raise RuntimeError(str(exc)) from exc + + +def _base_result(provider: Dict[str, Any]) -> Dict[str, Any]: + return { + "id": provider["id"], + "provider": provider["provider"], + "region": provider["region"], + "priority": provider["priority"], + "network": provider["network"], + "source": provider["url"], + "checked_via": provider.get("fetch_url", provider["url"]), + "status": "unknown", + "confidence": "none", + "plans": [], + "reason": "", + "checked_at": int(time.time()), + } + + +_COUNTED_PLAN = re.compile( + r"(?P[A-Za-z][A-Za-z0-9._-]{2,64})\s+(?P\d+)\s+" + r"(?P