├── .gitignore ├── .python-version ├── Dockerfile ├── LICENSE ├── README.md ├── TESTS.md ├── docker-compose.yml ├── hah.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | live_data_sb.json 2 | .vscode/settings.json 3 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.10.9/envs/hetzner-auction-hunter -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10-slim-buster 2 | 3 | LABEL desc="hetzner-auction-hunter" 4 | LABEL website="https://github.com/danielskowronski/hetzner-auction-hunter" 5 | 6 | COPY requirements.txt /requirements.txt 7 | RUN python3 -m pip install --no-cache-dir -r /requirements.txt 8 | 9 | COPY hah.py /hah.py 10 | 11 | ENTRYPOINT [ "./hah.py" ] 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023 Daniel Skowroński 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions 4 | are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 8 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in 9 | the documentation and/or other materials provided with the distribution. 10 | 11 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from 12 | this software without specific prior written permission. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 15 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 16 | COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 17 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 18 | HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 19 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 20 | 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hetzner-auction-hunter 2 | 3 | **unofficially** checks for newest servers on Hetzner server auction (server-bidding) and pushes to one of dozen providers supported by [Notifiers library](https://pypi.org/project/notifiers/), including Pushover, SimplePush, Slack, Gmail, Email (SMTP), Telegram, Gitter, Pushbullet, Join, Zulip, Twilio, Pagerduty, Mailgun, PopcornNotify, StatusPage.io, iCloud, VictorOps (Splunk) 4 | 5 | [Hetzner Auction website](https://www.hetzner.com/sb) 6 | 7 | [![Docker Pulls](https://img.shields.io/docker/pulls/danielskowronski/hetzner-auction-hunter)](https://hub.docker.com/repository/docker/danielskowronski/hetzner-auction-hunter) 8 | 9 | The price displayed on hetzner.com by default includes monthly rate for IPv4 address, therefore it's slightly higher than one reported by this tool. You can disable it by toggling *Enable IPv6 only* switch available on top of the list (on hetzner.com). 10 | 11 | ## requirements 12 | 13 | * python3 14 | * properly configured [Notifiers provider](https://notifiers.readthedocs.io/en/latest/providers/index.html) 15 | * some writable file to store processed offers (defaults to `/tmp/hah.txt`) 16 | 17 | ## usage 18 | 19 | ``` 20 | usage: hah.py [-h] [--data-url DATA_URL] [--provider PROVIDER] [--tax TAX] [--price PRICE] [--disk-count DISK_COUNT] [--disk-size DISK_SIZE] [--disk-min-size DISK_MIN_SIZE] [--disk-quick] [--hw-raid] [--red-psu] [--gpu] [--ipv4] [--inic] 21 | [--cpu-count CPU_COUNT] [--ram RAM] [--ecc] [--dc DC] [-f [F]] [--exclude-tax] [--test-mode] [--debug] [--send-payload] 22 | 23 | hah.py -- checks for newest servers on Hetzner server auction (server-bidding) and pushes to one of dozen providers supported by Notifiers library 24 | 25 | options: 26 | -h, --help show this help message and exit 27 | --data-url DATA_URL URL to live_data_sb.json 28 | --provider PROVIDER Notifiers provider name - see https://notifiers.readthedocs.io/en/latest/providers/index.html 29 | --tax TAX tax rate (VAT) in percents, defaults to 19 (Germany) 30 | --price PRICE max price (€) 31 | --disk-count DISK_COUNT 32 | min disk count 33 | --disk-size DISK_SIZE 34 | min disk capacity (GB) 35 | --disk-min-size DISK_MIN_SIZE 36 | min disk capacity per disk (GB) 37 | --disk-quick require SSD/NVMe 38 | --hw-raid require Hardware RAID 39 | --red-psu require Redundant PSU 40 | --gpu require discrete GPU 41 | --ipv4 require IPv4 42 | --inic require Intel NIC 43 | --cpu-count CPU_COUNT 44 | min CPU count 45 | --ram RAM min RAM (GB) 46 | --ecc require ECC memory 47 | --dc DC datacenter (FSN1-DC15) or location (FSN) 48 | -f [F] state file 49 | --exclude-tax exclude tax from output price 50 | --test-mode do not send actual messages and ignore state file 51 | --debug debug mode 52 | --send-payload send server data as JSON payload 53 | ``` 54 | 55 | Since there are way too many combinations of providers and their parameters to support as CLI args, you must pass `--provider PROVIDER` as defined on [Notifiers providers list](https://notifiers.readthedocs.io/en/latest/providers/index.html) and export all relevant ENV variables as per [Notifiers usage guide](https://notifiers.readthedocs.io/en/latest/usage.html?highlight=NOTIFIERS_#environment-variables). 56 | 57 | ### directly on machine 58 | 59 | You'll probably want to put it in crontab and make sure that state file is on permanent storage (`/tmp/` may or may not survive reboot). 60 | 61 | #### prepare local env 62 | 63 | ```bash 64 | pyenv activate 65 | python3 -m pip install -r requirements.txt 66 | ``` 67 | 68 | #### export ENV variables 69 | 70 | Those are just examples. Check out https://notifiers.readthedocs.io/en/latest/providers/index.html 71 | 72 | For **Pushover**: [register](https://pushover.net/signup), get your User Key from [main page](https://pushover.net) and then [register app](https://pushover.net/apps/build) for which you'll get app token. Then export as follows: 73 | 74 | ```bash 75 | export NOTIFIERS_PUSHOVER_USER=... 76 | export NOTIFIERS_PUSHOVER_TOKEN=... 77 | export HAH_PROVIDER=pushover 78 | ``` 79 | 80 | For **Gmail**: register, [enable 2FA](https://myaccount.google.com/signinoptions/two-step-verification/enroll-welcome) (required bacuse Google enforces app passwords for non-OAuth clients and you can't have app password without 2FA), [create app password](https://myaccount.google.com/apppasswords) selecting Mail as service. Then export as follows: 81 | 82 | ```bash 83 | export NOTIFIERS_GMAIL_USERNAME="...@gmail.com" # username 84 | export NOTIFIERS_GMAIL_PASSWORD="..." # app password 85 | export NOTIFIERS_GMAIL_FROM="$NOTIFIERS_GMAIL_USERNAME <$NOTIFIERS_GMAIL_USERNAME>" # optional From field, recommended to use real account email 86 | export NOTIFIERS_GMAIL_TO="..." # recipient 87 | export HAH_PROVIDER=gmail 88 | ``` 89 | 90 | For **Telegram** (discouraged, but provided for legacy compatibility): talk to [@BotFather](https://t.me/BotFather) to create new bot and obtain token, talk to [@myidbot](https://t.me/myidbot) to get your personal chat ID. Then export as follows: 91 | 92 | ```bash 93 | export NOTIFIERS_TELEGRAM_TOKEN="...:..." 94 | export NOTIFIERS_TELEGRAM_CHAT_ID="..." 95 | export HAH_PROVIDER=telegram 96 | ``` 97 | 98 | #### run 99 | 100 | To get servers cheaper than 38 EUR with more than 24GB of RAM, disks at least 3TB: 101 | 102 | ```bash 103 | ./hah.py --provider $HAH_PROVIDER --price 38 --disk-size 3000 --ram 24 104 | ``` 105 | 106 | ### docker 107 | 108 | Example run for Pushover: 109 | 110 | ```bash 111 | docker build . -t hetzner-auction-hunter:latest --no-cache=true 112 | 113 | docker run --rm \ 114 | -v /tmp/hah:/tmp/ \ 115 | -e NOTIFIERS_PUSHOVER_USER=$NOTIFIERS_PUSHOVER_USER \ 116 | -e NOTIFIERS_PUSHOVER_TOKEN=$NOTIFIERS_PUSHOVER_TOKEN \ 117 | hetzner-auction-hunter:latest --provider $HAH_PROVIDER --price 40 --disk-size 3000 --ram 24 118 | ``` 119 | 120 | For more universal executions, you may consider using `docker run --env-file`. 121 | 122 | ## debugging 123 | 124 | ```bash 125 | curl https://www.hetzner.com/_resources/app/jsondata/live_data_sb.json | jq > live_data_sb.json 126 | ./hah.py --data-url "file:///${PWD}/live_data_sb.json" --debug ... 127 | ``` 128 | 129 | ## docker image for hub.docker.com 130 | 131 | ```bash 132 | hadolint Dockerfile 133 | export TAG=danielskowronski/hetzner-auction-hunter:v... 134 | docker build . -t $TAG --no-cache=true 135 | docker push $TAG 136 | ``` 137 | -------------------------------------------------------------------------------- /TESTS.md: -------------------------------------------------------------------------------- 1 | # tests 2 | 3 | ## manual "test suite" 4 | 5 | ```bash 6 | # https://www.hetzner.com/sb?country=ot&price_to=32 7 | ./hah.py --exclude-tax --test-mode --send-payload --price 32 8 | 9 | # https://www.hetzner.com/sb?country=ot&drives_count_from=12 10 | ./hah.py --exclude-tax --test-mode --send-payload --disk-count 12 11 | 12 | # https://www.hetzner.com/sb?country=ot&drives_size_from=16000&drives_size_to=16000 13 | ./hah.py --exclude-tax --test-mode --send-payload --disk-size 16000 14 | 15 | # https://www.hetzner.com/sb?country=ot&price_to=32&driveType=sata%2Bnvme 16 | ./hah.py --exclude-tax --test-mode --send-payload --disk-quick --price 32 17 | 18 | # https://www.hetzner.com/sb?country=ot&price_to=38&additional=HWR 19 | ./hah.py --exclude-tax --test-mode --send-payload --hw-raid --price 38 20 | 21 | # https://www.hetzner.com/sb?country=ot&additional=RPS 22 | ./hah.py --exclude-tax --test-mode --send-payload --red-psu 23 | 24 | # https://www.hetzner.com/sb?country=ot&additional=GPU 25 | ./hah.py --exclude-tax --test-mode --send-payload --gpu 26 | 27 | # https://www.hetzner.com/sb?country=ot&price_to=32&additional=iNIC 28 | ./hah.py --exclude-tax --test-mode --send-payload --inic --price 32 29 | 30 | # https://www.hetzner.com/sb?country=ot&price_to=70&ram_from=250 31 | ./hah.py --exclude-tax --test-mode --send-payload --ram 250 --price 70 32 | 33 | # https://www.hetzner.com/sb?country=ot&price_to=38&ecc=true 34 | ./hah.py --exclude-tax --test-mode --send-payload --ecc --price 38 35 | 36 | # https://www.hetzner.com/sb?country=ot&price_to=38&location=FSN1-DC1 37 | ./hah.py --exclude-tax --test-mode --send-payload --dc FSN1-DC1 --price 38 38 | 39 | # https://www.hetzner.com/sb?country=ot&price_to=38&location=FSN 40 | ./hah.py --exclude-tax --test-mode --send-payload --dc FSN --price 38 41 | ``` 42 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | hetzner-auction-hunter: 3 | container_name: hetzner-auction-hunter 4 | image: hetzner-auction-hunter:latest 5 | volumes: 6 | - ./tmp/hah:/tmp/ 7 | environment: 8 | command: --price 38 --disk-size 3000 --ram 24 9 | restart: unless-stopped 10 | -------------------------------------------------------------------------------- /hah.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | import requests 4 | import requests_file 5 | import json 6 | import notifiers 7 | import argparse 8 | import html2text 9 | import base64 10 | 11 | 12 | class Server: 13 | def get_disk_description(self): 14 | disk_descriptors = [ 15 | desc for desc in self.server_raw["description"] if " GB" in desc or " TB" in desc] 16 | if len(disk_descriptors) == 0: 17 | disk_description = "%dx %dGB" % ( 18 | self.server_raw["hdd_count"], self.server_raw["hdd_size"]) 19 | else: 20 | disk_description = ", ".join(disk_descriptors) 21 | return disk_description 22 | 23 | def has_quick_disk(self): 24 | nvme_count = len(self.server_raw.get( 25 | "serverDiskData", []).get("nvme", [])) 26 | sata_count = len(self.server_raw.get( 27 | "serverDiskData", []).get("sata", [])) 28 | return nvme_count+sata_count > 0 29 | 30 | def get_smallest_disk_size(self): 31 | general_drives = self.disk_map.get("general", []) 32 | if len(general_drives) > 0: 33 | smallest_drive = min(general_drives) 34 | else: 35 | smallest_drive = -1 36 | return smallest_drive 37 | 38 | def __init__(self, server_raw, tax_percent=0): 39 | self.server_raw = server_raw 40 | self.tax_percent = tax_percent 41 | 42 | self.id = server_raw.get("id", 0) 43 | self.datacenter = server_raw.get("datacenter", "UNKNOWN_DATACENTER") 44 | self.price = server_raw.get("price", 0.0)*(100+self.tax_percent)/100 45 | 46 | self.ram_size = server_raw.get("ram_size", 0) 47 | self.ram_description = server_raw.get("ram", ["UNKNOWN_RAM"])[0] 48 | 49 | self.cpu_count = server_raw.get("cpu_count", 0) 50 | self.cpu_description = server_raw.get("cpu", "UNKNOWN_CPU") 51 | 52 | self.disk_count = server_raw.get("hdd_count", 0) 53 | self.disk_size_total = server_raw.get("hdd_size", 0) 54 | self.disk_map = server_raw.get( 55 | "serverDiskData", {"nvme": [], "sata": [], "hdd": [], "general": []}) 56 | self.disk_quick = self.has_quick_disk() 57 | self.disk_description = self.get_disk_description() 58 | 59 | self.sp_hw_raid = False 60 | self.sp_red_psu = False 61 | self.sp_ecc = False 62 | self.sp_gpu = False 63 | self.sp_ipv4 = False 64 | self.sp_inic = False 65 | for special in server_raw.get("specials", []): 66 | if special == 'HWR': 67 | self.sp_hw_raid = True 68 | if special == 'RPS': 69 | self.sp_red_psu = True 70 | if special == 'ECC': 71 | self.sp_ecc = True 72 | if special == 'GPU': 73 | self.sp_gpu = True 74 | if special == 'IPv4': 75 | self.sp_ipv4 = True 76 | if special == 'iNIC': 77 | self.sp_inic = True 78 | # interesting fields left: setup_price, fixed_price, next_reduce*, serverDiskData, traffic, bandwidth 79 | 80 | def get_url(self): 81 | return f"https://www.hetzner.com/sb?search={self.id}" 82 | 83 | def get_header(self): 84 | msg = f"Hetzner server #{self.id} in {self.datacenter} for {self.price}€" 85 | return msg 86 | 87 | def get_message(self, html=True, verbose=True): 88 | url = self.get_url() 89 | msg = f"Hetzner server #{self.id} in {self.datacenter} for {self.price}€:
" + \ 90 | f"{self.ram_size}GB RAM, {self.cpu_count}x {self.cpu_description}, {self.disk_description}
" + \ 91 | f"{url}
" 92 | if verbose: 93 | json_raw = json.dumps(self.server_raw) 94 | msg += f"
Details:
{json_raw}

" 95 | if not html: 96 | msg = html2text.html2text(msg) 97 | return msg 98 | 99 | 100 | def send_notification(notifier, server, send_payload): 101 | if notifier == None: 102 | print(f"DUMMY NOTIFICATION TITLE: "+server.get_header()) 103 | msg = server.get_message( 104 | html=False, verbose=send_payload).encode("utf-8") 105 | msg_base64 = base64.b64encode(msg).decode("utf-8") 106 | print(f"DUMMY NOTIFICATION BODY: {msg_base64}") 107 | else: 108 | html_html = notifier.schema.get("properties").get("html") 109 | html_pamo = notifier.schema.get("properties").get("parse_mode") 110 | html_supported = html_html or html_pamo 111 | 112 | title_subject = notifier.schema.get("properties").get("subject") 113 | title_title = notifier.schema.get("properties").get("title") 114 | 115 | msg = server.get_message(html=html_supported, verbose=send_payload) 116 | title = server.get_header() 117 | 118 | if html_html: 119 | if title_subject: 120 | notifier.notify(message=msg, html=True, subject=title) 121 | elif title_title: 122 | notifier.notify(message=msg, html=True, title=title) 123 | else: 124 | notifier.notify(message=msg, html=True) 125 | elif html_pamo: 126 | if title_subject: 127 | notifier.notify(message=msg, parse_mode="html", subject=title) 128 | elif title_title: 129 | notifier.notify(message=msg, parse_mode="html", title=title) 130 | else: 131 | notifier.notify(message=msg, parse_mode="html") 132 | else: 133 | if title_subject: 134 | notifier.notify(message=msg, subject=title) 135 | elif title_title: 136 | notifier.notify(message=msg, title=title) 137 | else: 138 | notifier.notify(message=msg) 139 | 140 | 141 | if __name__ == "__main__": 142 | parser = argparse.ArgumentParser( 143 | description='hah.py -- checks for newest servers on Hetzner server auction (server-bidding) and pushes to one of dozen providers supported by Notifiers library') 144 | parser.add_argument('--data-url', nargs=1, required=False, type=str, 145 | default=[ 146 | 'https://www.hetzner.com/_resources/app/jsondata/live_data_sb.json'], 147 | help='URL to live_data_sb.json') 148 | parser.add_argument('--provider', nargs=1, required=False, type=str, 149 | default=["dummy"], 150 | help='Notifiers provider name - see https://notifiers.readthedocs.io/en/latest/providers/index.html') 151 | parser.add_argument('--tax', nargs=1, required=False, type=int, 152 | default=[19], 153 | help='tax rate (VAT) in percents, defaults to 19 (Germany)') 154 | parser.add_argument('--price', nargs=1, required=False, type=int, 155 | help='max price (€)') 156 | parser.add_argument('--disk-count', nargs=1, required=False, type=int, 157 | default=[1], 158 | help='min disk count') 159 | parser.add_argument('--disk-size', nargs=1, required=False, type=int, 160 | help='min disk capacity (GB)') 161 | parser.add_argument('--disk-min-size', nargs=1, required=False, type=int, 162 | help='min disk capacity per disk (GB)') 163 | parser.add_argument('--disk-quick', action='store_true', 164 | help='require SSD/NVMe') 165 | parser.add_argument('--hw-raid', action='store_true', 166 | help='require Hardware RAID') 167 | parser.add_argument('--red-psu', action='store_true', 168 | help='require Redundant PSU') 169 | parser.add_argument('--gpu', action='store_true', 170 | help='require discrete GPU') 171 | parser.add_argument('--ipv4', action='store_true', 172 | help='require IPv4') 173 | parser.add_argument('--inic', action='store_true', 174 | help='require Intel NIC') 175 | parser.add_argument('--cpu-count', nargs=1, required=False, type=int, 176 | default=[1], 177 | help='min CPU count') 178 | parser.add_argument('--ram', nargs=1, required=False, type=int, 179 | help='min RAM (GB)') 180 | parser.add_argument('--ecc', action='store_true', 181 | help='require ECC memory') 182 | parser.add_argument('--dc', nargs=1, required=False, 183 | help='datacenter (FSN1-DC15) or location (FSN)') 184 | parser.add_argument('-f', nargs='?', 185 | default='/tmp/hah.txt', 186 | help='state file') 187 | parser.add_argument('--exclude-tax', action='store_true', 188 | help='exclude tax from output price') 189 | parser.add_argument('--test-mode', action='store_true', 190 | help='do not send actual messages and ignore state file') 191 | parser.add_argument('--debug', action='store_true', 192 | help='debug mode') 193 | parser.add_argument('--send-payload', action='store_true', 194 | help='send server data as JSON payload') 195 | cli_args = parser.parse_args() 196 | 197 | if not cli_args.test_mode: 198 | f = open(cli_args.f, 'a+') 199 | idsProcessed = open(cli_args.f).read() 200 | if cli_args.provider[0] == "dummy": 201 | notifier = None 202 | else: 203 | notifier = notifiers.get_notifier(cli_args.provider[0]) 204 | 205 | servers = None 206 | try: 207 | s = requests.Session() 208 | s.mount('file://', requests_file.FileAdapter()) 209 | rsp = s.get(cli_args.data_url[0], headers={ 210 | 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15'}) 211 | servers = json.loads(rsp.text)['server'] 212 | except Exception as e: 213 | print('Failed to download auction list') 214 | print(e) 215 | exit(1) 216 | 217 | for server_raw in servers: 218 | tax = cli_args.tax[0] if not cli_args.exclude_tax else 0.0 219 | server = Server(server_raw, tax) 220 | 221 | if cli_args.debug: 222 | print(json.dumps(server_raw)) 223 | if not cli_args.test_mode and str(server.id) in idsProcessed: 224 | continue 225 | 226 | datacenter_matches = False if cli_args.dc else True 227 | if cli_args.dc is not None and cli_args.dc[0] in server.datacenter: 228 | datacenter_matches = True 229 | 230 | price_matches = server.price <= cli_args.price[0] if cli_args.price else True 231 | 232 | cpu_count_matches = server.cpu_count >= cli_args.cpu_count[ 233 | 0] if cli_args.cpu_count else True 234 | ram_matches = server.ram_size >= cli_args.ram[0] if cli_args.ram else True 235 | 236 | disk_count_matches = server.disk_count >= cli_args.disk_count[ 237 | 0] if cli_args.disk_count else True 238 | disk_size_matches = server.disk_size_total >= cli_args.disk_size[ 239 | 0] if cli_args.disk_size else True 240 | disk_min_size_matches = server.get_smallest_disk_size( 241 | ) >= cli_args.disk_min_size[0] if cli_args.disk_min_size else True 242 | disk_quick_matches = server.has_quick_disk() if cli_args.disk_quick else True 243 | 244 | hw_raid_matches = server.sp_hw_raid if cli_args.hw_raid else True 245 | red_psu_matches = server.sp_red_psu if cli_args.red_psu else True 246 | ecc_matches = server.sp_ecc if cli_args.ecc else True 247 | gpu_matches = server.sp_gpu if cli_args.gpu else True 248 | ipv4_matches = server.sp_ipv4 if cli_args.ipv4 else True 249 | inic_matches = server.sp_inic if cli_args.inic else True 250 | 251 | if price_matches and disk_count_matches and disk_size_matches and disk_min_size_matches and \ 252 | disk_quick_matches and hw_raid_matches and red_psu_matches and cpu_count_matches and \ 253 | ram_matches and ecc_matches and gpu_matches and ipv4_matches and inic_matches and \ 254 | datacenter_matches: 255 | 256 | print(server.get_header()) 257 | if not cli_args.test_mode: 258 | send_notification(notifier, server, cli_args.send_payload) 259 | f.write(","+str(server.id)) 260 | 261 | if not cli_args.test_mode: 262 | f.close() 263 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests_file==1.2 2 | notifiers==1.3 3 | html2text==2020.1.16 --------------------------------------------------------------------------------