Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/page-loaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
getTagWorksFeedAtomUrl,
getTagWorksFeedUrl,
getUserProfileUrl,
getUserWorksUrl,
getWorkUrl,
} from "./urls";

Expand Down Expand Up @@ -101,6 +102,15 @@ export const loadUserProfilePage = async ({
});
};

export interface UserWorksPage extends CheerioAPI {
kind: 'UserWorksPage'
}
export const loadUserWorksList = async ({ username, page = 0 }: { username: string, page: number }) => {
return await fetchPage<UserWorksPage>({
url: getUserWorksUrl({ username, page }),
});
}

export interface ChapterIndexPage extends CheerioAPI {
kind: "ChapterIndexPage";
}
Expand Down
3 changes: 3 additions & 0 deletions src/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ export const getWorkUrl = ({
export const getUserProfileUrl = ({ username }: { username: string }) =>
`https://archiveofourown.org/users/${encodeURI(username)}/profile`;

export const getUserWorksUrl = ({ username, page = 0 }: { username: string, page?: number }) =>
`https://archiveofourown.org/users/${encodeURI(username)}/works?page=${page}`

export const getTagUrl = (tagName: string) =>
`https://archiveofourown.org/tags/${encodeURI(tagName)
.replaceAll("/", "*s*")
Expand Down
34 changes: 33 additions & 1 deletion src/users/getters.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { UserProfile } from "../page-loaders";
import { UserProfile, UserWorksPage } from "../page-loaders";
import { getUserProfileUrl } from "../urls";

//Dates are ten characters long in the following format:
Expand Down Expand Up @@ -123,3 +123,35 @@ export const getUserProfileGifts = ($userProfile: UserProfile) => {
.slice(GIFTS_PREFIX.length, -STAT_SUFFIX.length) || "0"
);
};


export const getTotalPages = ($page: UserWorksPage) => {
const lastNumberPagination = $page('.pagination li:has(+ .next)');

return parseInt(lastNumberPagination.text(), 10);
}

export const getWorkCount = ($page: UserWorksPage) => {
const worksNavItem = $page('.navigation.actions:nth-child(2) li:first-child');
return parseInt(worksNavItem.text().replaceAll(/\D/g, ''), 10);
}

export const getSeriesCount = ($page: UserWorksPage) => {
const seriesNavItem = $page('.navigation.actions:nth-child(2) li:nth-child(3)');
return parseInt(seriesNavItem.text().replaceAll(/\D/g, ''), 10);
}

export const getBookmarksCount = ($page: UserWorksPage) => {
const bookmarksNavItem = $page('.navigation.actions:nth-child(2) li:nth-child(4)');
return parseInt(bookmarksNavItem.text().replaceAll(/\D/g, ''), 10);
}

export const getCollectionsCount = ($page: UserWorksPage) => {
const collectionsNavItem = $page('.navigation.actions:nth-child(2) li:last-child');
return parseInt(collectionsNavItem.text().replaceAll(/\D/g, ''), 10);
}

export const getGiftsCount = ($page: UserWorksPage) => {
const giftsNavItem = $page('.navigation.actions:last-child li:last-child');
return parseInt(giftsNavItem.text().replaceAll(/\D/g, ''), 10);
}
74 changes: 72 additions & 2 deletions src/users/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import {
getBookmarksCount,
getCollectionsCount,
getGiftsCount,
getSeriesCount,
getTotalPages,
getUserProfileBio,
getUserProfileBirthday,
getUserProfileBookmarks,
Expand All @@ -13,11 +18,12 @@ import {
getUserProfilePseuds,
getUserProfileSeries,
getUserProfileWorks,
getWorkCount,
} from "./getters";

import { User } from "types/entities";
import { User, UserWorks, WorkPreview } from "types/entities";
import { getUserProfileUrl } from "../urls";
import { loadUserProfilePage } from "../page-loaders";
import { loadUserProfilePage, loadUserWorksList, loadWorkPage, UserWorksPage } from "../page-loaders";

export const getUser = async ({
username,
Expand Down Expand Up @@ -46,3 +52,67 @@ export const getUser = async ({
bioHtml: getUserProfileBio(profilePage),
};
};

const parseUserWorksIntoObject = ($userWorks: UserWorksPage) => {
/**
* It's just easier for me to reason this way,
* I can move this into a more correct file later
*/
const itemSelector = '.index.work.group > li';
const selectors = {
kudos: '.kudos + .kudos a',
comments: '.comments + .comments a',
chapters: '.chapters + .chapters a',
words: '.words + .words',
hits: '.hits + .hits',
bookmarks: '.bookmarks + .bookmarks a',
title: '.heading a:first-child',
fandom: '.fandoms a',
category: '.category .text',
rating: '.rating .text',
warnings: '.warnings .tag',
complete: '.iswip .text',
datetime: '.header.module .datetime',
}
const numberKeys = ['kudos','comments','chapters','words','hits','bookmarks']
const works: WorkPreview[] = [];
// unfortunately $userWorks(selector).map doesn't return an Array, it returns a Cheerio
$userWorks(itemSelector).each((_i, el) => {
const data = {} as WorkPreview;
const $item = $userWorks(el);
/**
* Parse into a number if it is a number data point
* otherwise pass in the text
*/
for (const [key, selector] of Object.entries(selectors)) {
data[key] = numberKeys.includes(key) ? parseInt($item.find(selector).text(), 10) : $item.find(selector).text();
}
works.push(data as WorkPreview);
})

return works
}

export const getUserWorks = async ({ username, page = 0 }: { username: string, page?: number }): Promise<UserWorks> => {
const worksPage = await loadUserWorksList({ username, page });
// parse current works page
// check for next page
// if next page
// loop it
// else return data
return {
username,
pageInfo: {
currentPage: page,
totalPages: getTotalPages(worksPage),
},
counts: {
works: getWorkCount(worksPage),
series: getSeriesCount(worksPage),
bookmarks: getBookmarksCount(worksPage),
collections: getCollectionsCount(worksPage),
gifts: getGiftsCount(worksPage),
},
worksInPage: parseUserWorksIntoObject(worksPage)
}
}
9 changes: 8 additions & 1 deletion tests/user.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getUser } from "src/index";
import { getUser, getUserWorks } from "src/index";
import { User } from "types/entities";

//NOTE: Some of these tests may fail if the referenced user has updated their profile!
Expand Down Expand Up @@ -44,4 +44,11 @@ describe("Fetches id data.", () => {
header: "Yes, it's really spelled with a Z",
} satisfies Partial<User>);
});

test('Fetches user works list', async () => {
const works = await getUserWorks({
username: 'franzeska'
});
console.log(works);
})
});
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"src/*": ["./src/*"],
"types/*": ["./types/*"]
},
"resolveJsonModule": true
"resolveJsonModule": true,
"lib": ["es2022"]
},
"include": ["**/*.ts"],
"exclude": ["node_modules/"]
Expand Down
57 changes: 41 additions & 16 deletions types/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,14 +123,10 @@ export interface Author {
anonymous: boolean;
}

export interface WorkSummary {
export interface WorkPreview extends Record<string, any> {
id: string;
title: string;
category: WorkCategory[] | null;
// Date in ISO format. See: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString
// Note that AO3 doesn't publish the actual time of publish, just the date.
publishedAt: string;
updatedAt: string | null;
// TODO: should this be in HTML?
summary: string | null;
rating: WorkRatings;
Expand All @@ -144,37 +140,65 @@ export interface WorkSummary {
relationships: string[];
additional: string[];
};
language: string;
words: number;
complete: boolean;
series: BasicSeries[];
stats: {
bookmarks: number;
comments: number;
kudos: number;
hits: number;
};
locked: false;
// If the author is anonymous this array will contain a single
// entry whose "anonymous" property is "true".
authors: Author[];
language: string;
words: number;
chapters: {
published: number;
total: number | null;
};
// Date in ISO format. See: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString
// Note that AO3 doesn't publish the actual time of publish, just the date.
updatedAt: string|null;
}

export interface WorkSummary extends WorkPreview {
// Date in ISO format. See: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString
// Note that AO3 doesn't publish the actual time of publish, just the date.
publishedAt: string;
chapterInfo: {
id: string;
index: number;
name: string | null;
summary: string | null;
} | null;
series: BasicSeries[];
complete: boolean;
stats: {
bookmarks: number;
comments: number;
kudos: number;
hits: number;
};
locked: false;
}

export interface LockedWorkSummary {
id: string;
locked: true;
}


export interface UserWorks {
username: string;
// very unsure about name
counts: {
works: number;
series: number;
bookmarks: number;
collections: number;
gifts: number;
}
pageInfo: {
currentPage: number;
totalPages: number;
}
worksInPage: WorkPreview[];
}


export interface Chapter {
id: string;
workId: string;
Expand All @@ -183,3 +207,4 @@ export interface Chapter {
publishedAt: string;
url: string;
}