-
Notifications
You must be signed in to change notification settings - Fork 12
Expand file tree
/
Copy pathblockbot.py
More file actions
executable file
·221 lines (190 loc) · 8.03 KB
/
Copy pathblockbot.py
File metadata and controls
executable file
·221 lines (190 loc) · 8.03 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
#! /usr/bin/env python3
import argparse
import datetime
import random
import re
import string
import time
from pprint import pprint
from typing import Any, Iterable, Self
import httpx
from ws.client import API
from ws.config import ConfigurableObject
from ws.interactive import ask_yesno, require_login
from ws.utils import RateLimited
def is_blocked(api: API, user: str) -> bool:
response = api.call_api(action="query", list="allusers", aufrom=user, auto=user, auprop="blockinfo")
return "blockid" in response["allusers"][0]
def block_user(api: API, user: str) -> None:
@RateLimited(1, 3)
def block() -> None:
print(f"Blocking user '{user}' ...")
api.call_with_csrftoken(
action="block",
user=user,
reason="spamming",
nocreate="1",
autoblock="1",
noemail="1",
)
if is_blocked(api, user):
print(f"User '{user}' is already blocked.")
return
block()
@RateLimited(1, 1)
def delete_page(api: API, title: str, pageid: int) -> None:
print(f"Deleting page '{title}' (pageid={pageid})")
api.call_with_csrftoken(action="delete", pageid=pageid, reason="spam")
class Blockbot(ConfigurableObject):
def __init__(
self,
api: API,
spam_phrases: list[str],
spam_occurrences_threshold: int = 5,
interactive: bool = True,
):
self.api = api
self.interactive = interactive
self.spam_occurrences_threshold = spam_occurrences_threshold
# TODO: assert len(spam_phrases) > 0
self.spam_phrases = spam_phrases
for phrase in spam_phrases[:]:
self.spam_phrases.append(phrase.replace(" ", ""))
# TODO: make configurable
self.timeout_func = lambda: random.uniform(180, 360)
self.punctuation_regex = re.compile("[%s]" % re.escape(string.punctuation))
@staticmethod
def set_argparser(argparser: argparse.ArgumentParser) -> None:
# first try to set options for objects we depend on
present_groups = [group.title for group in argparser._action_groups]
if "Connection parameters" not in present_groups:
API.set_argparser(argparser)
group = argparser.add_argument_group(title="script parameters")
group.add_argument(
"--interactive",
default=True,
metavar="BOOL",
type=ws.config.argtype_bool,
help="Enables interactive mode (default: %(default)s)",
)
group.add_argument(
"--spam-phrases",
action="append",
required=True,
metavar="STR",
help="A phrase considered as spam (this option can be specified multiple times).",
)
group.add_argument(
"--spam-occurrences-threshold",
type=int,
default=5,
help="Minimal number of phrases occurring on a page that triggers the spam filter.",
)
@classmethod
def from_argparser(cls: type[Self], args: argparse.Namespace, api: API | None = None) -> Self:
if api is None:
api = API.from_argparser(args)
return cls(api, args.spam_phrases, args.spam_occurrences_threshold, args.interactive)
def is_spam(self, title: str, text: str) -> bool:
title = self.punctuation_regex.sub("", title)
text = self.punctuation_regex.sub("", text)
for phrase in self.spam_phrases:
if phrase in title.lower():
return True
if text.lower().count(phrase) > self.spam_occurrences_threshold:
return True
return False
def filter_pages(self, pages: Iterable[dict[str, Any]]) -> None:
for page in pages:
if "revisions" in page:
# skip truncated results (due to PHP's 8MiB limit)
if len(page["revisions"]) == 0:
continue
rev = page["revisions"][0]
else:
# empty prop in generator due to continuation
continue
content = rev["*"]
if self.is_spam(page["title"], content):
print("Detected spam:")
pprint(
{
"title": page["title"],
"content": content,
"timestamp": rev["timestamp"],
}
)
if self.interactive and ask_yesno("Proceed with user account blocking and deletion?") is False:
continue
# block the account
block_user(self.api, rev["user"])
if rev["parentid"] == 0:
# first revision, delete whole page
delete_page(self.api, page["title"], page["pageid"])
else:
# TODO: if all revisions of the page are spam, delete the whole page
print("Warning: deletion of individual revisions is not implemented!")
else:
print(f"Page '{page['title']}' is not a spam.")
def main_loop(self) -> bool:
require_login(self.api)
if "block" not in self.api.user.rights:
print("Your account does not have the 'block' right.")
return False
if "delete" not in self.api.user.rights:
print("Your account does not have the 'delete' right.")
return False
start = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=1)
timeout: int | float
while True:
try:
start2 = datetime.datetime.now(datetime.UTC)
pages = self.api.generator(
generator="recentchanges",
grcstart=start,
grcdir="newer",
grcshow="unpatrolled",
grclimit="max",
prop="revisions",
rvprop="ids|timestamp|user|comment|content",
)
self.filter_pages(pages)
except (httpx.NetworkError, httpx.TimeoutException) as e:
# query failed, set short timeout and retry from the previous timestamp
timeout = 30
# FIXME: better representation of the exception as str()
print(f"Caught {e!r} exception, sleeping {timeout} seconds before retrying...")
else:
# query succeeded, shift start timestamp and set timeout
start = start2
timeout = self.timeout_func()
print(f"{start} Sleeping for {timeout:.3g} seconds...")
try:
time.sleep(timeout)
except KeyboardInterrupt:
try:
# short timeout to allow interruption of the main loop
time.sleep(0.5)
except KeyboardInterrupt as e:
raise e from None
# # go through recently deleted revisions, detect spam and block the remaining users
# logs = api.list(list="logevents", letype="delete", lelimit="max", ledir="newer", lestart=start)
# titles = [log["title"] for log in logs if log["comment"].lower().startswith("spam")]
# for chunk in list_chunks(titles, 50):
# result = api.call_api(action="query", titles="|".join(chunk), prop="deletedrevisions", drvprop="ids|timestamp|user|comment|content", rawcontinue="")
# pages = result["pages"].values()
# for page in pages:
# if "deletedrevisions" not in page:
# # empty prop in generator due to continuation
# continue
# rev = page["deletedrevisions"][0]
# if not is_blocked(api, rev["user"]):
# ans = ask_yesno("Block user '{}' who created page '{}'?")
# if ans is True:
# block_user(api, rev["user"])
return True
if __name__ == "__main__":
import sys
import ws.config
blockbot = ws.config.object_from_argparser(Blockbot, description="blockbot")
sys.exit(not blockbot.main_loop())