Module: analyse

The analyse module reads all the fetch files and then performs the analysis. It figures out what posts rank how high and which are local versus remote. It writes out the analysis to a JSON file.

At the moment, the ranking methodology is not great. I don't know a better way, but I'm open to suggestions. There are basically 2 rules:

The method

  1. Calculate the top_n posts with the most boosts.
  2. Take those posts out of consideration. Look at the remaining posts and find the top_n posts with the most favourites.
  3. Take those posts out of consideration. Look at the remaning posts and find the top_n posts with the most replies.

Code Reference

Module for analyzing toots for a hashtag. Reads a JSON dump of toots presumably written by the fetch() function.

analyse(config)

Does a bunch of analysis over the toots. Returns a dict with the results suitable for sending to post(). The whole process is described in more detail in the methodology documentation.

Config Parameters Used

Option Description
mastoscore:hashtag Hashtag to analyze
mastoscore:top_n How many top toots to report
mastoscore:timezone What timezone to convert times to
mastoscore:event_start Start time of the event
post:tag_users Whether we tag users with an @ or not

Parameters:

Name Type Description Default
config ConfigParser

A ConfigParser object from the config module

required

Returns:

Type Description
dict[str, Any]

Dict that includes a few elements: - preamble: A bit of information about the analysis. Hashtag and when it was generated. - num_toots: A few lines of text that describe the analysis: total number of toots, servers, participants, etc. - most_toots: A line about the person that posted the most toots. - max_boosts, max_faves, and max_replies: pandas DataFrames that contain the top_n toots in each of these categories.

Source code in mastoscore/analyse.py
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
def analyse(config: ConfigParser) -> dict[str, Any]:
    """
    Does a bunch of analysis over the toots. Returns a dict with the results suitable for
    sending to [post()](module-post.md). The whole process is described in more detail in
    the [methodology documentation](../methodology.md).

    ## Config Parameters Used

    | Option | Description |
    | ------- | ------- |
    | `mastoscore:hashtag` | Hashtag to analyze |
    | `mastoscore:top_n` | How many top toots to report |
    | `mastoscore:timezone` | What timezone to convert times to |
    | `mastoscore:event_start` | Start time of the event |
    | `post:tag_users` | Whether we tag users with an @ or not |

    Args:
        config: A ConfigParser object from the [config](module-config.md) module

    Returns:
        Dict that includes a few elements:
            - `preamble`: A bit of information about the analysis. Hashtag and when it was generated.
            - `num_toots`: A few lines of text that describe the analysis: total number of toots, servers, participants, etc.
            - `most_toots`: A line about the person that posted the most toots.
            - `max_boosts`, `max_faves`, and `max_replies`: pandas DataFrames that contain the `top_n` toots in each of these categories.

    """

    hashtag = config.get("mastoscore", "hashtag")
    top_n = config.getint("mastoscore", "top_n")
    timezone = config.get("mastoscore", "timezone")
    tag_users = config.getboolean("post", "tag_users")
    logger = get_logger(config, __name__)

    df = get_toots_df(config)
    if len(df) <= 0:
        return { "a": []  }
    logger.debug(f"DataFrame memory usage:\n{df.memory_usage(deep=True)}")
    logger.debug(f"Total: {df.memory_usage(deep=True).sum() / 1024**2:.2f} MB")

    analysis = {}
    # some old data files don't have data for fields we expect
    df = df.replace(nan, None)
    # top poster
    most_toots_id = df["userid"].value_counts().idxmax()
    most_toots_name = df.loc[df["userid"] == most_toots_id][:1][
        "account.display_name"
    ].values[0]
    most_toots_count = len(df.loc[df["userid"] == most_toots_id])

    # Some overall statistics
    num_servers = df["server"].nunique()
    max_server = df["server"].value_counts().idxmax()
    max_server_toots = len(df.loc[df["server"] == max_server])

    # do the max_boosts stuff last because it is destructive. I remove selected toots
    # from the dataframe so that they can't appear twice. i.e., if you're the most
    # boosted toot, you're taken out of the running for most favourites and most replies,
    # even if you DO have the most favourites and most replies.
    maxdf = df.copy(deep=True)
    max_boosts = maxdf.sort_values(
        by=["reblogs_count", "favourites_count", "replies_count"], ascending=False
    ).head(top_n)

    # drop from df all the toots that are in the max_boosts df
    maxdf.drop(maxdf[maxdf["uri"].isin(max_boosts["uri"])].index, inplace=True)

    max_faves = maxdf.sort_values(
        by=["favourites_count", "reblogs_count", "replies_count"], ascending=False
    ).head(top_n)

    # drop from df all the toots that are in the max_faves df
    maxdf.drop(maxdf[maxdf["uri"].isin(max_faves["uri"])].index, inplace=True)

    # Sort by external replies count if available, otherwise fall back to replies_count
    if "external_replies_count" in maxdf.columns:
        max_replies = maxdf.sort_values(
            by=["external_replies_count", "reblogs_count", "favourites_count"],
            ascending=False,
        ).head(top_n)
    else:
        max_replies = maxdf.sort_values(
            by=["replies_count", "reblogs_count", "favourites_count"], ascending=False
        ).head(top_n)

    # Prepare the analysis
    # convert config strings into datetime structs
    tag = "@" if tag_users else ""
    timezone = pytimezone(timezone)
    start_dt = get_event_start(config)
    end_dt = get_event_end(config)
    event_start_str = start_dt.strftime("%a %e %b %Y %H:%M %Z")
    end_time = end_dt.strftime("%a %e %b %Y %H:%M %Z")
    right_now = datetime.datetime.now(tz=timezone).strftime("%a %e %b %Y %H:%M %Z")
    analysis["preamble"] = f"<p>Summary of #{hashtag} generated at {right_now}.</p>"
    analysis["num_toots"] = (
        f"We looked at {len(df)} toots posted between {event_start_str} and " +
        f"{end_time} by {df['userid'].nunique()} " +
        f"different participants across {num_servers} different servers. {max_server} " +
        f"contributed the most toots at {max_server_toots}"
    )
    analysis["most_toots"] = (
        f"Most toots were from '{most_toots_name}' ({tag}{most_toots_id}) who posted {most_toots_count}"
    )
    analysis["max_boosts"] = max_boosts.to_dict(
        orient="records",
    )
    analysis["max_faves"] = max_faves.to_dict(orient="records")
    analysis["max_replies"] = max_replies.to_dict(orient="records")
    analysis["unique_ids"] = df["userid"].nunique()
    analysis["top_n"] = top_n
    analysis["hashtag"] = hashtag
    analysis["top_n"] = top_n
    analysis["generated"] = right_now
    analysis["event_start"] = event_start_str
    analysis["gross_toots"] = len(df)
    analysis["event_end"] = end_time
    analysis["num_servers"] = num_servers
    analysis["max_server"] = {}
    analysis["max_server"]["name"] = max_server
    analysis["max_server"]["num"] = max_server_toots
    analysis["most_posts"] = {}
    analysis["most_posts"]["name"] = most_toots_name
    analysis["most_posts"]["id"] = most_toots_id
    analysis["most_posts"]["count"] = most_toots_count

    if not write_json(config, "analysis", analysis):
        logger.warning("write analysis json failed")

    return analysis

get_toots_df(config)

Opens the journal files from a hierarchical directory structure, parses the toots, and does a bunch of analysis over the toots. Returns a df with the results. This is its own method because the graph() modules call it, as does analyse().

Parameters:

Name Type Description Default
config ConfigParser

A ConfigParser object from the config module

required

Config Parameters Used

  • mastoscore:journaldir: Base directory to read JSON files from
  • mastoscore:journalfile: Template for files to read
  • mastoscore:event_year: Year of the event (YYYY)
  • mastoscore:event_month: Month of the event (MM)
  • mastoscore:event_day: Day of the event (DD)

Returns:

Pandas DataFrame with all the toots pulled in and converted to normalised types.

Source code in mastoscore/analyse.py
 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
222
223
224
225
226
227
228
229
230
231
232
def get_toots_df(config:ConfigParser) -> pd.DataFrame:
    """
    Opens the journal files from a hierarchical directory structure, parses the toots,
    and does a bunch of analysis over the toots. Returns a df with the results.
    This is its own method because the graph() modules call it, as does analyse().

    Args:
      config: A ConfigParser object from the [config](module-config.md) module

    ## Config Parameters Used
    - `mastoscore:journaldir`: Base directory to read JSON files from
    - `mastoscore:journalfile`: Template for files to read
    - `mastoscore:event_year`: Year of the event (YYYY)
    - `mastoscore:event_month`: Month of the event (MM)
    - `mastoscore:event_day`: Day of the event (DD)

    Returns:

    Pandas DataFrame with all the toots pulled in and converted to normalised types.
    """

    journaldir = config.get("mastoscore", "journaldir")
    journalfile = config.get("mastoscore", "journalfile")
    logger = get_logger(config, __name__)

    # Get date components from config
    try:
        year = config.get("mastoscore", "event_year")
        month = config.get("mastoscore", "event_month")
        day = config.get("mastoscore", "event_day")
        date_path = os.path.join(year, month, day)
        logger.info(f"Looking for journal files in date path: {date_path}")
    except (FileNotFoundError, PermissionError, IsADirectoryError) as e:
        # Handle or wrap file access errors
        logger.error(f"get_toots_df() file error getting config: {e}")
        raise
    except UnicodeDecodeError as e:
        # File isn't valid text
        logger.error(f"get_toots_df() decoding error getting config: {e}")
        raise
    except OSError as e:
        # Catch-all for other I/O issues (disk full, broken symlink, etc.)
        logger.error(f"get_toots_df() Error reading config: {e}")
        raise

    df = pd.DataFrame([])
    # journal is now a template. Read all the matching files into a big data frame
    max_toots = 0
    max_toots_file = "none"
    nfiles = 0

    # Build the path to search for journal files
    if date_path:
        search_path = os.path.join(journaldir, date_path)
        p = Path(search_path).resolve()
        if not p.exists():
            logger.error(f"Directory {search_path} does not exist")
            return df
        pattern = f"{journalfile}-*.json"
    else:
        p = Path(journaldir).resolve()
        # Look for files in the hierarchical structure
        pattern = f"**/{journalfile}-*.json"

    logger.info(f"Searching for files matching pattern: {pattern} in {p}")
    filelist = list(p.glob(pattern))

    if not filelist:
        return df

    for jfile in filelist:
        try:
            logger.debug(f"Attempting to read {jfile}")
            newdf = pd.read_json(jfile)
        except (FileNotFoundError, PermissionError, IsADirectoryError) as e:
            # Handle or wrap file access errors
            logger.error(f"get_toots_df() file error getting config: {e}")
            raise
        except (UnicodeDecodeError, JSONDecodeError) as e:
            # File isn't valid text
            logger.error(f"get_toots_df() decoding error getting config: {e}")
            raise
        except OSError as e:
            # Catch-all for other I/O issues (disk full, broken symlink, etc.)
            logger.error(f"get_toots_df() Error reading config: {e}")
            raise

        if len(newdf) > max_toots:
            max_toots_file = jfile
            max_toots = len(newdf)
        nfiles = nfiles + 1
        df = pd.concat([df, newdf])
        logger.debug(f"Loaded {len(newdf)} toots from {jfile.name}")
        del newdf

    logger.info(f"Loaded {len(df)} total toots from {nfiles} JSON files")
    logger.info(f"Biggest was {max_toots} toots from {max_toots_file}")
    assert not df.empty
    # Now exclude toots that are too old or too new
    earliest, latest = get_fetch_window(config)
    df = df.loc[df["created_at"] >= earliest]
    df = df.loc[df["created_at"] <= latest]
    assert not df.empty
    # gather up the set we want to work on
    # 1. local toots
    # 2. remote toots where we didn't get a local version
    local_toots = df.loc[df["local"] == True]
    sources = local_toots["source"].unique()
    non_local_toots = df.loc[df["local"] == False]
    # drop all toots from servers we successfully contacted
    non_local_toots = non_local_toots.loc[~non_local_toots["server"].isin(sources)]
    # There will be more than one copy of non-local toots.
    # Iterate over each uri, find the copy of it that has the highest numbers
    # and keep it, deleting the others
    non_local_keepers = pd.DataFrame([])
    for uri in non_local_toots["uri"].unique():
        minidf = non_local_toots[non_local_toots["uri"] == uri]
        # logger.debug(f"{len(minidf)} toots for {uri}")
        minidf = minidf.sort_values(
            by=["reblogs_count", "favourites_count", "replies_count"], ascending=False
        ).head(1)
        non_local_keepers = pd.concat([non_local_keepers, minidf])
    logger.info(
        f"{len(local_toots)} local toots and {len(non_local_keepers)} non-local toots"
    )
    df = pd.concat([local_toots, non_local_keepers])

    # Quick check to make sure we don't have duplicates. Number of rows in the final
    # DataFrame and the number of unique URIs should be the same. If they're not, we
    # have duplicates somewhere.
    num_unique = len(df["uri"].unique())
    num_rows = len(df)
    if num_unique != num_rows:
        logger.error(
            f"We have {num_rows} toots, but {num_unique} URIs. Likely duplicates!"
        )
    else:
        logger.debug(
            f"Number of unique URIs ({num_unique}) == Number of rows ({num_rows}). All good."
        )

    # Add synthetic columns for self-reply detection
    # This identifies posts where the reply author is the same as the original author
    cols = df.columns.to_list()
    if "in_reply_to_id" in cols and "id" in cols:
        logger.debug("Computing self-reply counts")

        # Cast both id columns to string so merges don't fail on object vs int64
        df["id"] = df["id"].astype(str)
        df["in_reply_to_id"] = df["in_reply_to_id"].apply(lambda x: str(x) if pd.notna(x) else None)

        # Create a mapping of post IDs to their authors (userid)
        id_to_author = df[["id", "userid"]].drop_duplicates()

        # Identify rows that are replies (in_reply_to_id is not null)
        df["is_reply"] = df["in_reply_to_id"].notna()

        # For each reply, find the original post's author via merge
        df_with_original_author = df.merge(
            id_to_author,
            left_on="in_reply_to_id",
            right_on="id",
            how="left",
            suffixes=("", "_original"),
        )

        # Identify self-replies: where reply author == original post author
        df_with_original_author["is_self_reply"] = (
            df_with_original_author["userid"] == df_with_original_author["userid_original"]
        ) & df_with_original_author["is_reply"]

        # Count self-replies for each post (group by the post being replied to)
        self_reply_counts = (
            df_with_original_author[df_with_original_author["is_self_reply"]]
            .groupby("in_reply_to_id")
            .size()
            .reset_index(name="self_reply_count")
        )

        # Merge self-reply counts back to main dataframe
        df = df.merge(
            self_reply_counts,
            left_on="id",
            right_on="in_reply_to_id",
            how="left",
        )

        # Clean up duplicate in_reply_to_id columns from the merge
        if "in_reply_to_id_y" in df.columns:
            df = df.drop(columns=["in_reply_to_id_y"])
        if "in_reply_to_id_x" in df.columns:
            df = df.rename(columns={"in_reply_to_id_x": "in_reply_to_id"})

        # Fill NaN values with 0 (posts with no self-replies)
        df["self_reply_count"] = df["self_reply_count"].fillna(0)
        df = df.replace(nan, None)

        # Calculate external replies (total replies minus self-replies)
        df["external_replies_count"] = (
            df["replies_count"] - df["self_reply_count"]
        ).astype(int)

        logger.debug("Self-reply computation complete")
    else:
        logger.debug("in_reply_to_id and/or id columns not found, skipping self-reply computation")

    return df

toots2df(toots, api_base_url)

Take in a list of toots from a tooter object, turn it into a pandas dataframe with a bunch of data normalized.

Parameters:

Name Type Description Default
toots list[dict[str, Any]]

list. A list of toots in the same format as returned by the search_hashtag() API

required
api_base_url str

string. Expected to include protocol, like https://server.example.com.

required

Returns:

Type Description
DataFrame

A Pandas DataFrame that contains all the toots normalised. Normalisation includes:

  • Converting date fields like created_at to timezone-aware datetime objects
  • Converting integer fields like reblogs_count to integers
  • Adding some columns (see below)
  • Discarding all but a few columns. So many different systems return different columns, and I'm only using a few of them. So I just discard everything else. This cuts down on storage and processing time.

Synthetic columns added:

  • server: The server part of api_base_url: server.example.com if the api_base_url is https://server.example.com
  • userid: The user's name in person@server.example.com format. Note it does not have the leading @ because tagging people is optional.
  • local: Boolean that is True if the toot comes from the api_base_url server. False otherwise.
  • source: The server part of the server who owns the toot. I might be talking to server.example.com, but they've sent me a copy of a toot from other.example.social.
Source code in mastoscore/analyse.py
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
def toots2df(toots: list[dict[str, Any]], api_base_url: str) -> pd.DataFrame:
    """
    Take in a list of toots from a tooter object, turn it into a
    pandas dataframe with a bunch of data normalized.

    Args:
      toots: list. A list of toots in the same format as returned by the search_hashtag() API
      api_base_url: string. Expected to include protocol, like `https://server.example.com`.

    Returns:
        A Pandas DataFrame that contains all the toots normalised. Normalisation includes:

            - Converting date fields like `created_at` to timezone-aware `datetime` objects
            - Converting integer fields like `reblogs_count` to integers
            - Adding some columns (see below)
            - Discarding all but a few columns. So many different systems return different columns, and I'm only
                using a few of them. So I just discard everything else. This cuts down on storage and processing time.

    # Synthetic columns added:
    - server: The server part of `api_base_url`: `server.example.com` if the `api_base_url` is `https://server.example.com`
    - userid: The user's name in `person@server.example.com` format. Note it does not have the leading `@` because tagging people is optional.
    - local: Boolean that is **True** if the toot comes from the `api_base_url` server. **False** otherwise.
    - source: The server part of the server who owns the toot. I might be talking to `server.example.com`, but they've sent me a copy of a toot from `other.example.social`.
    """

    df = pd.json_normalize(toots)
    df["source"] = api_base_url.split("/")[2]
    df["local"] = [bool(i.startswith(api_base_url)) for i in df["uri"]]
    # make a new "server" column off of uris
    df["server"] = [n.split("/")[2] for n in df["uri"]]
    df["userid"] = df["account.username"] + "@" + df["server"]
    df["reblogs_count"] = df["reblogs_count"].fillna(0).astype(int)
    df["replies_count"] = df["replies_count"].fillna(0).astype(int)
    df["favourites_count"] = df["favourites_count"].fillna(0).astype(int)
    df["created_at"] = pd.to_datetime(df["created_at"], utc=True, format="ISO8601")
    # make sure IDs are treated as strings. They might not be integers.
    # Keep None for columns that are na
    df["id"] = df["id"].apply(lambda x: str(x) if pd.notna(x) else None)
    df["in_reply_to_id"] = df["in_reply_to_id"].apply(lambda x: str(x) if pd.notna(x) else None)

    # Define the columns to keep, all others will be deleted
    desired_columns = {
        "account.display_name",
        "account.indexable",
        "account.url",
        "content",
        "created_at",
        "external_replies_count",
        "favourites_count",
        "id",
        "in_reply_to_id",
        "local",
        "self_reply_count",
        "reblogs_count",
        "replies_count",
        "server",
        "source",
        "uri",
        "url",
        "userid",
    }
    # Get the intersection of desired columns and actual columns
    columns_to_keep = list(desired_columns.intersection(df.columns))

    # Create new data frame with only desired columns, implicitly discarding all others
    small_df = df[columns_to_keep]

    return small_df