ao3-py 2025.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ name: pypi
2
+
3
+ on: push
4
+
5
+ jobs:
6
+ pypi:
7
+ if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
8
+ runs-on: ubuntu-latest
9
+ steps:
10
+ - uses: actions/checkout@master
11
+ - uses: actions/setup-python@master
12
+ with:
13
+ python-version: "3.x"
14
+ - run: |
15
+ python -m pip install build --user
16
+ python -m build --sdist --wheel --outdir dist
17
+ - uses: pypa/gh-action-pypi-publish@master
18
+ with:
19
+ password: ${{ secrets.PYPI_API_TOKEN }}
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 OrganRemoved
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,55 @@
1
+ Metadata-Version: 2.4
2
+ Name: ao3-py
3
+ Version: 2025.1.1
4
+ Summary: an unofficial python sdk for Archive Of Our Own (AO3)
5
+ License: MIT License
6
+ License-File: LICENSE
7
+ Requires-Python: >=3
8
+ Requires-Dist: beautifulsoup4
9
+ Requires-Dist: lxml
10
+ Requires-Dist: requests
11
+ Description-Content-Type: text/markdown
12
+
13
+ # ao3-py
14
+
15
+ An unofficial python sdk for [Archive Of Our Own (AO3)](https://archiveofourown.org).
16
+
17
+ ## Feature
18
+
19
+ - Completely type hint
20
+ - Object orient
21
+ - Lazy loading
22
+
23
+ ## Quick Start
24
+
25
+ ### Installation
26
+
27
+ #### Install from PYPI
28
+ ```sh
29
+ pip install --no-cache --upgrade ao3-py
30
+ ```
31
+
32
+ #### Install from Github
33
+
34
+ ```sh
35
+ pip install --no-cache --upgrade git+https://github.com/OrganRemoved/ao3-py.git
36
+ ```
37
+
38
+ or
39
+
40
+ ```sh
41
+ pip install --no-cache --upgrade https://github.com/OrganRemoved/ao3-py/archive/refs/heads/main.zip
42
+ ```
43
+
44
+ ### Example
45
+ ```python
46
+ from ao3 import AO3
47
+
48
+ client = AO3()
49
+
50
+ client.get_fandom(...)
51
+
52
+ client.get_tag(...)
53
+
54
+ client.get_work(...)
55
+ ```
@@ -0,0 +1,43 @@
1
+ # ao3-py
2
+
3
+ An unofficial python sdk for [Archive Of Our Own (AO3)](https://archiveofourown.org).
4
+
5
+ ## Feature
6
+
7
+ - Completely type hint
8
+ - Object orient
9
+ - Lazy loading
10
+
11
+ ## Quick Start
12
+
13
+ ### Installation
14
+
15
+ #### Install from PYPI
16
+ ```sh
17
+ pip install --no-cache --upgrade ao3-py
18
+ ```
19
+
20
+ #### Install from Github
21
+
22
+ ```sh
23
+ pip install --no-cache --upgrade git+https://github.com/OrganRemoved/ao3-py.git
24
+ ```
25
+
26
+ or
27
+
28
+ ```sh
29
+ pip install --no-cache --upgrade https://github.com/OrganRemoved/ao3-py/archive/refs/heads/main.zip
30
+ ```
31
+
32
+ ### Example
33
+ ```python
34
+ from ao3 import AO3
35
+
36
+ client = AO3()
37
+
38
+ client.get_fandom(...)
39
+
40
+ client.get_tag(...)
41
+
42
+ client.get_work(...)
43
+ ```
File without changes
@@ -0,0 +1,52 @@
1
+ import logging
2
+ from typing import Literal
3
+
4
+ import requests
5
+
6
+ from ao3.fandom import Fandom
7
+ from ao3.tag import Tag
8
+ from ao3.work import Work
9
+
10
+ logging.basicConfig(
11
+ level=logging.INFO,
12
+ format="[%(asctime)s][%(levelname)s][%(module)s:%(funcName)s:%(lineno)d]: %(message)s",
13
+ )
14
+
15
+
16
+ class AO3:
17
+ def __init__(self) -> None:
18
+ self.session = requests.Session()
19
+
20
+ def get_fandom(
21
+ self,
22
+ fandom: Literal[
23
+ "Anime & Manga",
24
+ "Books & Literature",
25
+ "Cartoons & Comics & Graphic Novels",
26
+ "Celebrities & Real People",
27
+ "Movies",
28
+ "Music & Bands",
29
+ "Other Media",
30
+ "Theater",
31
+ "TV Shows",
32
+ "Video Games",
33
+ "Uncategorized Fandoms",
34
+ ],
35
+ ) -> Fandom:
36
+ return Fandom(session=self.session, name=fandom)
37
+
38
+ def get_tag(self, tag: str, page: int = 1, view_adult: bool = True) -> Tag:
39
+ return Tag(
40
+ session=self.session,
41
+ name=tag,
42
+ href=f"/tags/{tag}/works",
43
+ page=page,
44
+ view_adult=view_adult,
45
+ )
46
+
47
+ def get_work(self, work_id: int, chapter_id: int | None = None) -> Work:
48
+ return (
49
+ Work(session=self.session, href=f"/works/{work_id}/chapters/{chapter_id}")
50
+ if chapter_id
51
+ else Work(session=self.session, href=f"/works/{work_id}")
52
+ )
@@ -0,0 +1,28 @@
1
+ from typing import Literal
2
+
3
+ from ao3.fandom import Fandom
4
+ from ao3.tag import Tag
5
+ from ao3.work import Work
6
+
7
+ class AO3:
8
+ def __init__(self) -> None: ...
9
+ def get_fandom(
10
+ self,
11
+ fandom: Literal[
12
+ "Anime & Manga",
13
+ "Books & Literature",
14
+ "Cartoons & Comics & Graphic Novels",
15
+ "Celebrities & Real People",
16
+ "Movies",
17
+ "Music & Bands",
18
+ "Other Media",
19
+ "Theater",
20
+ "TV Shows",
21
+ "Video Games",
22
+ "Uncategorized Fandoms",
23
+ ],
24
+ ) -> Fandom: ...
25
+ def get_tag(self, tag: str, page: int = 1, view_adult: bool = True) -> Tag: ...
26
+ def get_work(
27
+ self, work_id: int, chapter_id: int | None = None, entire_work: bool = False
28
+ ) -> Work: ...
@@ -0,0 +1,71 @@
1
+ import re
2
+ from dataclasses import KW_ONLY, dataclass, field
3
+ from functools import cached_property
4
+ from typing import List
5
+ from urllib.parse import urljoin
6
+
7
+ import requests
8
+ from bs4 import BeautifulSoup
9
+
10
+ from ao3.tag import Tag
11
+
12
+
13
+ @dataclass
14
+ class Fandom:
15
+ _: KW_ONLY
16
+
17
+ session: requests.Session = field(default_factory=requests.Session)
18
+
19
+ name: str
20
+
21
+ def __post_init__(self) -> None:
22
+ soup = BeautifulSoup(
23
+ self.session.get("https://archiveofourown.org/media").text, features="lxml"
24
+ )
25
+
26
+ for li in soup.find("ul", {"class": "media fandom index group"}).find_all( # type: ignore
27
+ "li", {"class": "medium listbox group"}
28
+ ):
29
+ if heading := li.find("h3", {"class": "heading"}).find(
30
+ "a", string=self.name
31
+ ):
32
+ self.href = heading["href"]
33
+
34
+ self.hot_tags = [
35
+ Tag(
36
+ session=self.session,
37
+ name=a.text,
38
+ href=a["href"],
39
+ works_count=int(
40
+ re.search(
41
+ r"\((?P<works_count>\d+)\)", fandom.text
42
+ ).groupdict()["works_count"] # type: ignore
43
+ ),
44
+ )
45
+ for fandom in li.find("ol", {"class": "index group"}).find_all("li")
46
+ if (a := fandom.find("a", {"class": "tag"}))
47
+ ]
48
+
49
+ @cached_property
50
+ def tags(self) -> List[Tag]:
51
+ soup = BeautifulSoup(
52
+ self.session.get(urljoin("https://archiveofourown.org", self.href)).text,
53
+ features="lxml",
54
+ )
55
+
56
+ return [
57
+ Tag(
58
+ session=self.session,
59
+ name=a.text,
60
+ href=a["href"],
61
+ letter=li["id"].split("-")[1],
62
+ )
63
+ for li in soup.find(
64
+ "ol", {"class": "alphabet fandom index group"}
65
+ ).find_all("li", {"class": "letter listbox group"}) # type: ignore
66
+ for li_ in li.find("ul", {"class": "tags index group"}).find_all("li")
67
+ if (a := li_.find("a", {"class": "tag"}))
68
+ ]
69
+
70
+ def __repr__(self) -> str:
71
+ return f"{self.__class__.__name__}(name={self.name})"
@@ -0,0 +1,18 @@
1
+ from dataclasses import KW_ONLY, dataclass
2
+ from typing import List
3
+
4
+ import requests
5
+
6
+ from ao3.tag import Tag
7
+
8
+ @dataclass
9
+ class Fandom:
10
+ _: KW_ONLY
11
+
12
+ session: requests.Session
13
+
14
+ name: str
15
+ href: str | None = None
16
+
17
+ hot_tags: List[Tag] | None = None
18
+ tags: List[Tag] | None = None
@@ -0,0 +1,201 @@
1
+ import re
2
+ from dataclasses import KW_ONLY, dataclass, field
3
+ from itertools import groupby
4
+ from operator import itemgetter
5
+ from typing import Any, Type
6
+ from urllib.parse import urljoin, urlparse
7
+
8
+ import requests
9
+ from bs4 import BeautifulSoup
10
+
11
+
12
+ class Descriptor:
13
+ def __set_name__(self, owner: Type["Tag"], name: str) -> None:
14
+ self.name = f"_{name}"
15
+
16
+ def __get__(self, instance: "Tag", owner: Type["Tag"]) -> Any:
17
+ if instance is None:
18
+ return self
19
+
20
+ if not hasattr(instance, self.name):
21
+ from ao3.work import Work
22
+
23
+ resp = instance.session.get(
24
+ urljoin("https://archiveofourown.org", instance.href),
25
+ params={"page": instance.page, "view_adult": instance.view_adult},
26
+ )
27
+
28
+ soup = BeautifulSoup(resp.text, features="lxml")
29
+
30
+ if urlparse(resp.url).path.endswith("/works"):
31
+ if works_count := re.search(
32
+ r"(?P<works_count>[0-9,]+) Works",
33
+ soup.find("h2", {"class": "heading"}).text, # type: ignore
34
+ ):
35
+ setattr(
36
+ instance,
37
+ "_works_count",
38
+ int(works_count.groupdict()["works_count"].replace(",", "")),
39
+ )
40
+
41
+ works = []
42
+
43
+ for li in soup.find("ol", {"class": "work index group"}).find_all( # type: ignore
44
+ "li", {"role": "article"}
45
+ ):
46
+ header = li.find("div", {"class": "header module"})
47
+
48
+ title = header.find("h4", {"class": "heading"}).find("a")
49
+
50
+ work = Work(session=instance.session, href=title["href"])
51
+
52
+ setattr(work, "_title", title.text)
53
+
54
+ if elem := header.find("h4", {"class": "heading"}).find(
55
+ "a", {"rel": "author"}
56
+ ):
57
+ setattr(work, "_author", elem.text)
58
+
59
+ rating, warnings, category, complete = header.find(
60
+ "ul", {"class": "required-tags"}
61
+ ).find_all("li")
62
+
63
+ setattr(
64
+ work,
65
+ "_complete",
66
+ bool(complete.find("span", {"class": "complete-yes"})),
67
+ )
68
+
69
+ for class_, grouper in groupby(
70
+ li.find("ul", {"class": "tags commas"}).find_all("li"),
71
+ key=itemgetter("class"),
72
+ ):
73
+ match class_:
74
+ case ["warnings"]:
75
+ setattr(
76
+ work,
77
+ "_archive_warning",
78
+ [
79
+ Tag(
80
+ session=instance.session,
81
+ name=a.text,
82
+ href=a["href"],
83
+ )
84
+ for tag in grouper
85
+ if (a := tag.find("a", {"class": "tag"}))
86
+ ],
87
+ )
88
+
89
+ case ["relationships"]:
90
+ setattr(
91
+ work,
92
+ "_relationships",
93
+ [
94
+ Tag(
95
+ session=instance.session,
96
+ name=a.text,
97
+ href=a["href"],
98
+ )
99
+ for tag in grouper
100
+ if (a := tag.find("a", {"class": "tag"}))
101
+ ],
102
+ )
103
+
104
+ case ["characters"]:
105
+ setattr(
106
+ work,
107
+ "_characters",
108
+ [
109
+ Tag(
110
+ session=instance.session,
111
+ name=a.text,
112
+ href=a["href"],
113
+ )
114
+ for tag in grouper
115
+ if (a := tag.find("a", {"class": "tag"}))
116
+ ],
117
+ )
118
+
119
+ case ["freeforms"]:
120
+ setattr(
121
+ work,
122
+ "_additional_tags",
123
+ [
124
+ Tag(
125
+ session=instance.session,
126
+ name=a.text,
127
+ href=a["href"],
128
+ )
129
+ for tag in grouper
130
+ if (a := tag.find("a", {"class": "tag"}))
131
+ ],
132
+ )
133
+
134
+ stats = li.find("dl", {"class": "stats"})
135
+
136
+ if elem := stats.find("dd", {"class": "language"}):
137
+ setattr(work, "_language", elem.text)
138
+
139
+ if elem := stats.find("dd", {"class": "words"}):
140
+ setattr(work, "_words", int(elem.text.replace(",", "")))
141
+
142
+ if elem := stats.find("dd", {"class": "chapters"}):
143
+ chapter, chapter_count = elem.text.split("/")
144
+
145
+ setattr(work, "_chapter_number", int(chapter))
146
+ setattr(
147
+ work,
148
+ "_chapter_count",
149
+ None if chapter_count == "?" else int(chapter_count),
150
+ )
151
+
152
+ if elem := stats.find("dd", {"class": "comments"}):
153
+ setattr(work, "_comments", int(elem.text))
154
+
155
+ if elem := stats.find("dd", {"class": "kudos"}):
156
+ setattr(work, "_kudos", int(elem.text))
157
+
158
+ if elem := stats.find("dd", {"class": "hits"}):
159
+ setattr(work, "_hits", int(elem.text.replace(",", "")))
160
+
161
+ if elem := li.find("blockquote", {"class": "userstuff summary"}):
162
+ setattr(work, "_summary", "\n".join(elem.strings).strip())
163
+
164
+ works.append(work)
165
+
166
+ setattr(instance, "_works", works)
167
+
168
+ else:
169
+ raise NotImplementedError
170
+
171
+ if not hasattr(instance, self.name):
172
+ setattr(instance, self.name, None)
173
+ return
174
+
175
+ return getattr(instance, self.name)
176
+
177
+ def __set__(self, instance: "Tag", value: Any) -> None:
178
+ setattr(instance, f"_{value}", value)
179
+
180
+
181
+ @dataclass
182
+ class Tag:
183
+ _: KW_ONLY
184
+
185
+ session: requests.Session = field(default_factory=requests.Session)
186
+
187
+ name: str
188
+ href: str
189
+
190
+ view_adult: bool = True
191
+
192
+ letter: str | None = None
193
+
194
+ works: Descriptor = Descriptor()
195
+ works_count: Descriptor = Descriptor()
196
+ bookmarks: Descriptor = Descriptor()
197
+ page: int = 1
198
+ page_count: Descriptor = Descriptor()
199
+
200
+ def __repr__(self) -> str:
201
+ return f"{self.__class__.__name__}(name={self.name})"
@@ -0,0 +1,25 @@
1
+ from dataclasses import KW_ONLY, dataclass
2
+ from typing import List
3
+
4
+ import requests
5
+
6
+ from ao3.work import Work
7
+
8
+ @dataclass
9
+ class Tag:
10
+ _: KW_ONLY
11
+
12
+ session: requests.Session
13
+
14
+ name: str
15
+ href: str
16
+
17
+ view_adult: bool = True
18
+
19
+ letter: str | None = None
20
+
21
+ works: List[Work] | None = None
22
+ works_count: int | None = None
23
+ bookmarks: int | None = None
24
+ page: int | None = None
25
+ page_count: int | None = None
@@ -0,0 +1,303 @@
1
+ from dataclasses import KW_ONLY, dataclass, field
2
+ from datetime import datetime
3
+ from typing import Any, Type
4
+ from urllib.parse import urljoin, urlparse
5
+
6
+ import requests
7
+ from bs4 import BeautifulSoup
8
+
9
+
10
+ @dataclass
11
+ class Chapter:
12
+ _: KW_ONLY
13
+
14
+ session: requests.Session = field(default_factory=requests.Session, repr=False)
15
+
16
+ title: str | None = None
17
+ summary: str | None = None
18
+ notes: str | None = None
19
+ article: str | None = None
20
+ end_notes: str | None = None
21
+
22
+
23
+ class Descriptor:
24
+ def __set_name__(self, owner: Type["Work"], name: str) -> None:
25
+ self.name = f"_{name}"
26
+
27
+ def __get__(self, instance: "Work", owner: Type["Work"]) -> Any:
28
+ if instance is None:
29
+ return self
30
+
31
+ if not hasattr(instance, self.name):
32
+ from ao3.tag import Tag
33
+
34
+ resp = instance.session.get(
35
+ urljoin("https://archiveofourown.org", instance.href)
36
+ )
37
+
38
+ soup = BeautifulSoup(resp.text, features="lxml")
39
+
40
+ for dd in (
41
+ soup.find("div", {"class": "wrapper"})
42
+ .find("dl", {"class": "work meta group"}) # type: ignore
43
+ .find_all("dd") # type: ignore
44
+ ):
45
+ match dd["class"]:
46
+ case ["rating", "tags"]:
47
+ setattr(
48
+ instance,
49
+ "_rating",
50
+ [
51
+ Tag(
52
+ session=instance.session,
53
+ name=a.text,
54
+ href=a["href"],
55
+ )
56
+ for a in dd.find("ul", {"class": "commas"}).find_all(
57
+ "a", {"class": "tag"}
58
+ )
59
+ ],
60
+ )
61
+
62
+ case ["warning", "tags"]:
63
+ setattr(
64
+ instance,
65
+ "_archive_warning",
66
+ [
67
+ Tag(
68
+ session=instance.session,
69
+ name=a.text,
70
+ href=a["href"],
71
+ )
72
+ for a in dd.find("ul", {"class": "commas"}).find_all(
73
+ "a", {"class": "tag"}
74
+ )
75
+ ],
76
+ )
77
+
78
+ case ["category", "tags"]:
79
+ setattr(
80
+ instance,
81
+ "_category",
82
+ [
83
+ Tag(
84
+ session=instance.session,
85
+ name=a.text,
86
+ href=a["href"],
87
+ )
88
+ for a in dd.find("ul", {"class": "commas"}).find_all(
89
+ "a", {"class": "tag"}
90
+ )
91
+ ],
92
+ )
93
+
94
+ case ["fandom", "tags"]:
95
+ setattr(
96
+ instance,
97
+ "_fandom",
98
+ [
99
+ Tag(
100
+ session=instance.session,
101
+ name=a.text,
102
+ href=a["href"],
103
+ )
104
+ for a in dd.find("ul", {"class": "commas"}).find_all(
105
+ "a", {"class": "tag"}
106
+ )
107
+ ],
108
+ )
109
+
110
+ case ["relationship", "tags"]:
111
+ setattr(
112
+ instance,
113
+ "_relationships",
114
+ [
115
+ Tag(
116
+ session=instance.session,
117
+ name=a.text,
118
+ href=a["href"],
119
+ )
120
+ for a in dd.find("ul", {"class": "commas"}).find_all(
121
+ "a", {"class": "tag"}
122
+ )
123
+ ],
124
+ )
125
+
126
+ case ["language"]:
127
+ setattr(instance, "_language", dd.text.strip())
128
+
129
+ case ["stats"]:
130
+ stats = dd.find("dl", {"class": "stats"})
131
+
132
+ if elem := stats.find("dd", {"class": "published"}):
133
+ setattr(
134
+ instance,
135
+ "_published",
136
+ datetime.fromisoformat(elem.text),
137
+ )
138
+
139
+ if elem := stats.find("dd", {"class": "status"}):
140
+ setattr(
141
+ instance, "_status", datetime.fromisoformat(elem.text)
142
+ )
143
+ setattr(instance, "_complete", False)
144
+ else:
145
+ setattr(instance, "_status", None)
146
+ setattr(instance, "_complete", True)
147
+
148
+ if elem := stats.find("dd", {"class": "words"}):
149
+ setattr(instance, "_words", int(elem.text.replace(",", "")))
150
+
151
+ if elem := stats.find("dd", {"class": "chapters"}):
152
+ chapter, chapter_count = elem.text.split("/")
153
+
154
+ setattr(instance, "_chapter_number", int(chapter))
155
+ setattr(
156
+ instance,
157
+ "_chapter_count",
158
+ None if chapter_count == "?" else int(chapter_count),
159
+ )
160
+
161
+ if elem := stats.find("dd", {"class": "comments"}):
162
+ setattr(
163
+ instance, "_comments", int(elem.text.replace(",", ""))
164
+ )
165
+
166
+ if elem := stats.find("dd", {"class": "kudos"}):
167
+ setattr(instance, "_kudos", int(elem.text.replace(",", "")))
168
+
169
+ if elem := stats.find("dd", {"class": "hits"}):
170
+ setattr(instance, "_hits", int(elem.text.replace(",", "")))
171
+
172
+ workskin = soup.find("div", {"id": "workskin"})
173
+
174
+ preface = workskin.find("div", {"class": "preface group"}) # type: ignore
175
+
176
+ setattr(
177
+ instance,
178
+ "_title",
179
+ preface.find("h2", {"class": "title heading"}).text.strip(), # type: ignore
180
+ )
181
+
182
+ if elem := preface.find("h3", {"class": "byline heading"}).find( # type: ignore
183
+ "a",
184
+ {"rel": "author"}, # type: ignore
185
+ ):
186
+ setattr(instance, "_author", elem.text) # type: ignore
187
+ else:
188
+ setattr(instance, "_author", None)
189
+
190
+ if elem := preface.find("div", {"class": "summary module"}): # type: ignore
191
+ setattr(
192
+ instance,
193
+ "_summary",
194
+ "\n".join(
195
+ elem.find("blockquote", {"class": "userstuff"}).strings # type: ignore
196
+ ).strip(),
197
+ )
198
+
199
+ chapters = []
200
+
201
+ for chapter in workskin.find("div", {"id": "chapters"}).find_all( # type: ignore
202
+ "div", {"class": "chapter"}, recursive=False
203
+ ):
204
+ c = Chapter(session=instance.session)
205
+
206
+ if elem := chapter.find("h3", {"class": "title"}):
207
+ setattr(c, "title", elem.text)
208
+
209
+ if elem := chapter.find("div", {"id": "summary"}):
210
+ setattr(
211
+ c,
212
+ "summary",
213
+ "\n".join(
214
+ elem.find("blockquote", {"class": "userstuff"}).strings
215
+ ).strip(),
216
+ )
217
+
218
+ if (elem := chapter.find("div", {"id": "notes"})) and (
219
+ notes := elem.find("blockquote", {"class": "userstuff"})
220
+ ):
221
+ setattr(c, "notes", "\n".join(notes.strings).strip())
222
+
223
+ if elem := chapter.find("div", {"role": "article"}):
224
+ setattr(c, "article", "\n".join(elem.strings).strip())
225
+
226
+ if elem := chapter.find("div", {"class": "end notes module"}):
227
+ setattr(
228
+ c,
229
+ "end_notes",
230
+ "\n".join(
231
+ elem.find("blockquote", {"class": "userstuff"}).strings
232
+ ).strip(),
233
+ )
234
+
235
+ chapters.append(c)
236
+
237
+ if elem := workskin.find("div", {"class": "userstuff"}): # type: ignore
238
+ chapters.append(
239
+ Chapter(session=instance.session, article="\n".join(elem.strings)) # type: ignore
240
+ )
241
+
242
+ setattr(instance, "_chapters", chapters)
243
+
244
+ if not hasattr(instance, self.name):
245
+ setattr(instance, self.name, None)
246
+ return
247
+
248
+ return getattr(instance, self.name)
249
+
250
+ def __set__(self, instance: "Work", value: Any) -> None:
251
+ setattr(instance, f"_{value}", value)
252
+
253
+
254
+ @dataclass
255
+ class Work:
256
+ _: KW_ONLY
257
+
258
+ session: requests.Session = field(default_factory=requests.Session)
259
+
260
+ href: str
261
+
262
+ work_id: Descriptor = Descriptor()
263
+ chapter_id: Descriptor = Descriptor()
264
+
265
+ rating: Descriptor = Descriptor()
266
+ archive_warning: Descriptor = Descriptor()
267
+ fandoms: Descriptor = Descriptor()
268
+ relationships: Descriptor = Descriptor()
269
+ characters: Descriptor = Descriptor()
270
+ additional_tags: Descriptor = Descriptor()
271
+ language: Descriptor = Descriptor()
272
+
273
+ published: Descriptor = Descriptor()
274
+ status: Descriptor = Descriptor()
275
+ words: Descriptor = Descriptor()
276
+ chapter_number: Descriptor = Descriptor()
277
+ chapter_count: Descriptor = Descriptor()
278
+ comments: Descriptor = Descriptor()
279
+ kudos: Descriptor = Descriptor()
280
+ bookmarks: Descriptor = Descriptor()
281
+ hits: Descriptor = Descriptor()
282
+
283
+ author: Descriptor = Descriptor()
284
+ title: Descriptor = Descriptor()
285
+ summary: Descriptor = Descriptor()
286
+
287
+ chapters: Descriptor = Descriptor()
288
+
289
+ def __post_init__(self) -> None:
290
+ match [part for part in urlparse(self.href).path.split("/") if part]:
291
+ case ["works", work_id, "chapters", chapter_id]:
292
+ setattr(self, "_work_id", int(work_id))
293
+ setattr(self, "_chapter_id", int(chapter_id))
294
+
295
+ case ["works", work_id]:
296
+ setattr(self, "_work_id", int(work_id))
297
+ setattr(self, "_chapter_id", None)
298
+
299
+ case _:
300
+ raise ValueError(f"Unknow href: {self.href}")
301
+
302
+ def __repr__(self) -> str:
303
+ return f"{self.__class__.__name__}(name={self.title}, author={self.author})"
@@ -0,0 +1,56 @@
1
+ from dataclasses import KW_ONLY, dataclass
2
+ from datetime import datetime
3
+ from typing import List
4
+
5
+ import requests
6
+
7
+ from ao3.tag import Tag
8
+
9
+ @dataclass
10
+ class Chapter:
11
+ _: KW_ONLY
12
+
13
+ session: requests.Session
14
+
15
+ title: str | None = None
16
+ summary: str | None = None
17
+ notes: str | None = None
18
+ article: str | None = None
19
+ end_notes: str | None = None
20
+
21
+ @dataclass
22
+ class Work:
23
+ _: KW_ONLY
24
+
25
+ session: requests.Session
26
+
27
+ href: str | None = None
28
+
29
+ work_id: int | None = None
30
+ chapter_id: int | None = None
31
+
32
+ rating: List[Tag] | None = None
33
+ archive_warning: List[Tag] | None = None
34
+ fandoms: List[Tag] | None = None
35
+ relationships: List[Tag] | None = None
36
+ characters: List[Tag] | None = None
37
+ additional_tags: List[Tag] | None = None
38
+ language: str | None = None
39
+
40
+ complete: bool | None = None
41
+
42
+ published: datetime | None = None
43
+ status: datetime | None = None
44
+ words: int | None = None
45
+ chapter_number: int | None = None
46
+ chapter_count: int | None = None
47
+ comments: int | None = None
48
+ kudos: int | None = None
49
+ bookmarks: int | None = None
50
+ hits: int | None = None
51
+
52
+ author: str | None = None
53
+ title: str | None = None
54
+ summary: str | None = None
55
+
56
+ chapters: List[Chapter] | None = None
@@ -0,0 +1,15 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ao3-py"
7
+ version = "2025.1.1"
8
+ dependencies = ["beautifulsoup4", "lxml","requests"]
9
+ requires-python = ">=3"
10
+ description = "an unofficial python sdk for Archive Of Our Own (AO3)"
11
+ readme = "README.md"
12
+ license = {text = "MIT License"}
13
+
14
+ [tool.hatch.build.targets.wheel]
15
+ packages = ["ao3"]