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.
- ao3_py-2025.1.1/.github/workflows/pypi.yml +19 -0
- ao3_py-2025.1.1/LICENSE +21 -0
- ao3_py-2025.1.1/PKG-INFO +55 -0
- ao3_py-2025.1.1/README.md +43 -0
- ao3_py-2025.1.1/ao3/__init__.py +0 -0
- ao3_py-2025.1.1/ao3/ao3.py +52 -0
- ao3_py-2025.1.1/ao3/ao3.pyi +28 -0
- ao3_py-2025.1.1/ao3/fandom.py +71 -0
- ao3_py-2025.1.1/ao3/fandom.pyi +18 -0
- ao3_py-2025.1.1/ao3/tag.py +201 -0
- ao3_py-2025.1.1/ao3/tag.pyi +25 -0
- ao3_py-2025.1.1/ao3/work.py +303 -0
- ao3_py-2025.1.1/ao3/work.pyi +56 -0
- ao3_py-2025.1.1/pyproject.toml +15 -0
|
@@ -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 }}
|
ao3_py-2025.1.1/LICENSE
ADDED
|
@@ -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.
|
ao3_py-2025.1.1/PKG-INFO
ADDED
|
@@ -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"]
|