ao3statscraper 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env python3
2
+
3
+ from .__version__ import __version__
4
+
5
+ from .configuration import Config
6
+ from .scrape import UserSession
7
+ from .statsdata import StatsData, WorkStatsData, TotStatsData
8
+ from .utils import clear_terminal
9
+ from .plotting import plot_total_stats, plot_work_stats
10
+ from .pw import store_secrets, read_secrets
11
+
12
+ from .scripts.ao3plot import ao3plot
13
+ from .scripts.ao3scrape import ao3scrape
14
+ from .scripts.ao3_purge_stats_data import ao3_purge_stats_data
15
+ from .scripts.ao3_hits_to_kudos import ao3_hits_to_kudos
@@ -0,0 +1 @@
1
+ __version__ = "0.0.1"
@@ -0,0 +1,70 @@
1
+ import requests
2
+
3
+
4
+ class Requester:
5
+ """Requester object"""
6
+
7
+ def __init__(self, rqtw=-1, timew=60):
8
+ """Limits the request rate to prevent HTTP 429 (rate limiting) responses.
9
+ 12 request per minute seems to be the limit.
10
+
11
+ Args:
12
+ rqm (int, optional): Maximum requests per time window (-1 -> no limit). Defaults to -1.
13
+ timew (int, optional): Time window (seconds). Defaults to 60.
14
+ """
15
+
16
+ self._requests = []
17
+ self._rqtw = rqtw
18
+ self._timew = timew
19
+ self.total = 0
20
+
21
+ def setRQTW(self, value):
22
+ self._rqtw = value
23
+
24
+ def setTimeW(self, value):
25
+ self._timew = value
26
+
27
+ def request(self, *args, **kwargs):
28
+ """Requests a web page once enough time has passed since the last request
29
+
30
+ Mostly lifted from https://github.com/ArmindoFlores/ao3_api
31
+
32
+ Args:
33
+ session(requests.Session, optional): Session object to request with
34
+
35
+ Returns:
36
+ requests.Response: Response object
37
+ """
38
+
39
+ # We've made a bunch of requests, time to rate limit?
40
+ if self._rqtw != -1:
41
+ if len(self._requests) >= self._rqtw:
42
+ t = time.time()
43
+ # Reduce list to only requests made within the current time window
44
+ while len(self._requests):
45
+ if t - self._requests[0] >= self._timew:
46
+ self._requests.pop(0) # Older than window, forget about it
47
+ else:
48
+ break # Inside window, the rest of them must be too
49
+ # Have we used up all available requests within our window?
50
+ if len(self._requests) >= self._rqtw: # Yes
51
+ # Wait until the oldest request exits the window, giving us a slot for the new one
52
+ time.sleep(self._requests[0] + self._timew - t)
53
+ # Now outside window, drop it
54
+ self._requests.pop(0)
55
+
56
+ if self._rqtw != -1:
57
+ self._requests.append(time.time())
58
+ self.total += 1
59
+
60
+ if "session" in kwargs:
61
+ sess = kwargs["session"]
62
+ del kwargs["session"]
63
+ req = sess.request(*args, **kwargs)
64
+ else:
65
+ req = requests.request(*args, **kwargs)
66
+
67
+ return req
68
+
69
+
70
+ requester = Requester()
@@ -0,0 +1,228 @@
1
+ #!/usr/bin/env python3
2
+
3
+
4
+ import os
5
+ import platform
6
+ import yaml
7
+ from getpass import getpass
8
+
9
+ from .utils import clear_terminal
10
+ from .pw import get_credentials, read_secrets, store_secrets
11
+
12
+
13
+ class PlottingConfig(object):
14
+ """
15
+ Configuration for the plotting script.
16
+ """
17
+
18
+ def __init__(self):
19
+ self.prettify = True
20
+ return
21
+
22
+
23
+ class Config(object):
24
+ store_login = None
25
+ username = "Your AO3 Username"
26
+ password = "Don't type your password in plain text"
27
+
28
+ ao3dir = None
29
+ datadir = None
30
+ conffile = None
31
+ pklfile = None
32
+
33
+ plotting = None
34
+
35
+ def __init__(self, reset_conffile=False):
36
+ """
37
+ Initialize configuration.
38
+ If `reset_conffile` is True, then write a new config file from scratch
39
+ and exit.
40
+ """
41
+
42
+ if platform.system() == "Windows":
43
+ raise NotImplementedError("Setup for windows is ToDo")
44
+
45
+ # Get config directory
46
+ userdir = os.path.expanduser("~")
47
+ ao3dir = os.path.join(userdir, ".ao3statscraper")
48
+ self.ao3dir = ao3dir
49
+
50
+ # Make sure it exists.
51
+ try:
52
+ os.mkdir(ao3dir)
53
+ except FileExistsError:
54
+ pass
55
+
56
+ self.plotting = PlottingConfig()
57
+
58
+ # Set full paths for config files.
59
+ conffilename = "ao3statscraper.conf.yml"
60
+ conffile = os.path.join(ao3dir, conffilename)
61
+ self.conffile = conffile
62
+ pklfilename = "ao3statscraper.conf.pkl"
63
+ pklfile = os.path.join(ao3dir, pklfilename)
64
+ self.pklfile = pklfile
65
+
66
+ if not os.path.exists(conffile):
67
+ clear_terminal()
68
+ input(
69
+ "Didn't find a configuration file. We need to set one up. Hit any key to coninue.\n"
70
+ )
71
+ self.setup_config_file()
72
+ else:
73
+ if reset_conffile:
74
+ self.setup_config_file()
75
+ quit()
76
+
77
+ # get contents of config file.
78
+ self._read_config_file()
79
+
80
+ # Make sure everything is set.
81
+ self._check_config()
82
+
83
+ return
84
+
85
+ def _check_config(self):
86
+ """
87
+ Check that no attributes are None. Mainly intended as a dev tool to make
88
+ sure everything's intialized properly.
89
+ """
90
+
91
+ d = self.__dir__()
92
+
93
+ for key in d:
94
+ if key.startswith("__"):
95
+ continue
96
+ if getattr(self, key) is None:
97
+ raise ValueError(f"Attribute {key} not initialized.")
98
+ return
99
+
100
+ def set_credentials(self, username, password):
101
+ """
102
+ Store username and password.
103
+ """
104
+
105
+ self.username = username
106
+ self.password = password
107
+
108
+ return
109
+
110
+ def setup_config_file(self):
111
+ """
112
+ Set up permanent configuration and dump them in the configure file.
113
+ """
114
+
115
+ clear_terminal()
116
+ print("Welcome to the configuration setup for the AO3 Stat Scraper.")
117
+ print(
118
+ f"After this setup, a configuration file will be written in `{self.conffile}`"
119
+ )
120
+ print("You can freely modify it on your own without calling this dialogue.")
121
+ print(
122
+ "Select a directory to store downloaded statistics. Hit Enter to use default."
123
+ )
124
+ datadir = input(f"(default: '{self.ao3dir}')\n")
125
+
126
+ clear_terminal()
127
+ print("Optionally, you can store your AO3 login on your local machine.")
128
+ print("Your username and password will be encrypted and locked with a ")
129
+ print("master password that you'll be asked to set next.")
130
+ print("(If you forget the master password, you'll need to re-run this")
131
+ print("configuration dialogue. You can do that by running `getAO3stats.py -c`)")
132
+ store_login_str = "b"
133
+ a = 0
134
+ while not store_login_str.startswith(("n", "N", "y", "Y")):
135
+ if a > 0:
136
+ print("Invalid input.")
137
+ store_login_str = input(
138
+ "Do you want to store your AO3 login on your local machine? [y/n]\n"
139
+ )
140
+ a += 1
141
+ if a > 10:
142
+ print("Too many attempts. Exiting.")
143
+ quit()
144
+
145
+ store_login = True
146
+ if store_login_str.startswith(("n", "N")):
147
+ store_login = False
148
+
149
+ if store_login:
150
+ # Grab master password.
151
+ clear_terminal()
152
+ master_pwd = getpass(
153
+ "Enter a master password (not your AO3 login password):\n"
154
+ )
155
+ clear_terminal()
156
+ master_pwd2 = getpass("Enter the password again:\n")
157
+
158
+ if master_pwd != master_pwd2:
159
+ print("Error: Passwords do not match.")
160
+ quit()
161
+
162
+ # Grab AO3 credentials.
163
+ username, password = get_credentials()
164
+ # And store them.
165
+ self.set_credentials(username, password)
166
+ store_secrets(master_pwd, username, password, self.pklfile)
167
+
168
+ if datadir == "":
169
+ datadir = self.ao3dir
170
+
171
+ try:
172
+ os.mkdir(datadir)
173
+ except FileExistsError:
174
+ pass
175
+
176
+ # Store the config
177
+ self.datadir = datadir
178
+ self.store_login = store_login
179
+
180
+ # Now write the config file.
181
+ confdict = {}
182
+ global_conf = {
183
+ "data_directory": self.datadir,
184
+ "store_login": self.store_login,
185
+ }
186
+
187
+ confdict = {
188
+ "Globals": global_conf,
189
+ }
190
+
191
+ ymlfp = open(self.conffile, "w")
192
+ yaml.dump(confdict, ymlfp)
193
+ ymlfp.close()
194
+
195
+ return
196
+
197
+ def _read_config_file(self):
198
+ ymlfp = open(self.conffile, "r")
199
+ confdata = yaml.load(ymlfp, Loader=yaml.Loader)
200
+ ymlfp.close()
201
+
202
+ try:
203
+ self.datadir = confdata["Globals"]["data_directory"]
204
+ self.store_login = confdata["Globals"]["store_login"]
205
+ except KeyError:
206
+ print("ERROR: Some fields are missing from your config file.")
207
+ print("It might be out of date.")
208
+ print("Re-generate it using `getAO3stats.py -c`")
209
+ quit(1)
210
+
211
+ return
212
+
213
+ def prep_scrape(self):
214
+ """
215
+ Ensure everything is set for a scrape.
216
+ """
217
+
218
+ if self.store_login:
219
+ master_pwd = getpass(
220
+ "Enter master password (not your AO3 login password):\n"
221
+ )
222
+ username, password = read_secrets(master_pwd, self.pklfile, retry=True)
223
+ else:
224
+ username, password = get_credentials()
225
+
226
+ self.set_credentials(username, password)
227
+
228
+ return
@@ -0,0 +1,263 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import matplotlib
4
+ from matplotlib import pyplot as plt
5
+ import pandas as pd
6
+ from typing import Union
7
+
8
+ from .configuration import Config
9
+ from .statsdata import WorkStatsData, TotStatsData
10
+
11
+
12
+ _plotkwargs = {
13
+ "linewidth": 2,
14
+ "linestyle": "-",
15
+ "alpha": 0.75,
16
+ }
17
+
18
+ _scatterkwargs = {
19
+ "s": 10,
20
+ "alpha": 0.75,
21
+ }
22
+
23
+
24
+ def _set_matplotlib_style():
25
+ styles = [
26
+ # "seaborn-v0_8-darkgrid",
27
+ "ggplot"
28
+ ]
29
+
30
+ for style in styles:
31
+ if style in matplotlib.style.available:
32
+ plt.style.use(style)
33
+ return
34
+
35
+
36
+ def _plot_on_axis(
37
+ ax: matplotlib.axes.Axes,
38
+ abscissa: Union[pd.DataFrame, pd.Series],
39
+ ordinate: Union[pd.DataFrame, pd.Series],
40
+ prettify: bool = True,
41
+ ):
42
+ """
43
+ Plot abscissa data on the x-axis and ordinate data on the y-axis.
44
+ Set x_label and y_label according to data names.
45
+ """
46
+
47
+ ax.plot(abscissa, ordinate, **_plotkwargs)
48
+ ax.scatter(abscissa, ordinate, **_scatterkwargs)
49
+
50
+ # ax.set_xlabel(abscissa.name)
51
+ ax.set_ylabel(ordinate.name)
52
+ if prettify:
53
+ ax.grid(True)
54
+ ax.tick_params(axis="x", labelrotation=45)
55
+
56
+ return
57
+
58
+
59
+ def _plot_all_normalized_on_axis(
60
+ ax: matplotlib.axes.Axes,
61
+ data: pd.DataFrame,
62
+ legendax: matplotlib.axes.Axes = None,
63
+ prettify: bool = True,
64
+ ):
65
+ """
66
+ plot all columns of the dataframe and normalize data by setting the last
67
+ entry as 1.
68
+
69
+ If `legendax` is not none, that axis will be used to display the legend.
70
+ """
71
+
72
+ time = data["Timestamp"]
73
+ handles = []
74
+ labels = []
75
+
76
+ skip_columns = ["Timestamp", "index", "Title", "Fandom", "ID"]
77
+
78
+ for col in data.columns:
79
+ if col in skip_columns:
80
+ continue
81
+
82
+ d = data[col]
83
+ val = d.iat[-1]
84
+ d_norm = d / val
85
+
86
+ (h,) = ax.plot(time, d_norm, label=col, **_plotkwargs)
87
+ ax.scatter(time, d_norm, **_scatterkwargs)
88
+
89
+ handles.append(h)
90
+ labels.append(col)
91
+
92
+ # ax.set_xlabel("Timestamp")
93
+ ax.set_ylabel("All data (normalized)")
94
+
95
+ if legendax is None:
96
+ ax.legend(loc="upper left", ncols=2, frameon=True)
97
+ else:
98
+ legendax.grid(False)
99
+ legendax.axis("off")
100
+ legendax.legend(handles=handles, labels=labels, loc="center left")
101
+
102
+ if prettify:
103
+ ax.tick_params(axis="x", labelrotation=45)
104
+ ax.grid(True)
105
+
106
+ return
107
+
108
+
109
+ def plot_total_stats(tsfiles: list, conf: Config):
110
+ """
111
+ Plots the total user statistics.
112
+
113
+ tsfiles: list
114
+ list of file names of total user statistics to read in
115
+
116
+ conf:
117
+ AO3Stats configuration object
118
+ """
119
+
120
+ prettify = conf.plotting.prettify
121
+
122
+ # Grab data
123
+ ts_data = []
124
+
125
+ for f in tsfiles:
126
+ ts = TotStatsData(conf, source=f)
127
+ ts.data["Timestamp"] = ts.timestamp
128
+
129
+ ts_data.append(ts.data)
130
+
131
+ # Combine it into a single dataframe
132
+ alldata = pd.concat(ts_data)
133
+ alldata.reset_index(inplace=True)
134
+ alldata.sort_values(by="Timestamp")
135
+
136
+ # get shorthands
137
+ time = alldata["Timestamp"]
138
+ user_subscriptions = alldata["User Subscriptions"]
139
+ kudos = alldata["Kudos"]
140
+ comments = alldata["Comment Threads"]
141
+ bookmarks = alldata["Bookmarks"]
142
+ subscriptions = alldata["Subscriptions"]
143
+ words = alldata["Word Count"]
144
+ hits = alldata["Hits"]
145
+
146
+ if prettify:
147
+ _set_matplotlib_style()
148
+
149
+ fig = plt.figure()
150
+ fig.suptitle("Total User Statistics")
151
+
152
+ ax1 = fig.add_subplot(3, 3, 1)
153
+ ax2 = fig.add_subplot(3, 3, 2)
154
+ ax3 = fig.add_subplot(3, 3, 3)
155
+ ax4 = fig.add_subplot(3, 3, 4)
156
+ ax5 = fig.add_subplot(3, 3, 5)
157
+ ax6 = fig.add_subplot(3, 3, 6)
158
+ ax7 = fig.add_subplot(3, 3, 7)
159
+ ax8 = fig.add_subplot(3, 3, 8)
160
+ ax9 = fig.add_subplot(3, 3, 9)
161
+
162
+ _plot_on_axis(ax1, time, user_subscriptions, prettify)
163
+ _plot_on_axis(ax2, time, kudos, prettify)
164
+ _plot_on_axis(ax3, time, comments, prettify)
165
+ _plot_on_axis(ax4, time, bookmarks, prettify)
166
+ _plot_on_axis(ax5, time, subscriptions, prettify)
167
+ _plot_on_axis(ax6, time, words, prettify)
168
+ _plot_on_axis(ax7, time, hits, prettify)
169
+ _plot_all_normalized_on_axis(ax8, alldata, ax9, prettify)
170
+
171
+ if prettify:
172
+ plt.subplots_adjust(
173
+ top=0.952, bottom=0.078, left=0.044, right=0.992, hspace=0.337, wspace=0.169
174
+ )
175
+
176
+ plt.show()
177
+
178
+ return
179
+
180
+
181
+ def plot_work_stats(wsfiles: list, ID: int, conf: Config):
182
+ """
183
+ Plots the statistics of a single work specified via its AO3 ID.
184
+
185
+ wsfiles: list
186
+ list of file names of work statistics to read in
187
+
188
+ ID: int
189
+ AO3 ID of work to plot
190
+
191
+ conf:
192
+ AO3Stats configuration object
193
+ """
194
+
195
+ prettify = conf.plotting.prettify
196
+
197
+ # Grab data
198
+ ws_data = []
199
+
200
+ for f in wsfiles:
201
+ ws = WorkStatsData(conf, source=f)
202
+ ws.data["Timestamp"] = ws.timestamp
203
+
204
+ work = ws.data[ws.data["ID"] == ID]
205
+ if work.empty:
206
+ continue
207
+ else:
208
+ ws_data.append(work)
209
+
210
+ if len(ws_data) == 0:
211
+ raise ValueError(f"Didn't find any data on work with ID {ID}.")
212
+
213
+ # Combine it into a single dataframe
214
+ alldata = pd.concat(ws_data)
215
+ alldata.reset_index(inplace=True)
216
+ alldata.sort_values(by="Timestamp")
217
+
218
+ # get shorthands
219
+ time = alldata["Timestamp"]
220
+ words = alldata["Words"]
221
+ hits = alldata["Hits"]
222
+ kudos = alldata["Kudos"]
223
+ comments = alldata["Comment Threads"]
224
+ bookmarks = alldata["Bookmarks"]
225
+ subscriptions = alldata["Subscriptions"]
226
+
227
+ title = alldata["Title"].at[0]
228
+
229
+ if prettify:
230
+ _set_matplotlib_style()
231
+
232
+ fig1 = plt.figure()
233
+ fig1.suptitle(f"Work Statistics for '{title}'")
234
+
235
+ ax1 = fig1.add_subplot(2, 3, 1)
236
+ ax2 = fig1.add_subplot(2, 3, 2)
237
+ ax3 = fig1.add_subplot(2, 3, 3)
238
+ ax4 = fig1.add_subplot(2, 3, 4)
239
+ ax5 = fig1.add_subplot(2, 3, 5)
240
+ ax6 = fig1.add_subplot(2, 3, 6)
241
+
242
+ _plot_on_axis(ax1, time, words, prettify)
243
+ _plot_on_axis(ax2, time, hits, prettify)
244
+ _plot_on_axis(ax3, time, kudos, prettify)
245
+ _plot_on_axis(ax4, time, comments, prettify)
246
+ _plot_on_axis(ax5, time, bookmarks, prettify)
247
+ _plot_on_axis(ax6, time, subscriptions, prettify)
248
+
249
+ if prettify:
250
+ plt.subplots_adjust(
251
+ top=0.952, bottom=0.078, left=0.044, right=0.992, hspace=0.337, wspace=0.169
252
+ )
253
+
254
+ fig2, axes2 = plt.subplots(1, 2, width_ratios=[3, 1])
255
+ fig2.suptitle(f"Work Statistics for '{title}'")
256
+ ax21 = axes2[0]
257
+ ax22 = axes2[1]
258
+
259
+ _plot_all_normalized_on_axis(ax21, alldata, ax22, prettify)
260
+
261
+ plt.show()
262
+
263
+ return