Complete rewrite with Polars. No more single loop to fetch all data; posts, queued posts, drafts, and activity are all fetched separately. Extra caution when querying API to prevent HTTP 429
This commit is contained in:
27
README.md
27
README.md
@@ -1,20 +1,31 @@
|
||||
# tumblr-stats
|
||||
## Usage
|
||||
```
|
||||
usage: tumblr_stats.py [-h] -b BLOG [-t TAGS [TAGS ...]] [-i INPUT] [--after AFTER] OPERATION [OPERATION ...]
|
||||
usage: tumblr_stats.py [-h] -b BLOG [-t TAGS [TAGS ...]]
|
||||
[-s {none,posts,queued,drafts,activity} [{none,posts,queued,drafts,activity} ...]] [--until UNTIL]
|
||||
[--since SINCE] [--queued-file QUEUED_FILE] [--drafts-file DRAFTS_FILE] [--posts-file POSTS_FILE]
|
||||
[--activity-file ACTIVITY_FILE]
|
||||
|
||||
Use pytumblr to calculate stats after setting these enviroment variables: $TUMBLR_CONSUMER_KEY, $TUMBLR_CONSUMER_SECRET, $TUMBLR_OAUTH_TOKEN, and $TUMBLR_OAUTH_SECRET
|
||||
|
||||
positional arguments:
|
||||
OPERATION operation used to calculate stats
|
||||
Use pytumblr and Polars to calculate stats after setting these enviroment variables: $TUMBLR_CONSUMER_KEY,
|
||||
$TUMBLR_CONSUMER_SECRET, $TUMBLR_OAUTH_TOKEN, and $TUMBLR_OAUTH_SECRET
|
||||
|
||||
options:
|
||||
-h, --help show this help message and exit
|
||||
-b, --blog BLOG blog name for which to calculate stats
|
||||
-t, --tags TAGS [TAGS ...]
|
||||
tag(s) to focus on in status (if applicable)
|
||||
-i, --input INPUT Don't make API calls, just use a JSON input file
|
||||
--after AFTER only gather posts after YYYY-MM-DD
|
||||
tag(s) on which to filter (only affects posts)
|
||||
-s, --suppress {none,posts,queued,drafts,activity} [{none,posts,queued,drafts,activity} ...]
|
||||
objects to skip when aggregating data; will render some stats empty
|
||||
--until UNTIL only gather posts and activity until YYYY-MM-DD
|
||||
--since SINCE only fetch posts and activity since YYYY-MM-DD
|
||||
--queued-file QUEUED_FILE
|
||||
read queued posts from this path instead of fetching them from APIs
|
||||
--drafts-file DRAFTS_FILE
|
||||
read drafts from this path instead of fetching them from APIs
|
||||
--posts-file POSTS_FILE
|
||||
read posts from this path instead of fetching them from APIs
|
||||
--activity-file ACTIVITY_FILE
|
||||
read activity from this path instead of fetching it from APIs
|
||||
|
||||
— Be gay and do crime
|
||||
```
|
||||
357
fetcher.py
Normal file
357
fetcher.py
Normal file
@@ -0,0 +1,357 @@
|
||||
from datetime import datetime as dt
|
||||
import json
|
||||
import sys
|
||||
from time import sleep
|
||||
import traceback
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pytumblr
|
||||
import polars as pl
|
||||
|
||||
|
||||
def filter_for_after(df: pl.DataFrame,
|
||||
after: dt) -> pl.DataFrame:
|
||||
if df.is_empty() or not after: return pl.DataFrame()
|
||||
base_col = pl.col('date')
|
||||
if df.get_column('date').dtype == pl.String:
|
||||
base_col = pl.when(base_col.str.contains('GMT')) \
|
||||
.then(base_col.str.to_date('%Y-%m-%d %H:%M:%S GMT')) \
|
||||
.otherwise(base_col.str.to_date('%Y-%m-%d %H:%M:%S'))
|
||||
return df.filter(base_col >= after.date())
|
||||
|
||||
|
||||
def filter_for_before(df: pl.DataFrame,
|
||||
before: dt) -> pl.DataFrame:
|
||||
if df.is_empty() or not before: return pl.DataFrame()
|
||||
base_col = pl.col('date')
|
||||
if df.get_column('date').dtype == pl.String:
|
||||
base_col = pl.when(base_col.str.contains('GMT')) \
|
||||
.then(base_col.str.to_date('%Y-%m-%d %H:%M:%S GMT')) \
|
||||
.otherwise(base_col.str.to_date('%Y-%m-%d %H:%M:%S'))
|
||||
return df.filter(base_col <= before.date())
|
||||
|
||||
|
||||
def handle_error(e: Exception, data: Any, df: pl.DataFrame) -> None:
|
||||
print(e)
|
||||
traceback.print_exc()
|
||||
schema_dict = {col: str(dtype) for col, dtype in df.schema.items()}
|
||||
with open("schema.json", "w") as f:
|
||||
json.dump(schema_dict, f, indent=4)
|
||||
with open("bad_posts.json", "w") as f:
|
||||
json.dump(data, f, indent=4, default=str)
|
||||
|
||||
|
||||
def handle_limit_exceeded(data) -> bool:
|
||||
if 'meta' in data and data['meta']['status'] == 429:
|
||||
print("WARN: API limit exceeded. Sleeping for 10 minutes...")
|
||||
sleep(600)
|
||||
return True
|
||||
sleep(3)
|
||||
return False
|
||||
|
||||
|
||||
def fetch_minimized_post(blog_name: str,
|
||||
client: pytumblr.TumblrRestClient,
|
||||
id_string: str) -> Dict[str, Any]:
|
||||
api_url = f"/v2/blog/{blog_name}/posts"
|
||||
data: Dict[str, Any] = client.send_api_request(method='get',
|
||||
url=api_url,
|
||||
params={'id': id_string},
|
||||
valid_parameters=['id'])
|
||||
|
||||
if 'posts' in data:
|
||||
posts = data['posts']
|
||||
if handle_limit_exceeded(data):
|
||||
data = client.send_api_request(method='get', url=api_url, params={'id': id_string}, valid_parameters=['id'])
|
||||
posts = data['posts']
|
||||
if 'posts' not in data or not data['posts']:
|
||||
print(f"WARN: Post with ID {id_string} could not be retrieved!")
|
||||
print(data)
|
||||
return {}
|
||||
|
||||
minimized_post: Dict[str, Any] = {}
|
||||
if posts:
|
||||
minimized_post = posts[0]
|
||||
minimized_post = {
|
||||
'id_string': minimized_post['id_string'],
|
||||
'url': minimized_post['post_url'],
|
||||
'date_str': minimized_post['date'],
|
||||
'tags': minimized_post['tags'],
|
||||
'is_og': 'parent_post_url' not in minimized_post
|
||||
}
|
||||
return minimized_post
|
||||
|
||||
|
||||
def fetch_queued_posts(client: pytumblr.TumblrRestClient,
|
||||
args: Dict[str, Any]) -> pl.DataFrame:
|
||||
queued_posts: pl.DataFrame = pl.DataFrame()
|
||||
blog_name: str = args['blog']
|
||||
|
||||
offset: int = 0
|
||||
limit: int = 20
|
||||
|
||||
while True:
|
||||
# Begin LOOP
|
||||
# Get me some queued posts via REST! 😈🍪🍪🍪
|
||||
data: Dict[str, Any]
|
||||
data = client.queue(f"{blog_name}.tumblr.com",
|
||||
offset=offset,
|
||||
limit=limit)
|
||||
|
||||
if handle_limit_exceeded(data):
|
||||
data = client.queue(f"{blog_name}.tumblr.com",
|
||||
offset=offset,
|
||||
limit=limit)
|
||||
|
||||
# This struct breaks Polars for some reason 🤷
|
||||
posts_json = data['posts']
|
||||
for post in posts_json:
|
||||
if 'trail' in post:
|
||||
del post['trail']
|
||||
|
||||
try:
|
||||
curr_posts: pl.DataFrame = pl.DataFrame(posts_json)
|
||||
except Exception as e:
|
||||
handle_error(e, data, queued_posts)
|
||||
sys.exit(0)
|
||||
|
||||
# Stop the presses if we found no posts.
|
||||
if curr_posts.is_empty() or len(curr_posts) < 1:
|
||||
print('Stopping queued post retrieval, as no more queued posts were found.')
|
||||
break
|
||||
|
||||
queued_posts = pl.concat([queued_posts, curr_posts], how="diagonal_relaxed")
|
||||
|
||||
offset += limit
|
||||
# End LOOP
|
||||
|
||||
# Empty check.
|
||||
if queued_posts.is_empty():
|
||||
print("No queued posts were found.")
|
||||
|
||||
return queued_posts
|
||||
|
||||
|
||||
def fetch_drafts(client: pytumblr.TumblrRestClient,
|
||||
args: Dict[str, Any]) -> pl.DataFrame:
|
||||
drafts: pl.DataFrame = pl.DataFrame()
|
||||
blog_name: str = args['blog']
|
||||
draft_url = f"/v2/blog/{blog_name}/posts/draft"
|
||||
|
||||
while True:
|
||||
# Begin LOOP
|
||||
# Get me some drafts via REST! 😈🍪🍪🍪
|
||||
data: Dict[str, Any]
|
||||
data = client.send_api_request("get", draft_url)
|
||||
|
||||
if handle_limit_exceeded(data):
|
||||
data = client.send_api_request("get", draft_url)
|
||||
|
||||
# This struct breaks Polars for some reason 🤷
|
||||
drafts_json = data['posts']
|
||||
for draft in drafts_json:
|
||||
if 'trail' in draft:
|
||||
del draft['trail']
|
||||
|
||||
curr_drafts: pl.DataFrame = pl.DataFrame(drafts_json)
|
||||
drafts = pl.concat([drafts, curr_drafts], how="diagonal_relaxed")
|
||||
|
||||
if '_links' in data:
|
||||
draft_url = data['_links']['next']['href']
|
||||
continue
|
||||
else:
|
||||
print('Stopping draft retrieval, as no more drafts were found.')
|
||||
break
|
||||
# End LOOP
|
||||
|
||||
# Empty check.
|
||||
if drafts.is_empty():
|
||||
print("No drafts were found.")
|
||||
|
||||
return drafts
|
||||
|
||||
|
||||
def fetch_posts(client: pytumblr.TumblrRestClient,
|
||||
args: Dict[str, Any]) -> pl.DataFrame:
|
||||
posts: pl.DataFrame = pl.DataFrame()
|
||||
blog_name: str = args['blog']
|
||||
|
||||
# We populate params, starting with any tags for filtering.
|
||||
params = {}
|
||||
if args['tags']:
|
||||
params.update({'tag': ','.join(args['tags'])})
|
||||
|
||||
total: int = 0
|
||||
offset: int = 0
|
||||
limit: int = 20
|
||||
|
||||
# The request loop that pulls all data from the APIs.
|
||||
while True:
|
||||
# Begin LOOP
|
||||
# Get me some posts via REST! 😈🍪🍪🍪
|
||||
data: Dict[str, Any]
|
||||
data = client.posts(f"{blog_name}.tumblr.com",
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
**params)
|
||||
|
||||
if handle_limit_exceeded(data):
|
||||
data = client.posts(f"{blog_name}.tumblr.com",
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
**params)
|
||||
|
||||
# These structs break Polars for some reason 🤷
|
||||
posts_json = data['posts']
|
||||
for post in posts_json:
|
||||
if 'trail' in post:
|
||||
del post['trail']
|
||||
if 'player' in post:
|
||||
del post['player']
|
||||
|
||||
try:
|
||||
curr_posts: pl.DataFrame = pl.DataFrame(posts_json, strict=False, infer_schema_length=None)
|
||||
except Exception as e:
|
||||
handle_error(e, data, curr_posts)
|
||||
sys.exit(1)
|
||||
|
||||
# Stop the presses if we found no posts.
|
||||
if curr_posts.is_empty() or len(curr_posts) < 1:
|
||||
print('Stopping, as no more posts were found.')
|
||||
break
|
||||
|
||||
next_off: int = 0
|
||||
|
||||
# Total init check for the first iteration, but always checked for sanity.
|
||||
if not total and 'total_posts' in data:
|
||||
total_posts = data['total_posts']
|
||||
print(f"I'm working with {total_posts} total posts...")
|
||||
total = total_posts
|
||||
|
||||
if args['since'] and not args['until']:
|
||||
after: dt = args['since']
|
||||
curr_posts = filter_for_after(curr_posts, after)
|
||||
if curr_posts.is_empty():
|
||||
print(
|
||||
f"All posts since {after.year}-{after.month}-{after.day} retrieved.")
|
||||
return posts
|
||||
if args['until'] and not args['since']:
|
||||
before: dt = args['until']
|
||||
curr_posts = filter_for_before(curr_posts, before)
|
||||
if curr_posts.is_empty():
|
||||
print(
|
||||
f"All posts until {before.year}-{before.month}-{before.day} retrieved.")
|
||||
return posts
|
||||
if args['since'] and args['until']:
|
||||
if not posts.is_empty() and curr_posts.is_empty():
|
||||
print(f"All posts since {after.year}-{after.month}-{after.day}, "
|
||||
+ f"until {before.year}-{before.month}-{before.day} retrieved.")
|
||||
return posts
|
||||
|
||||
# Update the frame with what we found.
|
||||
try:
|
||||
posts = pl.concat([posts, curr_posts], how="diagonal_relaxed")
|
||||
except Exception as e:
|
||||
handle_error(e, data, posts)
|
||||
sys.exit(1)
|
||||
|
||||
# The increment and status printing.
|
||||
if next_off != 0 and next_off != offset:
|
||||
offset = next_off
|
||||
else:
|
||||
offset += limit
|
||||
if not (args['since'] or args['until']) and total:
|
||||
print(
|
||||
f"Processed batch {offset // limit} of {(total // 20) + 1}...")
|
||||
# End LOOP
|
||||
|
||||
if posts.is_empty():
|
||||
print("No posts were found.")
|
||||
|
||||
# Return posts (both original and not original).
|
||||
return posts.unique(subset=["id_string"])
|
||||
|
||||
|
||||
def fetch_activity(client: pytumblr.TumblrRestClient,
|
||||
args: Dict[str, Any],
|
||||
posts: pl.DataFrame = pl.DataFrame()) -> pl.DataFrame:
|
||||
activity: pl.DataFrame = pl.DataFrame(infer_schema_length=None)
|
||||
blog_name: str = args['blog']
|
||||
|
||||
before: dt = args['until']
|
||||
after: dt = args['since']
|
||||
|
||||
activity_url = f"/v2/blog/{blog_name}/notifications"
|
||||
if before:
|
||||
activity_url += f"?before={str(int(before.timestamp()))}"
|
||||
|
||||
params = {}
|
||||
params.update({'rollups': 'false'})
|
||||
|
||||
post_map: Dict[str, Any] = {}
|
||||
if not posts.is_empty():
|
||||
post_map = { row["id"]: row for row in posts.to_dicts() }
|
||||
|
||||
data: Dict[str, Any]
|
||||
while True:
|
||||
# Begin LOOP
|
||||
# Get me some activity via REST! 😈🍪🍪🍪
|
||||
data = client.send_api_request("get", activity_url)
|
||||
if handle_limit_exceeded(data):
|
||||
data = client.send_api_request("get", activity_url)
|
||||
|
||||
try:
|
||||
curr_activity: pl.DataFrame = pl.DataFrame(data['notifications'], infer_schema_length=None)
|
||||
except Exception as e:
|
||||
handle_error(e, data, activity)
|
||||
sys.exit(0)
|
||||
curr_activity = curr_activity.with_columns(pl.from_epoch('timestamp').alias('date'))
|
||||
if before:
|
||||
curr_activity = filter_for_before(curr_activity, before)
|
||||
if after:
|
||||
curr_activity = filter_for_after(curr_activity, after)
|
||||
|
||||
# Add additional post info to notifications.
|
||||
# Yeah, df to dictionary list. Sorry. :c
|
||||
curr_activity_dicts: List[Dict[str, Any]] = curr_activity.to_dicts()
|
||||
for notification in curr_activity_dicts:
|
||||
if 'target_post_id' in notification and notification['target_post_id']:
|
||||
target_post_id = notification['target_post_id']
|
||||
post: Any
|
||||
if target_post_id in post_map:
|
||||
post = post_map.get(target_post_id)
|
||||
notification['post'] = post
|
||||
continue
|
||||
elif 'type' in notification and notification['type'] == 'post_attribution':
|
||||
notification['post'] = {}
|
||||
continue
|
||||
else:
|
||||
post = fetch_minimized_post(blog_name, client, notification['target_post_id'])
|
||||
notification['post'] = post
|
||||
post_map.update({target_post_id: post})
|
||||
continue
|
||||
|
||||
curr_activity = pl.DataFrame(curr_activity_dicts, strict=False, infer_schema_length=None)
|
||||
activity = pl.concat([activity, curr_activity], how="diagonal_relaxed")
|
||||
|
||||
if '_links' in data:
|
||||
next_time = dt.fromtimestamp(int(data['_links']['next']['query_params']['before']))
|
||||
if before or after:
|
||||
print(f"Reached activity time: {next_time}...")
|
||||
if (before and next_time >= before) or (after and next_time <= after):
|
||||
print('Stopping activity retrieval, as no more activity is left in date range.')
|
||||
break
|
||||
activity_url = data['_links']['next']['href']
|
||||
continue
|
||||
else:
|
||||
print('Stopping activity retrieval, as no more activity was found.')
|
||||
break
|
||||
# End LOOP
|
||||
|
||||
# Empty check.
|
||||
if activity.is_empty():
|
||||
print("No activity was found.")
|
||||
else:
|
||||
activity = activity.unique(subset=["id"])
|
||||
|
||||
return activity
|
||||
309
model.py
Normal file
309
model.py
Normal file
@@ -0,0 +1,309 @@
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import polars as pl
|
||||
|
||||
|
||||
class DateAgg(Enum):
|
||||
DAY = 'DAY'
|
||||
MONTH = 'MONTH'
|
||||
QUARTER = 'QUARTER'
|
||||
YEAR = 'YEAR'
|
||||
|
||||
|
||||
@dataclass
|
||||
class StatsModel:
|
||||
"""Class that models the output of the Tumblr stats script."""
|
||||
# The blog in question.
|
||||
blog_name: str
|
||||
|
||||
# Contains posts.
|
||||
posts: pl.DataFrame
|
||||
|
||||
# Contains queued posts.
|
||||
queued_posts: pl.DataFrame
|
||||
|
||||
# Contains drafted posts, both original and not original.
|
||||
drafts: pl.DataFrame
|
||||
|
||||
# Contains activity (notifications).
|
||||
activity: pl.DataFrame
|
||||
|
||||
# Any tags used.
|
||||
tags: List[str] = field(default_factory=list)
|
||||
|
||||
# Total count of posts processed.
|
||||
total_posts: int = field(init=False)
|
||||
|
||||
# Total original posts (for blog_name) processed.
|
||||
total_original_posts: int = field(init=False)
|
||||
|
||||
# Total original post (for blog_name) notes processed.
|
||||
total_original_post_notes: int = field(init=False)
|
||||
|
||||
# Note stats for original posts by YYYY-MM-DD.
|
||||
original_post_notes_by_date: List[Dict[str, Any]] = field(init=False)
|
||||
|
||||
# Note stats for original posts by month and year.
|
||||
original_post_notes_by_month_and_year: List[Dict[str, Any]] = field(init=False)
|
||||
|
||||
# Note stats for original posts by quarter and year.
|
||||
original_post_notes_by_qtr_and_year: List[Dict[str, Any]] = field(init=False)
|
||||
|
||||
# Note stats for original posts by year.
|
||||
original_post_notes_by_year: List[Dict[str, Any]] = field(init=False)
|
||||
|
||||
# Tags ranked from most popular to least popular by notes.
|
||||
most_popular_tags: List[Dict[str, Any]] = field(init=False)
|
||||
|
||||
# Original posts ranked from most popular to least popular by notes.
|
||||
ranked_original_posts: List[Dict[str, Any]] = field(init=False)
|
||||
|
||||
# Original post note stats by interaction month and year.
|
||||
original_post_notes_by_interaction_date: List[Dict[str, Any]] = field(init=False)
|
||||
|
||||
# Original post note stats by interaction month and year.
|
||||
original_post_notes_by_interaction_month_and_year: List[Dict[str, Any]] = field(init=False)
|
||||
|
||||
# Top blogs by original post note count.
|
||||
top_interactors: List[Dict[str, Any]] = field(init=False)
|
||||
|
||||
# Queued posts (both original and not original), sorted in publish order.
|
||||
ordered_queue: List[Dict[str, Any]] = field(init=False)
|
||||
|
||||
# Top tags in queued posts (both original and not original), sorted by post count.
|
||||
top_queued_tags: List[Dict[str, Any]] = field(init=False)
|
||||
|
||||
# Top tags in drafts (both original and not original), sorted by post count.
|
||||
top_draft_tags: List[Dict[str, Any]] = field(init=False)
|
||||
|
||||
def __post_init__(self):
|
||||
self.total_posts = self.calculate_total_posts()
|
||||
self.total_original_posts = self.calculate_total_original_posts()
|
||||
self.total_original_post_notes = self.calculate_original_post_notes()
|
||||
self.original_post_notes_by_date = self.collect_note_stats_by_original_post_year_month_day()
|
||||
self.original_post_notes_by_month_and_year = self.collect_note_stats_by_original_post_month_and_year()
|
||||
self.original_post_notes_by_qtr_and_year = self.collect_note_stats_by_original_post_qtr_and_year()
|
||||
self.original_post_notes_by_year = self.collect_note_stats_by_original_post_year()
|
||||
self.most_popular_tags = self.determine_most_popular_tags(
|
||||
self.get_original_posts(), "note_count")
|
||||
self.ranked_original_posts = self.rank_original_posts()
|
||||
self.original_post_notes_by_interaction_month_and_year = self.collect_note_stats_by_op_interaction_month_and_year()
|
||||
self.original_post_notes_by_interaction_date = self.collect_note_stats_by_op_interaction_year_month_day()
|
||||
self.top_interactors = self.determine_top_interactors()
|
||||
self.ordered_queue = self.determine_ordered_queue()
|
||||
self.top_queued_tags = self.determine_most_popular_tags(
|
||||
self.queued_posts, 'post_count')
|
||||
self.top_draft_tags = self.determine_most_popular_tags(
|
||||
self.drafts, 'post_count')
|
||||
|
||||
@staticmethod
|
||||
def determine_most_popular_tags(df: pl.DataFrame, sort_key: str) -> List[Dict[str, Any]]:
|
||||
if df.is_empty():
|
||||
return []
|
||||
|
||||
grouped_tags = df.explode(
|
||||
'tags', keep_nulls=False, empty_as_null=False).group_by('tags')
|
||||
result: pl.DataFrame = pl.DataFrame()
|
||||
by_post_count = grouped_tags.agg(pl.count().alias(
|
||||
'post_count')).sort('post_count', descending=True)
|
||||
if sort_key == 'post_count':
|
||||
result = by_post_count.rename({'tags': 'tag'})
|
||||
elif sort_key == 'note_count':
|
||||
result = grouped_tags.agg(pl.sum('note_count'))
|
||||
result = result.join(other=by_post_count, on='tags', how='inner')
|
||||
result = result.select(
|
||||
(pl.col('tags').alias('tag')),
|
||||
(pl.col('note_count')),
|
||||
(pl.col('post_count')),
|
||||
(pl.col('note_count').truediv(pl.col('post_count'))).alias(
|
||||
"notes_to_posts_ratio")
|
||||
)
|
||||
result = result.sort('note_count', descending=True)
|
||||
else:
|
||||
print(f"WARN: {sort_key} is not a supported sort key!")
|
||||
return result.to_dicts()
|
||||
|
||||
def get_original_posts(self) -> pl.DataFrame:
|
||||
if self.posts.is_empty():
|
||||
return pl.DataFrame()
|
||||
return self.posts.filter(pl.col('parent_post_url').is_null())
|
||||
|
||||
def calculate_total_posts(self) -> int:
|
||||
if self.posts.is_empty():
|
||||
return 0
|
||||
return len(self.posts)
|
||||
|
||||
def calculate_total_original_posts(self) -> int:
|
||||
if self.posts.is_empty():
|
||||
return 0
|
||||
return len(self.get_original_posts())
|
||||
|
||||
def calculate_original_post_notes(self) -> int:
|
||||
if self.posts.is_empty():
|
||||
return 0
|
||||
og_posts = self.get_original_posts()
|
||||
return int(og_posts['note_count'].sum())
|
||||
|
||||
def rank_original_posts(self) -> List[Dict[str, Any]]:
|
||||
og_posts = self.get_original_posts()
|
||||
|
||||
sorted_posts = og_posts.clone()
|
||||
if not sorted_posts.is_empty():
|
||||
sorted_posts = sorted_posts.sort(by='note_count', descending=True)
|
||||
return sorted_posts.to_dicts()
|
||||
|
||||
def determine_top_interactors(self) -> List[Dict[str, Any]]:
|
||||
if self.activity.is_empty(): return []
|
||||
og_activity = self.activity.filter(pl.col('post').struct.field('is_og') == True)
|
||||
interactor_group_by = og_activity.group_by(pl.col('from_tumblelog_name'))
|
||||
agg = interactor_group_by.agg(pl.count().alias('interaction_count')).sort('interaction_count', descending=True)
|
||||
return agg.to_dicts()
|
||||
|
||||
def determine_ordered_queue(self) -> List[Dict[str, Any]]:
|
||||
if self.queued_posts.is_empty():
|
||||
return []
|
||||
sorted_queued_posts = self.queued_posts.sort(
|
||||
by='scheduled_publish_time', descending=False)
|
||||
return sorted_queued_posts.to_dicts()
|
||||
|
||||
def collect_note_stats_by_original_post_date(self, date_agg: DateAgg) -> List[Dict[str, Any]]:
|
||||
og_posts = self.get_original_posts()
|
||||
if og_posts.is_empty(): return []
|
||||
|
||||
sorted_og_posts = og_posts.sort(by='date')
|
||||
|
||||
group_key = self._get_group_key(sorted_og_posts, date_agg)
|
||||
grouped_posts = sorted_og_posts.group_by(group_key)
|
||||
base_stats = grouped_posts.agg(pl.col('id_string').n_unique().alias('post_count'),
|
||||
pl.sum('note_count').alias('note_count'))
|
||||
|
||||
# For breakdown by tag.
|
||||
exploded_tags = sorted_og_posts.explode(
|
||||
'tags', keep_nulls=False, empty_as_null=False)
|
||||
exploded_tags = exploded_tags.rename({'tags': 'tag'})
|
||||
grouped_tag_explosion = exploded_tags.group_by([group_key, pl.col('tag')])
|
||||
by_tags = grouped_tag_explosion.agg(
|
||||
pl.col('id_string').n_unique().alias('post_count'),
|
||||
pl.sum('note_count'),
|
||||
pl.sum('note_count').truediv(pl.col('id_string').n_unique())
|
||||
.alias("notes_to_posts_ratio")).sort('note_count', descending=True)
|
||||
by_tags = by_tags.group_by(pl.col('group_key')).agg(
|
||||
pl.struct(pl.all()).alias('tags'))
|
||||
|
||||
# For breakdown by post.
|
||||
grouped_by_posts = sorted_og_posts.group_by([group_key,
|
||||
pl.col('post_url').alias('url')])
|
||||
by_posts = grouped_by_posts.agg(pl.sum('note_count')) \
|
||||
.sort('note_count', descending=True)
|
||||
by_posts = by_posts.group_by(pl.col('group_key')).agg(pl.struct(pl.all()).alias('posts'))
|
||||
|
||||
post_cols = [(pl.col('note_count')),
|
||||
(pl.col('post_count')),
|
||||
(pl.col('note_count').truediv(pl.col('post_count'))).alias(
|
||||
"notes_to_posts_ratio"),
|
||||
(pl.col('tags')),
|
||||
(pl.col('posts'))]
|
||||
|
||||
# Join to get final result.
|
||||
result = base_stats.join(other=by_tags, on='group_key', how='inner')
|
||||
result = result.join(other=by_posts, on='group_key', how='inner')
|
||||
return self._date_agg_final_select(result, post_cols, date_agg).to_dicts()
|
||||
|
||||
def collect_note_stats_by_original_post_year_month_day(self) -> List[Dict[str, Any]]:
|
||||
return self.collect_note_stats_by_original_post_date(DateAgg.DAY)
|
||||
|
||||
def collect_note_stats_by_original_post_month_and_year(self) -> List[Dict[str, Any]]:
|
||||
return self.collect_note_stats_by_original_post_date(DateAgg.MONTH)
|
||||
|
||||
def collect_note_stats_by_original_post_qtr_and_year(self) -> List[Dict[str, Any]]:
|
||||
return self.collect_note_stats_by_original_post_date(DateAgg.QUARTER)
|
||||
|
||||
def collect_note_stats_by_original_post_year(self) -> List[Dict[str, Any]]:
|
||||
return self.collect_note_stats_by_original_post_date(DateAgg.YEAR)
|
||||
|
||||
def collect_note_stats_by_op_interaction_month_and_year(self) -> List[Dict[str, Any]]:
|
||||
return self.collect_note_stats_by_op_interaction_date(DateAgg.MONTH)
|
||||
|
||||
def collect_note_stats_by_op_interaction_year_month_day(self) -> List[Dict[str, Any]]:
|
||||
return self.collect_note_stats_by_op_interaction_date(DateAgg.DAY)
|
||||
|
||||
def collect_note_stats_by_op_interaction_date(self, date_agg: DateAgg) -> List[Dict[str, Any]]:
|
||||
if self.activity.is_empty(): return []
|
||||
|
||||
og_activity = self.activity.filter(pl.col('post').struct.field('is_og') == True)
|
||||
og_activity = og_activity.with_columns(pl.col('post').struct.field('tags').alias('og_post_tags'),
|
||||
pl.col('post').struct.field('url').alias('og_post_url'))
|
||||
sorted_og_activity = og_activity.sort(by='date')
|
||||
|
||||
group_key = self._get_group_key(sorted_og_activity, date_agg)
|
||||
grouped_activity = sorted_og_activity.group_by(group_key)
|
||||
base_stats = grouped_activity.agg(pl.col('id').n_unique().alias('interaction_count'))
|
||||
base_stats.write_json('lesbo.json')
|
||||
|
||||
# For breakdown by tag.
|
||||
exploded_tags = sorted_og_activity.explode(columns='og_post_tags', keep_nulls=False, empty_as_null=False)
|
||||
exploded_tags = exploded_tags.rename({'og_post_tags': 'tag'})
|
||||
grouped_tag_explosion = exploded_tags.group_by([group_key, pl.col('tag')])
|
||||
by_tags = grouped_tag_explosion.agg(pl.col('id').n_unique().alias('interaction_count')) \
|
||||
.sort('interaction_count', descending=True)
|
||||
by_tags = by_tags.group_by(pl.col('group_key')).agg(pl.struct(pl.all()).alias('tags'))
|
||||
|
||||
|
||||
# For breakdown by post.
|
||||
grouped_by_posts = sorted_og_activity.group_by([group_key,
|
||||
pl.col('og_post_url').alias('url')])
|
||||
by_posts = grouped_by_posts.agg(pl.col('id').n_unique().alias('interaction_count')) \
|
||||
.sort('interaction_count', descending=True)
|
||||
by_posts = by_posts.group_by(pl.col('group_key')).agg(pl.struct(pl.all()).alias('posts'))
|
||||
|
||||
actv_columns = [(pl.col('interaction_count')),
|
||||
(pl.col('tags')),
|
||||
(pl.col('posts'))]
|
||||
|
||||
# Join to get final result.
|
||||
result = base_stats.join(other=by_tags, on='group_key', how='inner')
|
||||
result = result.join(other=by_posts, on='group_key', how='inner')
|
||||
return self._date_agg_final_select(result, actv_columns, date_agg).to_dicts()
|
||||
|
||||
def _get_group_key(self, df: pl.DataFrame, date_agg: DateAgg):
|
||||
base_col = pl.col('date')
|
||||
if df.get_column('date').dtype == pl.String:
|
||||
base_col = pl.when(base_col.str.contains('GMT')) \
|
||||
.then(base_col.str.to_date('%Y-%m-%d %H:%M:%S GMT')) \
|
||||
.otherwise(base_col.str.to_date('%Y-%m-%d %H:%M:%S'))
|
||||
|
||||
match date_agg:
|
||||
case DateAgg.DAY:
|
||||
group_key = base_col.dt.date().alias('group_key')
|
||||
case DateAgg.MONTH:
|
||||
group_key = base_col.dt.month_start().dt.date().alias('group_key')
|
||||
case DateAgg.QUARTER:
|
||||
group_key = pl.struct(base_col.dt.year().alias('year'),
|
||||
base_col.dt.quarter().alias('quarter')) \
|
||||
.alias('group_key')
|
||||
case DateAgg.YEAR:
|
||||
group_key = base_col.dt.year().alias('group_key')
|
||||
return group_key
|
||||
|
||||
def _date_agg_final_select(self, result: pl.DataFrame, cols: List[pl.Expr], date_agg: DateAgg) -> pl.DataFrame:
|
||||
match date_agg:
|
||||
case DateAgg.DAY:
|
||||
final_cols = [(pl.col('group_key').dt.year().alias('year')),
|
||||
(pl.col('group_key').dt.month().alias('month')),
|
||||
(pl.col('group_key').dt.day().alias('day'))]
|
||||
final_cols.extend(cols)
|
||||
return result.select(final_cols).sort(by=['year', 'month', 'day'], descending=True)
|
||||
case DateAgg.MONTH:
|
||||
final_cols = [(pl.col('group_key').dt.year().alias('year')), (pl.col('group_key').dt.month().alias('month'))]
|
||||
final_cols.extend(cols)
|
||||
return result.select(final_cols).sort(by=['year', 'month'], descending=True)
|
||||
case DateAgg.QUARTER:
|
||||
final_cols = [(pl.col('group_key').alias('quarter'))]
|
||||
final_cols.extend(cols)
|
||||
return result.select(final_cols).sort(by='quarter', descending=True)
|
||||
case DateAgg.YEAR:
|
||||
final_cols = [(pl.col('group_key').alias('year'))]
|
||||
final_cols.extend(cols)
|
||||
return result.select(final_cols).sort(by='year', descending=True)
|
||||
@@ -1,11 +1,14 @@
|
||||
[project]
|
||||
name = "tumblr_stats"
|
||||
version = "0.1.0"
|
||||
description = "Calculate Tumblr stats using pytumblr."
|
||||
version = "1.0.0"
|
||||
description = "Calculate Tumblr stats using pytumblr and Polars."
|
||||
authors = [
|
||||
{name = "Amber", email = "amber@ailuridae.io"},
|
||||
]
|
||||
dependencies = ["pytumblr==0.1.2"]
|
||||
dependencies = [
|
||||
"pytumblr==0.1.2",
|
||||
"polars==1.44.1",
|
||||
]
|
||||
requires-python = "==3.14.*"
|
||||
readme = "README.md"
|
||||
|
||||
|
||||
287
tumblr_stats.py
287
tumblr_stats.py
@@ -1,46 +1,46 @@
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime
|
||||
import dataclasses
|
||||
from datetime import datetime as dt
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Any, Callable, Dict, List, Tuple
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pytumblr
|
||||
|
||||
from build_deep_stats_model import BuildDeepStatsModel
|
||||
from build_draft_stats_model import BuildDraftStatsModel
|
||||
from build_tag_stats_model import BuildTagStatsModel
|
||||
from build_total_stats_model import BuildTotalStatsModel
|
||||
from build_queue_stats_model import BuildQueueStatsModel
|
||||
from stats_model import StatsModel
|
||||
import polars as pl
|
||||
import fetcher
|
||||
from model import StatsModel
|
||||
|
||||
|
||||
def get_args() -> Dict[str, Any]:
|
||||
"""Pull arguments from command line, turn them into a dictionary of <arg, value>"""
|
||||
parser: argparse.ArgumentParser = argparse.ArgumentParser(
|
||||
prog='tumblr_stats.py',
|
||||
description='Use pytumblr to calculate stats after setting these enviroment variables: '
|
||||
description='Use pytumblr and Polars to calculate stats after setting these enviroment variables: '
|
||||
+ '$TUMBLR_CONSUMER_KEY, $TUMBLR_CONSUMER_SECRET, $TUMBLR_OAUTH_TOKEN, and $TUMBLR_OAUTH_SECRET',
|
||||
epilog='— Be gay and do crime')
|
||||
parser.add_argument('operation', type=str, nargs='+', metavar='OPERATION',
|
||||
choices=['build_tag_stats', 'build_queue_stats', 'build_draft_stats',
|
||||
'build_deep_stats'],
|
||||
help="operation used to calculate stats")
|
||||
parser.add_argument('-b', '--blog', type=str, required=True,
|
||||
help='blog name for which to calculate stats')
|
||||
parser.add_argument('-t', '--tags', type=str, nargs='+',
|
||||
help='tag(s) to focus on in status (if applicable)')
|
||||
parser.add_argument('-i', '--input', type=str,
|
||||
help='Don\'t make API calls, just use a JSON input file')
|
||||
# TODO: Make 'before' work, but it depends on https://github.com/tumblr/pytumblr/issues/174.
|
||||
# parser.add_argument('--before', type=lambda s: datetime.strptime(s, '%Y-%m-%d'),
|
||||
# help='only gather posts before YYYY-MM-DD')
|
||||
parser.add_argument('--after', type=lambda s: datetime.strptime(s, '%Y-%m-%d'),
|
||||
help='only gather posts after YYYY-MM-DD')
|
||||
help='tag(s) on which to filter (only affects posts)')
|
||||
parser.add_argument('-s', '--suppress', type=str, nargs='+',
|
||||
choices=['none', 'posts', 'queued', 'drafts', 'activity'],
|
||||
default='activity',
|
||||
help='objects to skip when aggregating data; will render some stats empty')
|
||||
parser.add_argument('--until', type=lambda s: dt.strptime(s, '%Y-%m-%d'),
|
||||
help='only gather posts and activity until YYYY-MM-DD')
|
||||
parser.add_argument('--since', type=lambda s: dt.strptime(s, '%Y-%m-%d'),
|
||||
help='only fetch posts and activity since YYYY-MM-DD')
|
||||
parser.add_argument('--queued-file', type=str,
|
||||
help='read queued posts from this path instead of fetching them from APIs')
|
||||
parser.add_argument('--drafts-file', type=str,
|
||||
help='read drafts from this path instead of fetching them from APIs')
|
||||
parser.add_argument('--posts-file', type=str,
|
||||
help='read posts from this path instead of fetching them from APIs')
|
||||
parser.add_argument('--activity-file', type=str,
|
||||
help='read activity from this path instead of fetching it from APIs')
|
||||
return vars(parser.parse_args())
|
||||
|
||||
|
||||
@@ -68,205 +68,76 @@ def init_client() -> pytumblr.TumblrRestClient:
|
||||
)
|
||||
|
||||
|
||||
def filter_posts_for_after(post_list: List[Dict[str, Any]],
|
||||
after: datetime) -> List[Dict[str, Any]]:
|
||||
# Quick short circuit check.
|
||||
if not post_list or not after:
|
||||
return []
|
||||
def json_encoder(obj):
|
||||
if dataclasses.is_dataclass(obj):
|
||||
return dataclasses.asdict(obj) # type: ignore
|
||||
elif isinstance(obj, pl.DataFrame):
|
||||
return json.loads(obj.write_json())
|
||||
raise TypeError(f"Type {type(obj)} is not serializable!")
|
||||
|
||||
# Handle 'after'.
|
||||
after_check: Callable[[Dict[str, Any]], bool] = lambda x: datetime.strptime(
|
||||
x['date'], '%Y-%m-%d %H:%M:%S %Z') > after
|
||||
return [post for post in post_list if after_check(post)]
|
||||
|
||||
def build_post_maps(client: pytumblr.TumblrRestClient,
|
||||
args: Dict[str, Any]) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
||||
og_post_map: Dict[str, Any] = {}
|
||||
un_og_post_map: Dict[str, Any] = {}
|
||||
blog_name: str = args['blog']
|
||||
def file_is_used(obj_arg: str, args: Dict[str, Any]) -> bool:
|
||||
return obj_arg in args and args[obj_arg]
|
||||
|
||||
# We populate params, starting with any tags for filtering.
|
||||
params = {}
|
||||
if args['tags']:
|
||||
params.update({'tag': ','.join(args['tags'])})
|
||||
# TODO: Make 'before' work.
|
||||
# if args['before']:
|
||||
# before: datetime = args['before']
|
||||
# params.update({'before': int(before.timestamp())})
|
||||
|
||||
draft_url = f"/v2/blog/{blog_name}/posts/draft"
|
||||
is_draft_stats: bool = 'build_draft_stats' in args['operation']
|
||||
is_deep_stats: bool = 'build_deep_stats' in args['operation']
|
||||
|
||||
total: int = 0
|
||||
offset: int = 0
|
||||
limit: int = 20
|
||||
|
||||
# The request loop that pulls all data from the APIs.
|
||||
while True:
|
||||
# Begin LOOP
|
||||
# Get me some posts via REST! 😈🍪🍪🍪
|
||||
data: Dict[str, Any]
|
||||
if 'build_queue_stats' in args['operation']:
|
||||
data = client.queue(f"{blog_name}.tumblr.com",
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
**params)
|
||||
elif is_draft_stats:
|
||||
data = client.send_api_request("get", draft_url)
|
||||
else: # Above is for queued + draft posts, below is for published posts.
|
||||
if is_deep_stats:
|
||||
params.update({'notes_info': 'true'})
|
||||
data = client.posts(f"{blog_name}.tumblr.com",
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
**params)
|
||||
|
||||
# Stop the presses if we found no posts.
|
||||
curr_posts: List[Dict[str, Any]] = data['posts']
|
||||
if not curr_posts or len(curr_posts) < 1:
|
||||
print('Stopping, as no more posts were found.')
|
||||
break
|
||||
|
||||
next_off: int = 0
|
||||
if '_links' in data and not is_draft_stats:
|
||||
links = data['_links']
|
||||
if 'next' in links and 'query_params' in links['next']:
|
||||
next_off = int(links['next']['query_params']['offset'])
|
||||
|
||||
# Total init check for the first iteration, but always checked for sanity.
|
||||
if not total and 'total_posts' in data:
|
||||
total_posts = data['total_posts']
|
||||
print(f"I'm working with {total_posts} total posts...")
|
||||
total = total_posts
|
||||
|
||||
# Behavior for 'after'.
|
||||
if args['after']:
|
||||
after: datetime = args['after']
|
||||
curr_posts = filter_posts_for_after(curr_posts, after)
|
||||
if not curr_posts:
|
||||
print(f"All posts after {after.year}-{after.month}-{after.day} processed.")
|
||||
return (og_post_map, un_og_post_map)
|
||||
|
||||
# This block populates the local post_maps from the raw response data.
|
||||
local_og_post_map: Dict[str, Any] = {
|
||||
item['id_string']: item for item in curr_posts if 'parent_post_url' not in item
|
||||
}
|
||||
local_un_og_post_map: Dict[str, Any] = {
|
||||
item['id_string']: item for item in curr_posts if 'parent_post_url' in item
|
||||
}
|
||||
|
||||
# Update the maps with what we found.
|
||||
og_post_map.update(local_og_post_map)
|
||||
un_og_post_map.update(local_un_og_post_map)
|
||||
|
||||
# For build_draft_stats.
|
||||
if is_draft_stats:
|
||||
if '_links' in data:
|
||||
draft_url = data['_links']['next']['href']
|
||||
continue
|
||||
else:
|
||||
print('All draft posts processed.')
|
||||
break
|
||||
|
||||
# The increment and status printing.
|
||||
if next_off != 0 and next_off != offset:
|
||||
offset = next_off
|
||||
else:
|
||||
offset += limit
|
||||
if not args['after'] and total:
|
||||
print(
|
||||
f"Processed batch {offset // limit} of {(total // 20) + 1}...")
|
||||
# End LOOP
|
||||
|
||||
# Return (og_posts, not_og_posts).
|
||||
return (og_post_map, un_og_post_map)
|
||||
def load_from_file(path: str) -> pl.DataFrame:
|
||||
return pl.read_json(source=Path(path), infer_schema_length=None)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args: Dict[str, Any] = get_args()
|
||||
suppress = args['suppress']
|
||||
client: pytumblr.TumblrRestClient = init_client()
|
||||
operation: List[str] = args['operation']
|
||||
|
||||
# Quick bail for bad use of build_queue_stats and build_draft_stats.
|
||||
if (set(operation) & set(['build_queue_stats', 'build_draft_stats'])) and len(operation) > 1:
|
||||
print(f"You can't mix operations {operation} together. Sorry.")
|
||||
sys.exit(1)
|
||||
pass
|
||||
# Read queued posts from a file, or fetch them via API and write queued post JSON.
|
||||
queued_posts: pl.DataFrame = pl.DataFrame()
|
||||
if not suppress or 'queued' not in suppress:
|
||||
if file_is_used('queued_file', args):
|
||||
queued_posts = load_from_file(args['queued_file'])
|
||||
else:
|
||||
queued_posts = fetcher.fetch_queued_posts(client, args)
|
||||
queued_posts.write_json("queued_posts.json")
|
||||
|
||||
# Handle JSON input (if you don't want to make API calls.)
|
||||
if 'input' in args and args['input']:
|
||||
input_path = Path(args['input'])
|
||||
with open(input_path, "r") as f:
|
||||
data = json.load(f)
|
||||
og_post_map = data['original_post_map']
|
||||
un_og_post_map = data['unoriginal_post_map']
|
||||
for post_key in og_post_map.copy():
|
||||
post = og_post_map[post_key]
|
||||
date: datetime = datetime.strptime(
|
||||
post['date'], '%Y-%m-%d %H:%M:%S %Z')
|
||||
if date.year != 2025:
|
||||
del og_post_map[post_key]
|
||||
for post_key in un_og_post_map.copy():
|
||||
post = un_og_post_map[post_key]
|
||||
date: datetime = datetime.strptime(
|
||||
post['date'], '%Y-%m-%d %H:%M:%S %Z')
|
||||
if date.year != 2025:
|
||||
del un_og_post_map[post_key]
|
||||
else:
|
||||
# Get the og_post_map (original posts) and un_og_post_map (not original posts).
|
||||
og_post_map, un_og_post_map = build_post_maps(args=args, client=client)
|
||||
# Read drafts from a file, or fetch them via API and write draft JSON.
|
||||
drafts: pl.DataFrame = pl.DataFrame()
|
||||
if not suppress or 'drafts' not in suppress:
|
||||
if file_is_used('drafts_file', args):
|
||||
drafts = load_from_file(args['drafts_file'])
|
||||
else:
|
||||
drafts = fetcher.fetch_drafts(client, args)
|
||||
drafts.write_json("drafts.json")
|
||||
|
||||
# Pick a stats model, which will determine output.
|
||||
stats_model: StatsModel
|
||||
match args:
|
||||
case {'operation': ['build_queue_stats']}:
|
||||
stats_model = BuildQueueStatsModel(blog_name=args['blog'],
|
||||
original_post_map=og_post_map,
|
||||
unoriginal_post_map=un_og_post_map)
|
||||
case {'operation': ['build_draft_stats']}:
|
||||
stats_model = BuildDraftStatsModel(blog_name=args['blog'],
|
||||
original_post_map=og_post_map,
|
||||
unoriginal_post_map=un_og_post_map)
|
||||
case {'operation': op} if 'build_tag_stats' in operation:
|
||||
stats_model = BuildTagStatsModel(blog_name=args['blog'],
|
||||
original_post_map=og_post_map,
|
||||
unoriginal_post_map=un_og_post_map)
|
||||
stats_model.tags = args['tags']
|
||||
case {'operation': op} if 'build_total_stats' in operation:
|
||||
if 'before' not in args: # or 'after' not in args:
|
||||
print(f"You must specify a time range for {op}. " +
|
||||
'You\'ll otherwise request TOO MUCH DATA!')
|
||||
sys.exit(1)
|
||||
stats_model = BuildTotalStatsModel(blog_name=args['blog'],
|
||||
original_post_map=og_post_map,
|
||||
unoriginal_post_map=un_og_post_map)
|
||||
case {'operation': op} if 'build_deep_stats' in operation:
|
||||
if 'after' not in args: # or 'before' not in args:
|
||||
print(f"You must specify a time range for {op}. " +
|
||||
'You\'ll otherwise request TOO MUCH DATA!')
|
||||
sys.exit(1)
|
||||
stats_model = BuildDeepStatsModel(blog_name=args['blog'],
|
||||
original_post_map=og_post_map,
|
||||
unoriginal_post_map=un_og_post_map)
|
||||
case _:
|
||||
print('Unsupported command. How did you even make it this far?!')
|
||||
sys.exit(1)
|
||||
# Read posts from a file, or fetch them via API and write post JSON.
|
||||
posts: pl.DataFrame = pl.DataFrame()
|
||||
if not suppress or 'posts' not in suppress:
|
||||
if file_is_used('posts_file', args):
|
||||
posts = load_from_file(args['posts_file'])
|
||||
else:
|
||||
posts = fetcher.fetch_posts(client, args)
|
||||
posts.write_json("posts.json")
|
||||
|
||||
# Write the selected model as JSON output.
|
||||
# Read activity from a file, or fetch them via API and write activity JSON.
|
||||
activity: pl.DataFrame = pl.DataFrame(infer_schema_length=None)
|
||||
if not suppress or 'activity' not in suppress:
|
||||
if file_is_used('activity_file', args):
|
||||
activity = load_from_file(args['activity_file'])
|
||||
else:
|
||||
activity = fetcher.fetch_activity(client, args, posts)
|
||||
activity.write_json('activity.json')
|
||||
|
||||
# The model.
|
||||
model: StatsModel = StatsModel(blog_name=args['blog'],
|
||||
posts = posts,
|
||||
queued_posts = queued_posts,
|
||||
drafts = drafts,
|
||||
activity = activity,
|
||||
tags = args['tags'])
|
||||
|
||||
# Write the model as JSON output.
|
||||
# https://stackoverflow.com/q/26244323
|
||||
with open('./tumblr_stats.json', 'w') as f:
|
||||
json.dump(asdict(stats_model), f, indent=1, default=str)
|
||||
json.dump(asdict(model), f, indent=1, default=json_encoder)
|
||||
|
||||
# If there were original posts, create a CSV for them.
|
||||
if og_post_map:
|
||||
with open('./tumblr_original_posts.csv', 'w', newline='') as f:
|
||||
post_list: List[Dict[str, Any]] = list(og_post_map.values())
|
||||
wr = csv.DictWriter(f, quoting=csv.QUOTE_ALL, extrasaction='ignore',
|
||||
fieldnames=post_list[0].keys())
|
||||
wr.writeheader()
|
||||
wr.writerows(post_list)
|
||||
else:
|
||||
print('No original posts were found, so a CSV of original posts was not written.')
|
||||
return
|
||||
|
||||
|
||||
|
||||
194
uv.lock
generated
Normal file
194
uv.lock
generated
Normal file
@@ -0,0 +1,194 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = "==3.14.*"
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.7.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "future"
|
||||
version = "1.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a7/b2/4140c69c6a66432916b26158687e821ba631a4c9273c474343badf84d3ba/future-1.0.0.tar.gz", hash = "sha256:bd2968309307861edae1458a4f8a4f3598c03be43b97521076aebf5d94c07b05", size = 1228490, upload-time = "2024-02-21T11:52:38.461Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/71/ae30dadffc90b9006d77af76b393cb9dfbfc9629f339fc1574a1c52e6806/future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", size = 491326, upload-time = "2024-02-21T11:52:35.956Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.20"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oauthlib"
|
||||
version = "3.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polars"
|
||||
version = "1.44.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "polars-runtime-32" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/26/73/258a1fe17bb2744a507199566ed712663144fdd0811b615b59a47dfa38d2/polars-1.44.1.tar.gz", hash = "sha256:ef3c89e9ebbbe8eb343c06873f1945683f8b6f97a1bdf001c60551c6c5e3cda1", size = 765660, upload-time = "2026-08-26T07:09:12.704Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/f1/59154659081930fbde291ea6225607956b500a71b0ce45d88217b7d32da2/polars-1.44.1-py3-none-any.whl", hash = "sha256:1fa62fc1c88fba77a68b28291b5aabdd69e5f38b34e59721a064ae3169b59bb5", size = 865208, upload-time = "2026-08-26T07:07:44.646Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polars-runtime-32"
|
||||
version = "1.44.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fd/b2/2a76415d047a45df05489f2334c91ff120a274cf655d4ca030c7f54a8743/polars_runtime_32-1.44.1.tar.gz", hash = "sha256:abd10a54ed1caff42228610fcba0f93251f9870bd7cffb0c78bc26f5e0718ce4", size = 3171156, upload-time = "2026-08-26T07:09:14.243Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/93/ef9344dcec16757cf21027dc907ef989197e50742cfe4407f2e87edb0a7f/polars_runtime_32-1.44.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:1dfccb2b52aa50468a7d28e3e61c8338a13fb5bffc8646e388a649f5bdc6b463", size = 53962370, upload-time = "2026-08-26T07:07:47.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/da/38b32b7901af33f1fee2172ceaa39e9159825657920064298917392d78fa/polars_runtime_32-1.44.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:0580807dc3eed258f0db70bb65d905dd43f0135392119ec25308033ae24258fb", size = 48620468, upload-time = "2026-08-26T07:07:50.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/51/185af877d1d2236671493cf72bd3327a6046240eeea69c0696d1af2a5acb/polars_runtime_32-1.44.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0627f9aa82cb869725235e5188f698862fd9ada0c8c1cf65c3dc5a49a4a0ec26", size = 52455947, upload-time = "2026-08-26T07:07:53.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/0a/0858f60cb5a6f8f73ec4cdd73eccd9f748d66bfc23304c5c23fa3468094a/polars_runtime_32-1.44.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eea4283be8e60822d890dbda20588fe59b4172b508bd5ebf3471e531ca9f50d7", size = 58561262, upload-time = "2026-08-26T07:07:57.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/08/de4774b5612d7c8739f89ac01b601486b4f057b1da35a5b876bf9276fd95/polars_runtime_32-1.44.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:04e2c0f46e7a9906fffb1897f18f23b079b74f83c56b50060bace9e7b9b49b1a", size = 52636064, upload-time = "2026-08-26T07:08:06.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/ac/769c598dd106e2a6647798da3ed25ddeee67f2d12c04f5da316cb3da6360/polars_runtime_32-1.44.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:0956f0cae632d8fad3a04b4315bf2bb69b56d10c83c79a75c2c4c5a13b9ce5cc", size = 56447612, upload-time = "2026-08-26T07:08:12.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/ee/98408296e15388020b6183323fdbe78ccab4f72c20d8e0d7092c062d3ad2/polars_runtime_32-1.44.1-cp310-abi3-win_amd64.whl", hash = "sha256:159334184e6fbb074c9f4692221ea19970a5e2bed2a479f9d7bdb00b7f3eedb9", size = 53702970, upload-time = "2026-08-26T07:08:15.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/9d/8b17e075aac73c881a50b6c1f690d20df46db2f3bcabc99f600ecdee1290/polars_runtime_32-1.44.1-cp310-abi3-win_arm64.whl", hash = "sha256:3ba28d638d0513e0b4afbcdab5c0059a85021e5f81d62b5f793e7e23badb2cf7", size = 47281050, upload-time = "2026-08-26T07:08:18.43Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytumblr"
|
||||
version = "0.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "future" },
|
||||
{ name = "requests-oauthlib" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e3/7c/f4002420a03bc367ee6ac58943b4968ae2cdfd690f1890e0c8cd75e53fbb/PyTumblr-0.1.2.tar.gz", hash = "sha256:eef2653110f84df74a9d8628a4edf30131004de07e93484bc8cc95e44072036e", size = 16655, upload-time = "2023-01-18T16:18:50.992Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/82/fb/4623b76a15f198aab85aa91a4bbc29e4cd1d52ed9dbd44f15e6af7dde4ce/PyTumblr-0.1.2-py2.py3-none-any.whl", hash = "sha256:4435bf4ddbd3543a980a2920788914e8e41a9b91b255eabfd22dd7a2f4eedfec", size = 19083, upload-time = "2023-01-18T16:18:48.862Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.34.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests-oauthlib"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "oauthlib" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tumblr-stats"
|
||||
version = "1.0.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "polars" },
|
||||
{ name = "pytumblr" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "polars", specifier = "==1.44.1" },
|
||||
{ name = "pytumblr", specifier = "==0.1.2" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user