]> git.mxchange.org Git - fba.git/blob - fba.py
Continued:
[fba.git] / fba.py
1 # Fedi API Block - An aggregator for fetching blocking data from fediverse nodes
2 # Copyright (C) 2023 Free Software Foundation
3 #
4 # This program is free software: you can redistribute it and/or modify
5 # it under the terms of the GNU Affero General Public License as published
6 # by the Free Software Foundation, either version 3 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 # GNU Affero General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
16
17 import bs4
18 import hashlib
19 import re
20 import reqto
21 import json
22 import sqlite3
23 import sys
24 import time
25 import validators
26
27 with open("config.json") as f:
28     config = json.loads(f.read())
29
30 # Don't check these, known trolls/flooders/testing/developing
31 blacklist = [
32     # Floods network with fake nodes as "research" project
33     "activitypub-troll.cf",
34     # Similar troll
35     "gab.best",
36     # Similar troll
37     "4chan.icu",
38     # Flooder (?)
39     "social.shrimpcam.pw",
40     # Flooder (?)
41     "mastotroll.netz.org",
42     # Testing/developing installations
43     "ngrok.io", "ngrok-free.app",
44 ]
45
46 # Array with pending errors needed to be written to database
47 pending_errors = {
48 }
49
50 # "rel" identifiers (no real URLs)
51 nodeinfo_identifier = [
52     "https://nodeinfo.diaspora.software/ns/schema/2.1",
53     "https://nodeinfo.diaspora.software/ns/schema/2.0",
54     "https://nodeinfo.diaspora.software/ns/schema/1.1",
55     "https://nodeinfo.diaspora.software/ns/schema/1.0",
56     "http://nodeinfo.diaspora.software/ns/schema/2.1",
57     "http://nodeinfo.diaspora.software/ns/schema/2.0",
58     "http://nodeinfo.diaspora.software/ns/schema/1.1",
59     "http://nodeinfo.diaspora.software/ns/schema/1.0",
60 ]
61
62 # HTTP headers for non-API requests
63 headers = {
64     "User-Agent": config["useragent"],
65 }
66 # HTTP headers for API requests
67 api_headers = {
68     "User-Agent": config["useragent"],
69     "Content-Type": "application/json",
70 }
71
72 # Found info from node, such as nodeinfo URL, detection mode that needs to be
73 # written to database. Both arrays must be filled at the same time or else
74 # update_nodeinfos() will fail
75 nodeinfos = {
76     # Detection mode: 'AUTO_DISCOVERY', 'STATIC_CHECKS' or 'GENERATOR'
77     # NULL means all detection methods have failed (maybe still reachable instance)
78     "detection_mode": {},
79     # Found nodeinfo URL
80     "nodeinfo_url": {},
81     # Where to fetch peers (other instances)
82     "get_peers_url": {},
83 }
84
85 language_mapping = {
86     # English -> English
87     "Silenced instances"            : "Silenced servers",
88     "Suspended instances"           : "Suspended servers",
89     "Limited instances"             : "Limited servers",
90     # Mappuing German -> English
91     "Gesperrte Server"              : "Suspended servers",
92     "Gefilterte Medien"             : "Filtered media",
93     "Stummgeschaltete Server"       : "Silenced servers",
94     # Japanese -> English
95     "停止済みのサーバー"            : "Suspended servers",
96     "制限中のサーバー"              : "Limited servers",
97     "メディアを拒否しているサーバー": "Filtered media",
98     "サイレンス済みのサーバー"      : "Silenced servers",
99     # ??? -> English
100     "שרתים מושעים"                  : "Suspended servers",
101     "מדיה מסוננת"                   : "Filtered media",
102     "שרתים מוגבלים"                 : "Silenced servers",
103     # French -> English
104     "Serveurs suspendus"            : "Suspended servers",
105     "Médias filtrés"                : "Filtered media",
106     "Serveurs limités"              : "Limited servers",
107     "Serveurs modérés"              : "Limited servers",
108 }
109
110 # URL for fetching peers
111 get_peers_url = "/api/v1/instance/peers"
112
113 # Connect to database
114 connection = sqlite3.connect("blocks.db")
115 cursor = connection.cursor()
116
117 # Pattern instance for version numbers
118 patterns = [
119     # semantic version number (with v|V) prefix)
120     re.compile("^(?P<version>v|V{0,1})(\.{0,1})(?P<major>0|[1-9]\d*)\.(?P<minor>0+|[1-9]\d*)(\.(?P<patch>0+|[1-9]\d*)(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)?$"),
121     # non-sematic, e.g. 1.2.3.4
122     re.compile("^(?P<version>v|V{0,1})(\.{0,1})(?P<major>0|[1-9]\d*)\.(?P<minor>0+|[1-9]\d*)(\.(?P<patch>0+|[1-9]\d*)(\.(?P<subpatch>0|[1-9]\d*))?)$"),
123     # non-sematic, e.g. 2023-05[-dev]
124     re.compile("^(?P<year>[1-9]{1}[0-9]{3})\.(?P<month>[0-9]{2})(-dev){0,1}$"),
125     # non-semantic, e.g. abcdef0
126     re.compile("^[a-f0-9]{7}$"),
127 ]
128
129 def add_peers(rows: dict) -> list:
130     # DEBUG: print(f"DEBUG: rows()={len(rows)} - CALLED!")
131     peers = list()
132     for element in ["linked", "allowed", "blocked"]:
133         # DEBUG: print(f"DEBUG: Checking element='{element}'")
134         if element in rows and rows[element] != None:
135             # DEBUG: print(f"DEBUG: Adding {len(rows[element])} peer(s) to peers list ...")
136             for peer in rows[element]:
137                 # DEBUG: print(f"DEBUG: peer='{peer}' - BEFORE!")
138                 peer = tidyup(peer)
139
140                 # DEBUG: print(f"DEBUG: peer='{peer}' - AFTER!")
141                 if is_blacklisted(peer):
142                     # DEBUG: print(f"DEBUG: peer='{peer}' is blacklisted, skipped!")
143                     continue
144
145                 # DEBUG: print(f"DEBUG: Adding peer='{peer}' ...")
146                 peers.append(peer)
147
148     # DEBUG: print(f"DEBUG: peers()={len(peers)} - EXIT!")
149     return peers
150
151 def remove_version(software: str) -> str:
152     # DEBUG: print(f"DEBUG: software='{software}' - CALLED!")
153     if not "." in software and " " not in software:
154         print(f"WARNING: software='{software}' does not contain a version number.")
155         return software
156
157     temp = software
158     if ";" in software:
159         temp = software.split(";")[0]
160     elif "," in software:
161         temp = software.split(",")[0]
162     elif " - " in software:
163         temp = software.split(" - ")[0]
164
165     # DEBUG: print(f"DEBUG: software='{software}'")
166     version = None
167     if " " in software:
168         version = temp.split(" ")[-1]
169     elif "/" in software:
170         version = temp.split("/")[-1]
171     elif "-" in software:
172         version = temp.split("-")[-1]
173     else:
174         # DEBUG: print(f"DEBUG: Was not able to find common seperator, returning untouched software='{software}'")
175         return software
176
177     matches = None
178     match = None
179     # DEBUG: print(f"DEBUG: Checking {len(patterns)} patterns ...")
180     for pattern in patterns:
181         # Run match()
182         match = pattern.match(version)
183
184         # DEBUG: print(f"DEBUG: match[]={type(match)}")
185         if type(match) is re.Match:
186             break
187
188     # DEBUG: print(f"DEBUG: version[{type(version)}]='{version}',match='{match}'")
189     if type(match) is not re.Match:
190         print(f"WARNING: version='{version}' does not match regex, leaving software='{software}' untouched.")
191         return software
192
193     # DEBUG: print(f"DEBUG: Found valid version number: '{version}', removing it ...")
194     end = len(temp) - len(version) - 1
195
196     # DEBUG: print(f"DEBUG: end[{type(end)}]={end}")
197     software = temp[0:end].strip()
198     if " version" in software:
199         # DEBUG: print(f"DEBUG: software='{software}' contains word ' version'")
200         software = strip_until(software, " version")
201
202     # DEBUG: print(f"DEBUG: software='{software}' - EXIT!")
203     return software
204
205 def strip_powered_by(software: str) -> str:
206     # DEBUG: print(f"DEBUG: software='{software}' - CALLED!")
207     if software == "":
208         print(f"ERROR: Bad method call, 'software' is empty")
209         raise Exception("Parameter 'software' is empty")
210     elif not "powered by" in software:
211         print(f"WARNING: Cannot find 'powered by' in '{software}'!")
212         return software
213
214     start = software.find("powered by ")
215     # DEBUG: print(f"DEBUG: start[{type(start)}]='{start}'")
216
217     software = software[start + 11:].strip()
218     # DEBUG: print(f"DEBUG: software='{software}'")
219
220     software = strip_until(software, " - ")
221
222     # DEBUG: print(f"DEBUG: software='{software}' - EXIT!")
223     return software
224
225 def strip_until(software: str, until: str) -> str:
226     # DEBUG: print(f"DEBUG: software='{software}',until='{until}' - CALLED!")
227     if software == "":
228         print(f"ERROR: Bad method call, 'software' is empty")
229         raise Exception("Parameter 'software' is empty")
230     elif until == "":
231         print(f"ERROR: Bad method call, 'until' is empty")
232         raise Exception("Parameter 'until' is empty")
233     elif not until in software:
234         print(f"WARNING: Cannot find 'powered by' in '{software}'!")
235         return software
236
237     # Next, strip until part
238     end = software.find(until)
239
240     # DEBUG: print(f"DEBUG: end[{type(end)}]='{end}'")
241     if end > 0:
242         software = software[0:end].strip()
243
244     # DEBUG: print(f"DEBUG: software='{software}' - EXIT!")
245     return software
246
247 def is_blacklisted(domain: str) -> bool:
248     blacklisted = False
249     for peer in blacklist:
250         if peer in domain:
251             blacklisted = True
252
253     return blacklisted
254
255 def remove_pending_error(domain: str):
256     try:
257         # Prevent updating any pending errors, nodeinfo was found
258         del pending_errors[domain]
259
260     except:
261         pass
262
263 def get_hash(domain: str) -> str:
264     return hashlib.sha256(domain.encode("utf-8")).hexdigest()
265
266 def update_last_blocked(domain: str):
267     # DEBUG: print("DEBUG: Updating last_blocked for domain", domain)
268     try:
269         cursor.execute("UPDATE instances SET last_blocked = ?, last_updated = ? WHERE domain = ? LIMIT 1", [
270             time.time(),
271             time.time(),
272             domain
273         ])
274
275         if cursor.rowcount == 0:
276             print("WARNING: Did not update any rows:", domain)
277
278     except BaseException as e:
279         print(f"ERROR: failed SQL query: domain='{domain}',exception:'{str(e)}'")
280         sys.exit(255)
281
282     # DEBUG: print("DEBUG: EXIT!")
283
284 def update_nodeinfos(domain: str):
285     # DEBUG: print("DEBUG: Updating nodeinfo for domain:", domain)
286     sql_string = ''
287     fields = list()
288     for key in nodeinfos:
289         # DEBUG: print("DEBUG: key:", key)
290         if domain in nodeinfos[key]:
291            # DEBUG: print(f"DEBUG: Adding '{nodeinfos[key][domain]}' for key='{key}' ...")
292            fields.append(nodeinfos[key][domain])
293            sql_string += f" {key} = ?,"
294
295     fields.append(domain)
296     # DEBUG: print(f"DEBUG: sql_string='{sql_string}',fields()={len(fields)}")
297
298     sql = "UPDATE instances SET" + sql_string + " last_status_code = NULL, last_error_details = NULL WHERE domain = ? LIMIT 1"
299     # DEBUG: print("DEBUG: sql:", sql)
300
301     try:
302         # DEBUG: print("DEBUG: Executing SQL:", sql)
303         cursor.execute(sql, fields)
304         # DEBUG: print(f"DEBUG: Success! (rowcount={cursor.rowcount })")
305
306         if cursor.rowcount == 0:
307             print("WARNING: Did not update any rows:", domain)
308
309     except BaseException as e:
310         print(f"ERROR: failed SQL query: domain='{domain}',sql='{sql}',exception:'{str(e)}'")
311         sys.exit(255)
312
313     # DEBUG: print("DEBUG: Deleting nodeinfos for domain:", domain)
314     for key in nodeinfos:
315         try:
316             # DEBUG: print("DEBUG: Deleting key:", key)
317             del nodeinfos[key][domain]
318         except:
319             pass
320
321     # DEBUG: print("DEBUG: EXIT!")
322
323 def update_last_error(domain: str, res: any):
324     # DEBUG: print("DEBUG: domain,res[]:", domain, type(res))
325     try:
326         # DEBUG: print("DEBUG: BEFORE res[]:", type(res))
327         if isinstance(res, BaseException) or isinstance(res, json.JSONDecodeError):
328             res = str(res)
329
330         # DEBUG: print("DEBUG: AFTER res[]:", type(res))
331         if type(res) is str:
332             # DEBUG: print(f"DEBUG: Setting last_error_details='{res}'");
333             cursor.execute("UPDATE instances SET last_status_code = 999, last_error_details = ?, last_updated = ? WHERE domain = ? LIMIT 1", [
334                 res,
335                 time.time(),
336                 domain
337             ])
338         else:
339             # DEBUG: print(f"DEBUG: Setting last_error_details='{res.reason}'");
340             cursor.execute("UPDATE instances SET last_status_code = ?, last_error_details = ?, last_updated = ? WHERE domain = ? LIMIT 1", [
341                 res.status_code,
342                 res.reason,
343                 time.time(),
344                 domain
345             ])
346
347         if cursor.rowcount == 0:
348             # DEBUG: print("DEBUG: Did not update any rows:", domain)
349             pending_errors[domain] = res
350
351     except BaseException as e:
352         print(f"ERROR: failed SQL query: domain='{domain}',exception:'{str(e)}'")
353         sys.exit(255)
354
355     # DEBUG: print("DEBUG: EXIT!")
356
357 def update_last_instance_fetch(domain: str):
358     #print("DEBUG: Updating last_instance_fetch for domain:", domain)
359     try:
360         cursor.execute("UPDATE instances SET last_instance_fetch = ?, last_updated = ? WHERE domain = ? LIMIT 1", [
361             time.time(),
362             time.time(),
363             domain
364         ])
365
366         if cursor.rowcount == 0:
367             print("WARNING: Did not update any rows:", domain)
368
369     except BaseException as e:
370         print(f"ERROR: failed SQL query: domain='{domain}',exception:'{str(e)}'")
371         sys.exit(255)
372
373     connection.commit()
374     #print("DEBUG: EXIT!")
375
376 def update_last_nodeinfo(domain: str):
377     # DEBUG: print("DEBUG: Updating last_nodeinfo for domain:", domain)
378     try:
379         cursor.execute("UPDATE instances SET last_nodeinfo = ?, last_updated = ? WHERE domain = ? LIMIT 1", [
380             time.time(),
381             time.time(),
382             domain
383         ])
384
385         if cursor.rowcount == 0:
386             print("WARNING: Did not update any rows:", domain)
387
388     except BaseException as e:
389         print(f"ERROR: failed SQL query: domain='{domain}',exception:'{str(e)}'")
390         sys.exit(255)
391
392     connection.commit()
393     # DEBUG: print("DEBUG: EXIT!")
394
395 def get_peers(domain: str, software: str) -> list:
396     # DEBUG: print("DEBUG: Getting peers for domain:", domain, software)
397     peers = list()
398
399     if software == "misskey":
400         # DEBUG: print(f"DEBUG: domain='{domain}' is misskey, sending API POST request ...")
401
402         offset = 0
403         step = config["misskey_offset"]
404         # iterating through all "suspended" (follow-only in its terminology)
405         # instances page-by-page, since that troonware doesn't support
406         # sending them all at once
407         while True:
408             # DEBUG: print(f"DEBUG: Fetching offset='{offset}' from '{domain}' ...")
409             if offset == 0:
410                 fetched = post_json_api(domain, "/api/federation/instances", json.dumps({
411                     "sort" : "+pubAt",
412                     "host" : None,
413                     "limit": step
414                 }), {"Origin": domain})
415             else:
416                 fetched = post_json_api(domain, "/api/federation/instances", json.dumps({
417                     "sort"  : "+pubAt",
418                     "host"  : None,
419                     "limit" : step,
420                     "offset": offset - 1
421                 }), {"Origin": domain})
422
423             # DEBUG: print("DEBUG: fetched():", len(fetched))
424             if len(fetched) == 0:
425                 # DEBUG: print("DEBUG: Returned zero bytes, exiting loop:", domain)
426                 break
427             elif len(fetched) != config["misskey_offset"]:
428                 # DEBUG: print(f"DEBUG: Fetched '{len(fetched)}' row(s) but expected: '{config['misskey_offset']}'")
429                 offset = offset + (config["misskey_offset"] - len(fetched))
430             else:
431                 # DEBUG: print("DEBUG: Raising offset by step:", step)
432                 offset = offset + step
433
434             # Check records
435             # DEBUG: print(f"DEBUG: fetched({len(fetched)})[]={type(fetched)}")
436             if isinstance(fetched, dict) and "error" in fetched and "message" in fetched["error"]:
437                 print(f"WARNING: post_json_api() returned error: {fetched['error']['message']}")
438                 update_last_error(domain, fetched["error"]["message"])
439                 break
440             for row in fetched:
441                 # DEBUG: print(f"DEBUG: row():{len(row)}")
442                 if not "host" in row:
443                     print(f"WARNING: row()={len(row)} does not contain element 'host': {row},domain='{domain}'")
444                     continue
445                 elif is_blacklisted(row["host"]):
446                     # DEBUG: print(f"DEBUG: row[host]='{row['host']}' is blacklisted. domain='{domain}'")
447                     continue
448
449                 # DEBUG: print(f"DEBUG: Adding peer: '{row['host']}'")
450                 peers.append(row["host"])
451
452         update_last_instance_fetch(domain)
453
454         # DEBUG: print("DEBUG: Returning peers[]:", type(peers))
455         return peers
456     elif software == "lemmy":
457         # DEBUG: print(f"DEBUG: domain='{domain}' is Lemmy, fetching JSON ...")
458         try:
459             res = reqto.get(f"https://{domain}/api/v3/site", headers=api_headers, timeout=(config["connection_timeout"], config["read_timeout"]))
460
461             data = res.json()
462             # DEBUG: print(f"DEBUG: res.ok={res.ok},res.status_code='{res.status_code}',data[]='{type(data)}'")
463             if not res.ok or res.status_code >= 400:
464                 print("WARNING: Could not reach any JSON API:", domain)
465                 update_last_error(domain, res)
466             elif res.ok and isinstance(data, list):
467                 # DEBUG: print(f"DEBUG: domain='{domain}' returned a list: '{data}'")
468                 sys.exit(255)
469             elif "federated_instances" in data:
470                 # DEBUG: print(f"DEBUG: Found federated_instances for domain='{domain}'")
471                 peers = peers + add_peers(data["federated_instances"])
472                 # DEBUG: print("DEBUG: Added instance(s) to peers")
473             else:
474                 print("WARNING: JSON response does not contain 'federated_instances':", domain)
475                 update_last_error(domain, res)
476
477         except BaseException as e:
478             print(f"WARNING: Exception during fetching JSON: domain='{domain}',exception:'{str(e)}'")
479
480         update_last_instance_fetch(domain)
481
482         # DEBUG: print("DEBUG: Returning peers[]:", type(peers))
483         return peers
484     elif software == "peertube":
485         # DEBUG: print(f"DEBUG: domain='{domain}' is a PeerTube, fetching JSON ...")
486
487         start = 0
488         for mode in ["followers", "following"]:
489             # DEBUG: print(f"DEBUG: domain='{domain}',mode='{mode}'")
490             while True:
491                 try:
492                     res = reqto.get(f"https://{domain}/api/v1/server/{mode}?start={start}&count=100", headers=api_headers, timeout=(config["connection_timeout"], config["read_timeout"]))
493
494                     data = res.json()
495                     # DEBUG: print(f"DEBUG: res.ok={res.ok},res.status_code='{res.status_code}',data[]='{type(data)}'")
496                     if res.ok and isinstance(data, dict):
497                         # DEBUG: print("DEBUG: Success, data:", len(data))
498                         if "data" in data:
499                             # DEBUG: print(f"DEBUG: Found {len(data['data'])} record(s).")
500                             for record in data["data"]:
501                                 # DEBUG: print(f"DEBUG: record()={len(record)}")
502                                 if mode in record and "host" in record[mode]:
503                                     # DEBUG: print(f"DEBUG: Found host={record[mode]['host']}, adding ...")
504                                     peers.append(record[mode]["host"])
505                                 else:
506                                     print(f"WARNING: record from '{domain}' has no '{mode}' or 'host' record: {record}")
507
508                             if len(data["data"]) < 100:
509                                 # DEBUG: print("DEBUG: Reached end of JSON response:", domain)
510                                 break
511
512                         # Continue with next row
513                         start = start + 100
514
515                 except BaseException as e:
516                     print(f"WARNING: Exception during fetching JSON: domain='{domain}',exception:'{str(e)}'")
517
518             update_last_instance_fetch(domain)
519
520             # DEBUG: print("DEBUG: Returning peers[]:", type(peers))
521             return peers
522
523     # DEBUG: print(f"DEBUG: Fetching get_peers_url='{get_peers_url}' from '{domain}' ...")
524     try:
525         res = reqto.get(f"https://{domain}{get_peers_url}", headers=api_headers, timeout=(config["connection_timeout"], config["read_timeout"]))
526
527         data = res.json()
528         # DEBUG: print(f"DEBUG: res.ok={res.ok},res.status_code={res.status_code},data[]='{type(data)}'")
529         if not res.ok or res.status_code >= 400:
530             # DEBUG: print(f"DEBUG: Was not able to fetch '{get_peers_url}', trying alternative ...")
531             res = reqto.get(f"https://{domain}/api/v3/site", headers=api_headers, timeout=(config["connection_timeout"], config["read_timeout"]))
532
533             data = res.json()
534             # DEBUG: print(f"DEBUG: res.ok={res.ok},res.status_code={res.status_code},data[]='{type(data)}'")
535             if not res.ok or res.status_code >= 400:
536                 print("WARNING: Could not reach any JSON API:", domain)
537                 update_last_error(domain, res)
538             elif res.ok and isinstance(data, list):
539                 print(f"DEBUG: domain='{domain}' returned a list: '{data}'")
540                 sys.exit(255)
541             elif "federated_instances" in data:
542                 # DEBUG: print(f"DEBUG: Found federated_instances for domain='{domain}'")
543                 peers = peers + add_peers(data["federated_instances"])
544                 # DEBUG: print("DEBUG: Added instance(s) to peers")
545             else:
546                 print("WARNING: JSON response does not contain 'federated_instances':", domain)
547                 update_last_error(domain, res)
548         else:
549             # DEBUG: print("DEBUG: Querying API was successful:", domain, len(data))
550             peers = data
551             nodeinfos["get_peers_url"][domain] = get_peers_url
552
553     except BaseException as e:
554         print("WARNING: Some error during get():", domain, e)
555         update_last_error(domain, e)
556
557     update_last_instance_fetch(domain)
558
559     # DEBUG: print("DEBUG: Returning peers[]:", type(peers))
560     return peers
561
562 def post_json_api(domain: str, path: str, parameter: str, extra_headers: dict = {}) -> dict:
563     # DEBUG: print("DEBUG: Sending POST to domain,path,parameter:", domain, path, parameter, extra_headers)
564     data = {}
565     try:
566         res = reqto.post(f"https://{domain}{path}", data=parameter, headers={**api_headers, **extra_headers}, timeout=(config["connection_timeout"], config["read_timeout"]))
567
568         data = res.json()
569         # DEBUG: print(f"DEBUG: res.ok={res.ok},res.status_code={res.status_code},data[]='{type(data)}'")
570         if not res.ok or res.status_code >= 400:
571             print(f"WARNING: Cannot query JSON API: domain='{domain}',path='{path}',parameter()={len(parameter)},res.status_code='{res.status_code}',data[]='{type(data)}'")
572             update_last_error(domain, res)
573         else:
574             update_last_nodeinfo(domain)
575
576     except BaseException as e:
577         print(f"WARNING: Some error during post(): domain='{domain}',path='{path}',parameter()={len(parameter)},exception[{type(e)}]:'{str(e)}'")
578
579     # DEBUG: print(f"DEBUG: Returning data({len(data)})=[]:{type(data)}")
580     return data
581
582 def fetch_nodeinfo(domain: str, path: str = None) -> list:
583     # DEBUG: print("DEBUG: Fetching nodeinfo from domain,path:", domain, path)
584
585     nodeinfo = fetch_wellknown_nodeinfo(domain)
586     # DEBUG: print("DEBUG: nodeinfo:", len(nodeinfo))
587
588     if len(nodeinfo) > 0:
589         # DEBUG: print("DEBUG: Returning auto-discovered nodeinfo:", len(nodeinfo))
590         return nodeinfo
591
592     requests = [
593        f"https://{domain}/nodeinfo/2.1.json",
594        f"https://{domain}/nodeinfo/2.1",
595        f"https://{domain}/nodeinfo/2.0.json",
596        f"https://{domain}/nodeinfo/2.0",
597        f"https://{domain}/nodeinfo/1.0",
598        f"https://{domain}/api/v1/instance"
599     ]
600
601     data = {}
602     for request in requests:
603         if path != None and path != "" and request != path:
604             print(f"DEBUG: path='{path}' does not match request='{request}' - SKIPPED!")
605             continue
606
607         try:
608             # DEBUG: print("DEBUG: Fetching request:", request)
609             res = reqto.get(request, headers=api_headers, timeout=(config["connection_timeout"], config["read_timeout"]))
610
611             data = res.json()
612             # DEBUG: print(f"DEBUG: res.ok={res.ok},res.status_code={res.status_code},data[]='{type(data)}'")
613             if res.ok and isinstance(data, dict):
614                 # DEBUG: print("DEBUG: Success:", request)
615                 nodeinfos["detection_mode"][domain] = "STATIC_CHECK"
616                 nodeinfos["nodeinfo_url"][domain] = request
617                 break
618             elif res.ok and isinstance(data, list):
619                 # DEBUG: print(f"DEBUG: domain='{domain}' returned a list: '{data}'")
620                 sys.exit(255)
621             elif not res.ok or res.status_code >= 400:
622                 print("WARNING: Failed fetching nodeinfo from domain:", domain)
623                 update_last_error(domain, res)
624                 continue
625
626         except BaseException as e:
627             # DEBUG: print("DEBUG: Cannot fetch API request:", request)
628             update_last_error(domain, e)
629             pass
630
631     # DEBUG: print("DEBUG: Returning data[]:", type(data))
632     return data
633
634 def fetch_wellknown_nodeinfo(domain: str) -> list:
635     # DEBUG: print("DEBUG: Fetching .well-known info for domain:", domain)
636     data = {}
637
638     try:
639         res = reqto.get(f"https://{domain}/.well-known/nodeinfo", headers=api_headers, timeout=(config["connection_timeout"], config["read_timeout"]))
640
641         data = res.json()
642         # DEBUG: print("DEBUG: domain,res.ok,data[]:", domain, res.ok, type(data))
643         if res.ok and isinstance(data, dict):
644             nodeinfo = data
645             # DEBUG: print("DEBUG: Found entries:", len(nodeinfo), domain)
646             if "links" in nodeinfo:
647                 # DEBUG: print("DEBUG: Found links in nodeinfo():", len(nodeinfo["links"]))
648                 for link in nodeinfo["links"]:
649                     # DEBUG: print("DEBUG: rel,href:", link["rel"], link["href"])
650                     if link["rel"] in nodeinfo_identifier:
651                         # DEBUG: print("DEBUG: Fetching nodeinfo from:", link["href"])
652                         res = reqto.get(link["href"])
653
654                         data = res.json()
655                         # DEBUG: print("DEBUG: href,res.ok,res.status_code:", link["href"], res.ok, res.status_code)
656                         if res.ok and isinstance(data, dict):
657                             # DEBUG: print("DEBUG: Found JSON nodeinfo():", len(data))
658                             nodeinfos["detection_mode"][domain] = "AUTO_DISCOVERY"
659                             nodeinfos["nodeinfo_url"][domain] = link["href"]
660                             break
661                     else:
662                         print("WARNING: Unknown 'rel' value:", domain, link["rel"])
663             else:
664                 print("WARNING: nodeinfo does not contain 'links':", domain)
665
666     except BaseException as e:
667         print("WARNING: Failed fetching .well-known info:", domain)
668         update_last_error(domain, e)
669         pass
670
671     # DEBUG: print("DEBUG: Returning data[]:", type(data))
672     return data
673
674 def fetch_generator_from_path(domain: str, path: str = "/") -> str:
675     # DEBUG: print(f"DEBUG: domain='{domain}',path='{path}' - CALLED!")
676     software = None
677
678     try:
679         # DEBUG: print(f"DEBUG: Fetching path='{path}' from '{domain}' ...")
680         res = reqto.get(f"https://{domain}{path}", headers=headers, timeout=(config["connection_timeout"], config["read_timeout"]))
681
682         # DEBUG: print("DEBUG: domain,res.ok,res.status_code,res.text[]:", domain, res.ok, res.status_code, type(res.text))
683         if res.ok and res.status_code < 300 and len(res.text) > 0:
684             # DEBUG: print("DEBUG: Search for <meta name='generator'>:", domain)
685             doc = bs4.BeautifulSoup(res.text, "html.parser")
686
687             # DEBUG: print("DEBUG: doc[]:", type(doc))
688             tag = doc.find("meta", {"name": "generator"})
689
690             # DEBUG: print(f"DEBUG: tag[{type(tag)}: {tag}")
691             if isinstance(tag, bs4.element.Tag):
692                 # DEBUG: print("DEBUG: Found generator meta tag: ", domain)
693                 software = tidyup(tag.get("content"))
694                 print(f"INFO: domain='{domain}' is generated by '{software}'")
695                 nodeinfos["detection_mode"][domain] = "GENERATOR"
696                 remove_pending_error(domain)
697
698     except BaseException as e:
699         # DEBUG: print(f"DEBUG: Cannot fetch / from '{domain}':", e)
700         update_last_error(domain, e)
701         pass
702
703     # DEBUG: print(f"DEBUG: software[]={type(software)}")
704     if type(software) is str and software == "":
705         # DEBUG: print(f"DEBUG: Corrected empty string to None for software of domain='{domain}'")
706         software = None
707     elif type(software) is str and ("." in software or " " in software):
708         # DEBUG: print(f"DEBUG: software='{software}' may contain a version number, domain='{domain}', removing it ...")
709         software = remove_version(software)
710
711     # DEBUG: print(f"DEBUG: software[]={type(software)}")
712     if type(software) is str and "powered by" in software:
713         # DEBUG: print(f"DEBUG: software='{software}' has 'powered by' in it")
714         software = remove_version(strip_powered_by(software))
715     elif type(software) is str and " by " in software:
716         # DEBUG: print(f"DEBUG: software='{software}' has ' by ' in it")
717         software = strip_until(software, " by ")
718     elif type(software) is str and " see " in software:
719         # DEBUG: print(f"DEBUG: software='{software}' has ' see ' in it")
720         software = strip_until(software, " see ")
721
722     # DEBUG: print(f"DEBUG: software='{software}' - EXIT!")
723     return software
724
725 def determine_software(domain: str, path: str = None) -> str:
726     # DEBUG: print("DEBUG: Determining software for domain,path:", domain, path)
727     software = None
728
729     # DEBUG: print(f"DEBUG: Fetching nodeinfo from '{domain}' ...")
730     data = fetch_nodeinfo(domain, path)
731
732     # DEBUG: print("DEBUG: data[]:", type(data))
733     if not isinstance(data, dict) or len(data) == 0:
734         # DEBUG: print("DEBUG: Could not determine software type:", domain)
735         return fetch_generator_from_path(domain)
736
737     # DEBUG: print("DEBUG: data():", len(data), data)
738     if "status" in data and data["status"] == "error" and "message" in data:
739         print("WARNING: JSON response is an error:", data["message"])
740         update_last_error(domain, data["message"])
741         return fetch_generator_from_path(domain)
742     elif "software" not in data or "name" not in data["software"]:
743         # DEBUG: print(f"DEBUG: JSON response from {domain} does not include [software][name], fetching / ...")
744         software = fetch_generator_from_path(domain)
745
746         # DEBUG: print(f"DEBUG: Generator for domain='{domain}' is: {software}, EXIT!")
747         return software
748
749     software = tidyup(data["software"]["name"])
750
751     # DEBUG: print("DEBUG: sofware after tidyup():", software)
752     if software in ["akkoma", "rebased"]:
753         # DEBUG: print("DEBUG: Setting pleroma:", domain, software)
754         software = "pleroma"
755     elif software in ["hometown", "ecko"]:
756         # DEBUG: print("DEBUG: Setting mastodon:", domain, software)
757         software = "mastodon"
758     elif software in ["calckey", "groundpolis", "foundkey", "cherrypick", "meisskey"]:
759         # DEBUG: print("DEBUG: Setting misskey:", domain, software)
760         software = "misskey"
761     elif software.find("/") > 0:
762         print("WARNING: Spliting of slash:", software)
763         software = software.split("/")[-1];
764     elif software.find("|") > 0:
765         print("WARNING: Spliting of pipe:", software)
766         software = tidyup(software.split("|")[0]);
767     elif "powered by" in software:
768         # DEBUG: print(f"DEBUG: software='{software}' has 'powered by' in it")
769         software = strip_powered_by(software)
770     elif type(software) is str and " by " in software:
771         # DEBUG: print(f"DEBUG: software='{software}' has ' by ' in it")
772         software = strip_until(software, " by ")
773     elif type(software) is str and " see " in software:
774         # DEBUG: print(f"DEBUG: software='{software}' has ' see ' in it")
775         software = strip_until(software, " see ")
776
777     # DEBUG: print(f"DEBUG: software[]={type(software)}")
778     if software == "":
779         print("WARNING: tidyup() left no software name behind:", domain)
780         software = None
781
782     # DEBUG: print(f"DEBUG: software[]={type(software)}")
783     if str(software) == "":
784         # DEBUG: print(f"DEBUG: software for '{domain}' was not detected, trying generator ...")
785         software = fetch_generator_from_path(domain)
786     elif len(str(software)) > 0 and ("." in software or " " in software):
787         # DEBUG: print(f"DEBUG: software='{software}' may contain a version number, domain='{domain}', removing it ...")
788         software = remove_version(software)
789
790     # DEBUG: print(f"DEBUG: software[]={type(software)}")
791     if type(software) is str and "powered by" in software:
792         # DEBUG: print(f"DEBUG: software='{software}' has 'powered by' in it")
793         software = remove_version(strip_powered_by(software))
794
795     # DEBUG: print("DEBUG: Returning domain,software:", domain, software)
796     return software
797
798 def update_block_reason(reason: str, blocker: str, blocked: str, block_level: str):
799     # DEBUG: print("DEBUG: Updating block reason:", reason, blocker, blocked, block_level)
800     try:
801         cursor.execute(
802             "UPDATE blocks SET reason = ?, last_seen = ? WHERE blocker = ? AND blocked = ? AND block_level = ? AND reason = ''",
803             (
804                 reason,
805                 time.time(),
806                 blocker,
807                 blocked,
808                 block_level
809             ),
810         )
811
812         # DEBUG: print(f"DEBUG: cursor.rowcount={cursor.rowcount}")
813         if cursor.rowcount == 0:
814             print("WARNING: Did not update any rows:", domain)
815
816     except BaseException as e:
817         print(f"ERROR: failed SQL query: reason='{reason}',blocker='{blocker}',blocked='{blocked}',block_level='{block_level}',sql='{sql}',exception:'{str(e)}'")
818         sys.exit(255)
819
820     # DEBUG: print("DEBUG: EXIT!")
821
822 def update_last_seen(blocker: str, blocked: str, block_level: str):
823     # DEBUG: print("DEBUG: Updating last_seen for:", blocker, blocked, block_level)
824     try:
825         cursor.execute(
826             "UPDATE blocks SET last_seen = ? WHERE blocker = ? AND blocked = ? AND block_level = ?",
827             (
828                 time.time(),
829                 blocker,
830                 blocked,
831                 block_level
832             )
833         )
834
835         if cursor.rowcount == 0:
836             print("WARNING: Did not update any rows:", domain)
837
838     except BaseException as e:
839         print(f"ERROR: failed SQL query: last_seen='{last_seen}',blocker='{blocker}',blocked='{blocked}',block_level='{block_level}',exception:'{str(e)}'")
840         sys.exit(255)
841
842     # DEBUG: print("DEBUG: EXIT!")
843
844 def block_instance(blocker: str, blocked: str, reason: str, block_level: str):
845     # DEBUG: print("DEBUG: blocker,blocked,reason,block_level:", blocker, blocked, reason, block_level)
846     if not validators.domain(blocker.split("/")[0]):
847         print("WARNING: Bad blocker:", blocker)
848         raise
849     elif not validators.domain(blocked.split("/")[0]):
850         print("WARNING: Bad blocked:", blocked)
851         raise
852
853     print("INFO: New block:", blocker, blocked, reason, block_level, first_seen, last_seen)
854     try:
855         cursor.execute(
856             "INSERT INTO blocks (blocker, blocked, reason, block_level, first_seen, last_seen) VALUES(?, ?, ?, ?, ?, ?)",
857              (
858                  blocker,
859                  blocked,
860                  reason,
861                  block_level,
862                  time.time(),
863                  time.time()
864              ),
865         )
866
867     except BaseException as e:
868         print(f"ERROR: failed SQL query: blocker='{blocker}',blocked='{blocked}',reason='{reason}',block_level='{block_level}',first_seen='{first_seen}',last_seen='{last_seen}',exception:'{str(e)}'")
869         sys.exit(255)
870
871     # DEBUG: print("DEBUG: EXIT!")
872
873 def is_instance_registered(domain: str) -> bool:
874     # DEBUG: print(f"DEBUG: domain='{domain}' - CALLED!")
875     # Default is not registered
876     registered = False
877
878     try:
879         cursor.execute(
880             "SELECT rowid FROM instances WHERE domain = ? LIMIT 1", [domain]
881         )
882
883         # Check condition
884         registered = cursor.fetchone() != None
885     except BaseException as e:
886         print(f"ERROR: failed SQL query: last_seen='{last_seen}'blocker='{blocker}',blocked='{blocked}',block_level='{block_level}',first_seen='{first_seen}',last_seen='{last_seen}',exception:'{str(e)}'")
887         sys.exit(255)
888
889     # DEBUG: print(f"DEBUG: registered='{registered}' - EXIT!")
890     return registered
891
892 def add_instance(domain: str, origin: str, originator: str, path: str = None):
893     # DEBUG: print("DEBUG: domain,origin,originator,path:", domain, origin, originator, path)
894     if not validators.domain(domain.split("/")[0]):
895         print("WARNING: Bad domain name:", domain)
896         raise
897     elif origin is not None and not validators.domain(origin.split("/")[0]):
898         print("WARNING: Bad origin name:", origin)
899         raise
900
901     software = determine_software(domain, path)
902     # DEBUG: print("DEBUG: Determined software:", software)
903
904     print(f"INFO: Adding instance {domain} (origin: {origin})")
905     try:
906         cursor.execute(
907             "INSERT INTO instances (domain, origin, originator, hash, software, first_seen) VALUES (?, ?, ?, ?, ?, ?)",
908             (
909                domain,
910                origin,
911                originator,
912                get_hash(domain),
913                software,
914                time.time()
915             ),
916         )
917
918         for key in nodeinfos:
919             # DEBUG: print(f"DEBUG: key='{key}',domain='{domain}',nodeinfos[key]={nodeinfos[key]}")
920             if domain in nodeinfos[key]:
921                 # DEBUG: print(f"DEBUG: domain='{domain}' has pending nodeinfo being updated ...")
922                 update_nodeinfos(domain)
923                 remove_pending_error(domain)
924                 break
925
926         if domain in pending_errors:
927             # DEBUG: print("DEBUG: domain has pending error being updated:", domain)
928             update_last_error(domain, pending_errors[domain])
929             remove_pending_error(domain)
930
931     except BaseException as e:
932         print(f"ERROR: failed SQL query: domain='{domain}',exception:'{str(e)}'")
933         sys.exit(255)
934     else:
935         # DEBUG: print("DEBUG: Updating nodeinfo for domain:", domain)
936         update_last_nodeinfo(domain)
937
938     # DEBUG: print("DEBUG: EXIT!")
939
940 def send_bot_post(instance: str, blocks: dict):
941     message = instance + " has blocked the following instances:\n\n"
942     truncated = False
943
944     if len(blocks) > 20:
945         truncated = True
946         blocks = blocks[0 : 19]
947
948     for block in blocks:
949         if block["reason"] == None or block["reason"] == '':
950             message = message + block["blocked"] + " with unspecified reason\n"
951         else:
952             if len(block["reason"]) > 420:
953                 block["reason"] = block["reason"][0:419] + "[…]"
954
955             message = message + block["blocked"] + ' for "' + block["reason"].replace("@", "@\u200b") + '"\n'
956
957     if truncated:
958         message = message + "(the list has been truncated to the first 20 entries)"
959
960     botheaders = {**api_headers, **{"Authorization": "Bearer " + config["bot_token"]}}
961
962     req = reqto.post(
963         f"{config['bot_instance']}/api/v1/statuses",
964         data={
965             "status"      : message,
966             "visibility"  : config['bot_visibility'],
967             "content_type": "text/plain"
968         },
969         headers=botheaders,
970         timeout=10
971     ).json()
972
973     return True
974
975 def get_mastodon_blocks(domain: str) -> dict:
976     # DEBUG: print("DEBUG: Fetching mastodon blocks from domain:", domain)
977     blocks = {
978         "Suspended servers": [],
979         "Filtered media"   : [],
980         "Limited servers"  : [],
981         "Silenced servers" : [],
982     }
983
984     try:
985         doc = bs4.BeautifulSoup(
986             reqto.get(f"https://{domain}/about/more", headers=headers, timeout=(config["connection_timeout"], config["read_timeout"])).text,
987             "html.parser",
988         )
989     except BaseException as e:
990         print("ERROR: Cannot fetch from domain:", domain, e)
991         update_last_error(domain, e)
992         return {}
993
994     for header in doc.find_all("h3"):
995         header_text = tidyup(header.text)
996
997         if header_text in language_mapping:
998             # DEBUG: print(f"DEBUG: header_text='{header_text}'")
999             header_text = language_mapping[header_text]
1000
1001         if header_text in blocks or header_text.lower() in blocks:
1002             # replaced find_next_siblings with find_all_next to account for instances that e.g. hide lists in dropdown menu
1003             for line in header.find_all_next("table")[0].find_all("tr")[1:]:
1004                 blocks[header_text].append(
1005                     {
1006                         "domain": tidyup(line.find("span").text),
1007                         "hash"  : tidyup(line.find("span")["title"][9:]),
1008                         "reason": tidyup(line.find_all("td")[1].text),
1009                     }
1010                 )
1011
1012     # DEBUG: print("DEBUG: Returning blocks for domain:", domain)
1013     return {
1014         "reject"        : blocks["Suspended servers"],
1015         "media_removal" : blocks["Filtered media"],
1016         "followers_only": blocks["Limited servers"] + blocks["Silenced servers"],
1017     }
1018
1019 def get_friendica_blocks(domain: str) -> dict:
1020     # DEBUG: print("DEBUG: Fetching friendica blocks from domain:", domain)
1021     blocks = []
1022
1023     try:
1024         doc = bs4.BeautifulSoup(
1025             reqto.get(f"https://{domain}/friendica", headers=headers, timeout=(config["connection_timeout"], config["read_timeout"])).text,
1026             "html.parser",
1027         )
1028     except BaseException as e:
1029         print("WARNING: Failed to fetch /friendica from domain:", domain, e)
1030         update_last_error(domain, e)
1031         return {}
1032
1033     blocklist = doc.find(id="about_blocklist")
1034
1035     # Prevents exceptions:
1036     if blocklist is None:
1037         # DEBUG: print("DEBUG:Instance has no block list:", domain)
1038         return {}
1039
1040     for line in blocklist.find("table").find_all("tr")[1:]:
1041         # DEBUG: print(f"DEBUG: line='{line}'")
1042         blocks.append({
1043             "domain": tidyup(line.find_all("td")[0].text),
1044             "reason": tidyup(line.find_all("td")[1].text)
1045         })
1046
1047     # DEBUG: print("DEBUG: Returning blocks() for domain:", domain, len(blocks))
1048     return {
1049         "reject": blocks
1050     }
1051
1052 def get_misskey_blocks(domain: str) -> dict:
1053     # DEBUG: print("DEBUG: Fetching misskey blocks from domain:", domain)
1054     blocks = {
1055         "suspended": [],
1056         "blocked"  : []
1057     }
1058
1059     offset = 0
1060     step = config["misskey_offset"]
1061     while True:
1062         # iterating through all "suspended" (follow-only in its terminology)
1063         # instances page-by-page, since that troonware doesn't support
1064         # sending them all at once
1065         try:
1066             # DEBUG: print(f"DEBUG: Fetching offset='{offset}' from '{domain}' ...")
1067             if offset == 0:
1068                 # DEBUG: print("DEBUG: Sending JSON API request to domain,step,offset:", domain, step, offset)
1069                 fetched = post_json_api(domain, "/api/federation/instances", json.dumps({
1070                     "sort"     : "+pubAt",
1071                     "host"     : None,
1072                     "suspended": True,
1073                     "limit"    : step
1074                 }), {"Origin": domain})
1075             else:
1076                 # DEBUG: print("DEBUG: Sending JSON API request to domain,step,offset:", domain, step, offset)
1077                 fetched = post_json_api(domain, "/api/federation/instances", json.dumps({
1078                     "sort"     : "+pubAt",
1079                     "host"     : None,
1080                     "suspended": True,
1081                     "limit"    : step,
1082                     "offset"   : offset - 1
1083                 }), {"Origin": domain})
1084
1085             # DEBUG: print("DEBUG: fetched():", len(fetched))
1086             if len(fetched) == 0:
1087                 # DEBUG: print("DEBUG: Returned zero bytes, exiting loop:", domain)
1088                 break
1089             elif len(fetched) != config["misskey_offset"]:
1090                 # DEBUG: print(f"DEBUG: Fetched '{len(fetched)}' row(s) but expected: '{config['misskey_offset']}'")
1091                 offset = offset + (config["misskey_offset"] - len(fetched))
1092             else:
1093                 # DEBUG: print("DEBUG: Raising offset by step:", step)
1094                 offset = offset + step
1095
1096             for instance in fetched:
1097                 # just in case
1098                 if instance["isSuspended"]:
1099                     blocks["suspended"].append(
1100                         {
1101                             "domain": tidyup(instance["host"]),
1102                             # no reason field, nothing
1103                             "reason": None
1104                         }
1105                     )
1106
1107         except BaseException as e:
1108             print("WARNING: Caught error, exiting loop:", domain, e)
1109             update_last_error(domain, e)
1110             offset = 0
1111             break
1112
1113     while True:
1114         # same shit, different asshole ("blocked" aka full suspend)
1115         try:
1116             if offset == 0:
1117                 # DEBUG: print("DEBUG: Sending JSON API request to domain,step,offset:", domain, step, offset)
1118                 fetched = post_json_api(domain,"/api/federation/instances", json.dumps({
1119                     "sort"   : "+pubAt",
1120                     "host"   : None,
1121                     "blocked": True,
1122                     "limit"  : step
1123                 }), {"Origin": domain})
1124             else:
1125                 # DEBUG: print("DEBUG: Sending JSON API request to domain,step,offset:", domain, step, offset)
1126                 fetched = post_json_api(domain,"/api/federation/instances", json.dumps({
1127                     "sort"   : "+pubAt",
1128                     "host"   : None,
1129                     "blocked": True,
1130                     "limit"  : step,
1131                     "offset" : offset-1
1132                 }), {"Origin": domain})
1133
1134             # DEBUG: print("DEBUG: fetched():", len(fetched))
1135             if len(fetched) == 0:
1136                 # DEBUG: print("DEBUG: Returned zero bytes, exiting loop:", domain)
1137                 break
1138             elif len(fetched) != config["misskey_offset"]:
1139                 # DEBUG: print(f"DEBUG: Fetched '{len(fetched)}' row(s) but expected: '{config['misskey_offset']}'")
1140                 offset = offset + (config["misskey_offset"] - len(fetched))
1141             else:
1142                 # DEBUG: print("DEBUG: Raising offset by step:", step)
1143                 offset = offset + step
1144
1145             for instance in fetched:
1146                 if instance["isBlocked"]:
1147                     blocks["blocked"].append({
1148                         "domain": tidyup(instance["host"]),
1149                         "reason": None
1150                     })
1151
1152         except BaseException as e:
1153             print("ERROR: Exception during POST:", domain, e)
1154             update_last_error(domain, e)
1155             offset = 0
1156             break
1157
1158     # DEBUG: print("DEBUG: Returning for domain,blocked(),suspended():", domain, len(blocks["blocked"]), len(blocks["suspended"]))
1159     return {
1160         "reject"        : blocks["blocked"],
1161         "followers_only": blocks["suspended"]
1162     }
1163
1164 def tidyup(string: str) -> str:
1165     # some retards put their blocks in variable case
1166     string = string.lower().strip()
1167
1168     # other retards put the port
1169     string = re.sub("\:\d+$", "", string)
1170
1171     # bigger retards put the schema in their blocklist, sometimes even without slashes
1172     string = re.sub("^https?\:(\/*)", "", string)
1173
1174     # and trailing slash
1175     string = re.sub("\/$", "", string)
1176
1177     # and the @
1178     string = re.sub("^\@", "", string)
1179
1180     # the biggest retards of them all try to block individual users
1181     string = re.sub("(.+)\@", "", string)
1182
1183     return string