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.
- ao3statscraper/__init__.py +15 -0
- ao3statscraper/__version__.py +1 -0
- ao3statscraper/ao3requester.py +70 -0
- ao3statscraper/configuration.py +228 -0
- ao3statscraper/plotting.py +263 -0
- ao3statscraper/pw.py +149 -0
- ao3statscraper/scrape.py +249 -0
- ao3statscraper/statsdata.py +412 -0
- ao3statscraper/utils.py +61 -0
- ao3statscraper-0.0.1.dist-info/LICENSE +674 -0
- ao3statscraper-0.0.1.dist-info/METADATA +737 -0
- ao3statscraper-0.0.1.dist-info/RECORD +15 -0
- ao3statscraper-0.0.1.dist-info/WHEEL +5 -0
- ao3statscraper-0.0.1.dist-info/entry_points.txt +6 -0
- ao3statscraper-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -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
|