faapi 3.11.7__tar.gz → 3.11.9__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: faapi
3
- Version: 3.11.7
3
+ Version: 3.11.9
4
4
  Summary: Python module to implement API-like functionality for the FurAffinity.net website.
5
5
  Home-page: https://github.com/FurryCoders/FAAPI
6
6
  License: EUPL-1.2
@@ -0,0 +1 @@
1
+ __version__ = "3.11.9"
@@ -131,6 +131,7 @@ class Comment:
131
131
  self.date = datetime.fromtimestamp(parsed["timestamp"])
132
132
  self.author = faapi.user.UserPartial()
133
133
  self.author.name = parsed["user_name"]
134
+ self.author.display_name = parsed["user_display_name"]
134
135
  self.author.title = parsed["user_title"]
135
136
  self.author.avatar_url = parsed["avatar_url"]
136
137
  self.text = parsed["text"]
@@ -141,6 +141,7 @@ class JournalPartial(JournalBase):
141
141
  self.id = parsed["id"]
142
142
  self.title = parsed["title"]
143
143
  self.author.name = parsed.get("user_name", "")
144
+ self.author.display_name = parsed.get("user_display_name", "")
144
145
  self.author.status = parsed.get("user_status", "")
145
146
  self.author.title = parsed.get("user_title", "")
146
147
  self.author.join_date = parsed.get("user_join_date", "")
@@ -220,6 +221,7 @@ class Journal(JournalBase):
220
221
  self.id = parsed["id"]
221
222
  self.title = parsed["title"]
222
223
  self.author.name = parsed["user_info"]["name"]
224
+ self.author.display_name = parsed["user_info"]["display_name"]
223
225
  self.author.status = parsed["user_info"]["status"]
224
226
  self.author.title = parsed["user_info"]["title"]
225
227
  self.author.join_date = parsed["user_info"]["join_date"]
@@ -1,9 +1,9 @@
1
1
  from datetime import datetime
2
- from re import MULTILINE
3
- from re import Match
4
- from re import Pattern
5
2
  from re import compile as re_compile
3
+ from re import Match
6
4
  from re import match
5
+ from re import MULTILINE
6
+ from re import Pattern
7
7
  from re import search
8
8
  from re import sub
9
9
  from typing import Any
@@ -19,14 +19,14 @@ from dateutil.parser import parse as parse_date
19
19
  from urllib3.util import parse_url
20
20
 
21
21
  from .connection import root
22
+ from .exceptions import _raise_exception
22
23
  from .exceptions import DisabledAccount
23
- from .exceptions import NoTitle
24
24
  from .exceptions import NonePage
25
25
  from .exceptions import NotFound
26
26
  from .exceptions import NoticeMessage
27
+ from .exceptions import NoTitle
27
28
  from .exceptions import ParsingError
28
29
  from .exceptions import ServerError
29
- from .exceptions import _raise_exception
30
30
 
31
31
  relative_url: Pattern = re_compile(r"^(?:https?://(?:www\.)?furaffinity\.net)?(.*)")
32
32
  mentions_regexp: Pattern = re_compile(r"^(?:(?:https?://)?(?:www\.)?furaffinity\.net)?/user/([^/#]+).*$")
@@ -130,9 +130,11 @@ def html_to_bbcode(html: str) -> str:
130
130
 
131
131
  for a in body.select("a"):
132
132
  href_match: Optional[Match] = relative_url.match(a.attrs.get('href', ''))
133
- a.replaceWith(f"[url={href_match[1] if href_match else a.attrs.get('href', '')}]",
134
- *a.children,
135
- "[/url]")
133
+ a.replaceWith(
134
+ f"[url={href_match[1] if href_match else a.attrs.get('href', '')}]",
135
+ *a.children,
136
+ "[/url]"
137
+ )
136
138
 
137
139
  for yt in body.select("iframe[src*='youtube.com/embed']"):
138
140
  yt.replaceWith(f"[yt]https://youtube.com/embed/{yt.attrs.get('src', '').strip('/').split('/')}[/yt]")
@@ -144,31 +146,33 @@ def html_to_bbcode(html: str) -> str:
144
146
  quote_name_tag.replaceWith(quote_author)
145
147
  continue
146
148
  quote_name_tag.decompose()
147
- quote_tag.replaceWith(f"[quote{('=' + quote_author) if quote_author else ''}]",
148
- *quote_tag.children,
149
- "[/quote]")
149
+ quote_tag.replaceWith(
150
+ f"[quote{('=' + quote_author) if quote_author else ''}]",
151
+ *quote_tag.children,
152
+ "[/quote]"
153
+ )
150
154
 
151
155
  for quote_tag in body.select("span.bbcode.bbcode_quote"):
152
156
  quote_tag.replaceWith("[quote]", *quote_tag.children, "[/quote]")
153
157
 
154
158
  for [selector, bbcode_tag] in (
155
- ("i", "i"),
156
- ("b", "b"),
157
- ("strong", "b"),
158
- ("u", "u"),
159
- ("s", "s"),
160
- ("code.bbcode_left", "left"),
161
- ("code.bbcode_center", "center"),
162
- ("code.bbcode_right", "right"),
163
- ("span.bbcode_spoiler", "spoiler"),
164
- ("sub", "sub"),
165
- ("sup", "sup"),
166
- ("h1", "h1"),
167
- ("h2", "h2"),
168
- ("h3", "h3"),
169
- ("h4", "h4"),
170
- ("h5", "h5"),
171
- ("h6", "h6"),
159
+ ("i", "i"),
160
+ ("b", "b"),
161
+ ("strong", "b"),
162
+ ("u", "u"),
163
+ ("s", "s"),
164
+ ("code.bbcode_left", "left"),
165
+ ("code.bbcode_center", "center"),
166
+ ("code.bbcode_right", "right"),
167
+ ("span.bbcode_spoiler", "spoiler"),
168
+ ("sub", "sub"),
169
+ ("sup", "sup"),
170
+ ("h1", "h1"),
171
+ ("h2", "h2"),
172
+ ("h3", "h3"),
173
+ ("h4", "h4"),
174
+ ("h5", "h5"),
175
+ ("h6", "h6"),
172
176
  ):
173
177
  for tag in body.select(selector):
174
178
  tag.replaceWith(f"[{bbcode_tag}]", *tag.children, f"[/{bbcode_tag}]")
@@ -183,9 +187,11 @@ def html_to_bbcode(html: str) -> str:
183
187
  if not (div_class := tag.attrs.get("class", None)):
184
188
  tag.replaceWith(f"[tag={tag.name}]", *tag.children, "[/tag.{tag.name}]")
185
189
  else:
186
- tag.replaceWith(f"[tag={tag.name}.{' '.join(div_class) if isinstance(div_class, list) else div_class}]",
187
- *tag.children,
188
- "[/tag]")
190
+ tag.replaceWith(
191
+ f"[tag={tag.name}.{' '.join(div_class) if isinstance(div_class, list) else div_class}]",
192
+ *tag.children,
193
+ "[/tag]"
194
+ )
189
195
 
190
196
  bbcode: str = body.decode_contents()
191
197
 
@@ -193,15 +199,15 @@ def html_to_bbcode(html: str) -> str:
193
199
  bbcode = sub("^ *", "", bbcode, flags=MULTILINE)
194
200
 
195
201
  for char, substitution in (
196
- ("©", "(c)"),
197
- ("™", "(tm)"),
198
- ("®", "(r)"),
199
- ("©", "(c)"),
200
- ("®", "(tm)"),
201
- ("™", "(r)"),
202
- ("&lt;", "<"),
203
- ("&gt;", ">"),
204
- ("&amp;", "&"),
202
+ ("©", "(c)"),
203
+ ("™", "(tm)"),
204
+ ("®", "(r)"),
205
+ ("&copy;", "(c)"),
206
+ ("&reg;", "(tm)"),
207
+ ("&trade;", "(r)"),
208
+ ("&lt;", "<"),
209
+ ("&gt;", ">"),
210
+ ("&amp;", "&"),
205
211
  ):
206
212
  bbcode = bbcode.replace(char, substitution)
207
213
 
@@ -251,8 +257,11 @@ def bbcode_to_html(bbcode: str) -> str:
251
257
  child_new = Tag(name="a", attrs={"class": "iconusername", "href": f"/user/{user}"})
252
258
  child_new_img: Tag = Tag(
253
259
  name="img",
254
- attrs={"alt": user, "title": user,
255
- "src": f"//a.furaffinity.net/{datetime.now():%Y%m%d}/{username_url(user)}.gif"})
260
+ attrs={
261
+ "alt": user, "title": user,
262
+ "src": f"//a.furaffinity.net/{datetime.now():%Y%m%d}/{username_url(user)}.gif"
263
+ }
264
+ )
256
265
  child_new.insert(0, child_new_img)
257
266
  if m_[2]:
258
267
  child_new.insert(1, f"\xA0{m_[2]}")
@@ -455,22 +464,26 @@ def parse_submission_author(author_tag: Tag) -> dict[str, Any]:
455
464
 
456
465
  assert tag_author is not None, _raise_exception(ParsingError("Missing author tag"))
457
466
 
458
- tag_author_name: Optional[Tag] = tag_author.select_one("a > strong")
467
+ tag_author_name: Optional[Tag] = tag_author.select_one("span.c-usernameBlockSimple__displayName")
459
468
  tag_author_icon: Optional[Tag] = author_tag.select_one("img.submission-user-icon")
460
469
 
461
470
  assert tag_author_name is not None, _raise_exception(ParsingError("Missing author name tag"))
462
471
  assert tag_author_icon is not None, _raise_exception(ParsingError("Missing author icon tag"))
463
472
 
464
- author_name: str = tag_author_name.text.strip()
465
- author_title: str = ([*filter(bool, [child.strip()
466
- for child in tag_author.children
467
- if isinstance(child, NavigableString)][3:])] or [""])[-1]
473
+ author_name: str = tag_author_name.attrs["title"].strip()
474
+ author_display_name: str = tag_author_name.text.strip()
475
+ author_title: str = ([*filter(
476
+ bool, [child.strip()
477
+ for child in tag_author.children
478
+ if isinstance(child, NavigableString)][3:]
479
+ )] or [""])[-1]
468
480
  author_title = author_title if tag_author.select_one('a[href$="/#tip"]') is None else sub(r"\|$", "", author_title)
469
481
  author_title = author_title.strip("\xA0 ") # NBSP
470
482
  author_icon_url: str = "https:" + get_attr(tag_author_icon, "src")
471
483
 
472
484
  return {
473
485
  "author": author_name,
486
+ "author_display_name": author_display_name,
474
487
  "author_title": author_title,
475
488
  "author_icon_url": author_icon_url,
476
489
  }
@@ -564,9 +577,11 @@ def parse_submission_page(sub_page: BeautifulSoup) -> dict[str, Any]:
564
577
  thumbnail_url = f"{thumbnail_url.rsplit('/', 1)[0]}/{quote(thumbnail_url.rsplit('/', 1)[1])}" \
565
578
  if thumbnail_url else ""
566
579
  prev_sub: Optional[int] = int(
567
- get_attr(tag_prev, "href").split("/")[-2]) if tag_prev and tag_prev.text.lower() == "prev" else None
580
+ get_attr(tag_prev, "href").split("/")[-2]
581
+ ) if tag_prev and tag_prev.text.lower() == "prev" else None
568
582
  next_sub: Optional[int] = int(
569
- get_attr(tag_next, "href").split("/")[-2]) if tag_next and tag_next.text.lower() == "next" else None
583
+ get_attr(tag_next, "href").split("/")[-2]
584
+ ) if tag_next and tag_next.text.lower() == "next" else None
570
585
  fav_link: Optional[str] = f"{root}{href}" if (href := get_attr(tag_fav, "href")).startswith("/fav/") else None
571
586
  unfav_link: Optional[str] = f"{root}{href}" if (href := get_attr(tag_fav, "href")).startswith("/unfav/") else None
572
587
  user_folders: list[tuple[str, str, str]] = []
@@ -574,11 +589,13 @@ def parse_submission_page(sub_page: BeautifulSoup) -> dict[str, Any]:
574
589
  tag_folder_name: Optional[Tag] = a.select_one("span")
575
590
  tag_folder_group: Optional[Tag] = a.select_one("strong")
576
591
  assert tag_folder_name is not None, _raise_exception(ParsingError("Missing folder name tag"))
577
- user_folders.append((
578
- tag_folder_name.text.strip(),
579
- (root + href) if (href := a.attrs.get("href", "")) else "",
580
- tag_folder_group.text.strip() if tag_folder_group else ""
581
- ))
592
+ user_folders.append(
593
+ (
594
+ tag_folder_name.text.strip(),
595
+ (root + href) if (href := a.attrs.get("href", "")) else "",
596
+ tag_folder_group.text.strip() if tag_folder_group else ""
597
+ )
598
+ )
582
599
 
583
600
  return {
584
601
  "id": id_,
@@ -609,19 +626,21 @@ def parse_submission_page(sub_page: BeautifulSoup) -> dict[str, Any]:
609
626
 
610
627
 
611
628
  def parse_user_header(user_header: Tag) -> dict[str, Any]:
612
- tag_status: Optional[Tag] = user_header.select_one("userpage-nav-user-details h1 username")
613
- tag_title_join_date: Optional[Tag] = user_header.select_one("userpage-nav-user-details username.user-title")
629
+ tag_user_name: Optional[Tag] = user_header.select_one("a.c-usernameBlock__userName")
630
+ tag_user_display_name: Optional[Tag] = user_header.select_one("a.c-usernameBlock__displayName")
631
+ tag_title_join_date: Optional[Tag] = user_header.select_one("userpage-nav-user-details span.user-title")
614
632
  tag_avatar: Optional[Tag] = user_header.select_one("userpage-nav-avatar img")
615
633
 
616
- assert tag_status is not None, _raise_exception(ParsingError("Missing name tag"))
634
+ assert tag_user_name is not None, _raise_exception(ParsingError("Missing user name tag"))
635
+ assert tag_user_display_name is not None, _raise_exception(ParsingError("Missing user display name tag"))
617
636
  assert tag_title_join_date is not None, _raise_exception(ParsingError("Missing join date tag"))
618
637
  assert tag_avatar is not None, _raise_exception(ParsingError("Missing user icon tag"))
619
638
 
620
- status: str = ""
621
- name: str = tag_status.text.strip()
639
+ tag_user_symbol: Optional[Tag] = tag_user_name.select_one("span.c-usernameBlock__symbol")
622
640
 
623
- if not user_header.select_one("img.type-admin"):
624
- status, name = name[0], name[1:]
641
+ status: str = tag_user_symbol.text.strip() if tag_user_symbol else ""
642
+ name: str = tag_user_name.text.strip().removeprefix(status).strip()
643
+ display_name: str = tag_user_display_name.text.strip()
625
644
 
626
645
  title: str = ttd[0].strip() if len(ttd := tag_title_join_date.text.rsplit("|", 1)) > 1 else ""
627
646
  join_date: datetime = parse_date(ttd[-1].strip().split(":", 1)[1])
@@ -631,6 +650,7 @@ def parse_user_header(user_header: Tag) -> dict[str, Any]:
631
650
  return {
632
651
  "status": status,
633
652
  "name": name,
653
+ "display_name": display_name,
634
654
  "title": title,
635
655
  "join_date": join_date,
636
656
  "avatar_url": avatar_url,
@@ -712,7 +732,9 @@ def parse_user_page(user_page: BeautifulSoup) -> dict[str, Any]:
712
732
 
713
733
  def parse_comment_tag(tag: Tag) -> dict:
714
734
  tag_id: Optional[Tag] = tag.select_one("a.comment_anchor")
715
- tag_username: Optional[Tag] = tag.select_one("comment-username .comment_username")
735
+ tag_user_name: Optional[Tag] = tag.select_one("comment-username a.c-usernameBlock__userName")
736
+ tag_user_symbol: Optional[Tag] = tag_user_name.select_one(".c-usernameBlock__symbol") if tag_user_name else None
737
+ tag_user_display_name: Optional[Tag] = tag.select_one("comment-username a.c-usernameBlock__displayName")
716
738
  tag_avatar: Optional[Tag] = tag.select_one("div.avatar img.comment_useravatar")
717
739
  tag_user_title: Optional[Tag] = tag.select_one("comment-title")
718
740
  tag_body: Optional[Tag] = tag.select_one("comment-user-text")
@@ -730,10 +752,11 @@ def parse_comment_tag(tag: Tag) -> dict:
730
752
  comment_id: int = int(attr_id.removeprefix("cid:"))
731
753
  comment_text: str = clean_html(inner_html(tag_body))
732
754
 
733
- if tag_username is None:
755
+ if tag_user_name is None or tag_user_display_name is None:
734
756
  return {
735
757
  "id": comment_id,
736
758
  "user_name": "",
759
+ "user_display_name": "",
737
760
  "user_title": "",
738
761
  "avatar_url": "",
739
762
  "timestamp": 0,
@@ -764,7 +787,10 @@ def parse_comment_tag(tag: Tag) -> dict:
764
787
 
765
788
  return {
766
789
  "id": comment_id,
767
- "user_name": tag_username.text.strip(),
790
+ "user_name": tag_user_name.text.strip().removeprefix(
791
+ tag_user_symbol.text.strip() if tag_user_symbol else ""
792
+ ).strip(),
793
+ "user_display_name": tag_user_display_name.text.strip(),
768
794
  "user_title": tag_user_title.text.strip(),
769
795
  "avatar_url": avatar_url,
770
796
  "timestamp": int(attr_timestamp),
@@ -257,6 +257,7 @@ class Submission(SubmissionBase):
257
257
  self.id = parsed["id"]
258
258
  self.title = parsed["title"]
259
259
  self.author.name = parsed["author"]
260
+ self.author.display_name = parsed["author_display_name"]
260
261
  self.author.title = parsed["author_title"]
261
262
  self.author.avatar_url = parsed["author_icon_url"]
262
263
  self.date = parsed["date"]
@@ -37,6 +37,7 @@ class UserBase:
37
37
 
38
38
  def __init__(self):
39
39
  self.name: str = ""
40
+ self.display_name: str = ""
40
41
  self.status: str = ""
41
42
 
42
43
  def __hash__(self) -> int:
@@ -79,6 +80,7 @@ class UserBase:
79
80
 
80
81
  def __iter__(self):
81
82
  yield "name", self.name
83
+ yield "display_name", self.display_name
82
84
  yield "status", self.status
83
85
 
84
86
  def __repr__(self):
@@ -195,6 +197,7 @@ class User(UserBase):
195
197
 
196
198
  def __iter__(self):
197
199
  yield "name", self.name
200
+ yield "display_name", self.display_name
198
201
  yield "status", self.status
199
202
  yield "title", self.title
200
203
  yield "join_date", self.join_date
@@ -236,6 +239,7 @@ class User(UserBase):
236
239
  parsed: dict = parse_user_page(self.user_page)
237
240
 
238
241
  self.name = parsed["name"]
242
+ self.display_name = parsed["display_name"]
239
243
  self.status = parsed["status"]
240
244
  self.profile = parsed["profile"]
241
245
  self.title = parsed["title"]
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "faapi"
3
- version = "3.11.7"
3
+ version = "3.11.9"
4
4
  description = "Python module to implement API-like functionality for the FurAffinity.net website."
5
5
  authors = ["Matteo Campinoti <matteo.campinoti94@gmail.com>"]
6
6
  license = "EUPL-1.2"
@@ -16,7 +16,7 @@ install_requires = \
16
16
 
17
17
  setup_kwargs = {
18
18
  'name': 'faapi',
19
- 'version': '3.11.7',
19
+ 'version': '3.11.9',
20
20
  'description': 'Python module to implement API-like functionality for the FurAffinity.net website.',
21
21
  'long_description': '<div align="center">\n\n<img alt="logo" width="400" src="https://raw.githubusercontent.com/FurryCoders/Logos/main/logos/faapi-transparent.png">\n\n# Fur Affinity API\n\nPython library to implement API-like functionality for the [Fur Affinity](https://furaffinity.net) website.\n\n[![](https://img.shields.io/pypi/v/faapi?logo=pypi)](https://pypi.org/project/faapi/)\n[![](https://img.shields.io/pypi/pyversions/faapi?logo=Python)](https://www.python.org)\n\n[![](https://img.shields.io/github/v/tag/FurryCoders/faapi?label=github&sort=date&logo=github&color=blue)](https://github.com/FurryCoders/faapi)\n[![](https://img.shields.io/github/issues/FurryCoders/faapi?logo=github&color=blue)](https://github.com/FurryCoders/FAAPI/issues)\n[![](https://img.shields.io/github/actions/workflow/status/FurryCoders/FAAPI/test.yml?label=Test&logo=githubactions)](https://github.com/FurryCoders/FAAPI/actions/workflows/test.yml)\n[![](https://coveralls.io/repos/github/FurryCoders/FAAPI/badge.svg)](https://coveralls.io/github/FurryCoders/FAAPI)\n\n</div>\n\n## Requirements\n\nPython 3.9+ is necessary to run this\nlibrary. [<img src="https://python-poetry.org/images/logo-origami.svg" height="12"> Poetry](https://python-poetry.org)\nis used for packaging and dependency management.\n\n## Usage\n\nThe API comprises a main class `FAAPI`, two submission classes `Submission` and `SubmissionPartial`, a journal\nclass `Journal`, and a user class `User`.\n\nOnce `FAAPI` is initialized, its methods can be used to crawl FA and return parsed objects.\n\n```python\nfrom requests.cookies import RequestsCookieJar\nimport faapi\nimport orjson\n\ncookies = RequestsCookieJar()\ncookies.set("a", "38565475-3421-3f21-7f63-3d341339737")\ncookies.set("b", "356f5962-5a60-0922-1c11-65003b70308")\n\napi = faapi.FAAPI(cookies)\nsub, sub_file = api.submission(12345678, get_file=True)\n\nprint(sub.id, sub.title, sub.author, f"{len(sub_file) / 1024:02f}KiB")\n\nwith open(f"{sub.id}.json", "wb") as f:\n f.write(orjson.dumps(dict(sub)))\n\nwith open(sub.file_url.split("/")[-1], "wb") as f:\n f.write(sub_file)\n\ngallery, _ = api.gallery("user_name", 1)\nwith open("user_name-gallery.json", "wb") as f:\n f.write(orjson.dumps(list(map(dict, gallery))))\n```\n\n### robots.txt\n\nAt init, the `FAAPI` object downloads the [robots.txt](https://www.furaffinity.net/robots.txt) file from FA to determine\nthe `Crawl-delay` and `disallow` values set therein. If not set in the robots.txt file, a crawl delay value of 1 second\nis used.\n\nTo respect this value, the default behaviour of the `FAAPI` object is to wait when a get request is made if the last\nrequest was performed more recently then the crawl delay value.\n\nSee under [FAAPI](#faapi) for more details on this behaviour.\n\nFurthermore, any get operation that points to a disallowed path from robots.txt will raise an exception. This check\nshould not be circumvented, and the developer of this library does not take responsibility for violations of the TOS of\nFur Affinity.\n\n### Cookies\n\nTo access protected pages, cookies from an active session are needed. These cookies can be given to the FAAPI object as\na list of dictionaries - each containing a `name` and a `value` field -, or as a `http.cookiejar.CookieJar`\nobject (`requests.cookies.RequestsCookieJar` and other objects inheriting from `CookieJar` are also supported). The\ncookies list should look like the following example:\n\n```python\ncookies = [\n {"name": "a", "value": "38565475-3421-3f21-7f63-3d3413397537"},\n {"name": "b", "value": "356f5962-5a60-0922-1c11-65003b703038"},\n]\n```\n\n```python\nfrom requests.cookies import RequestsCookieJar\n\ncookies = RequestsCookieJar()\ncookies.set("a", "38565475-3421-3f21-7f63-3d3413397537")\ncookies.set("b", "356f5962-5a60-0922-1c11-65003b703038")\n```\n\nTo access session cookies, consult the manual of the browser used to log in.\n\n*Note:* it is important to not logout of the session the cookies belong to, otherwise they will no longer work.<br/>\n*Note:* as of April 2022 only cookies `a` and `b` are needed.\n\n### User Agent\n\n`FAAPI` attaches a `User-Agent` header to every request. The user agent string is generated at startup in the following\nformat: `faapi/{library version} Python/{python version} {system name}/{system release}`.\n\n## Objects\n\n### FAAPI\n\nThis is the main object that handles all the calls to scrape pages and get submissions.\n\nIt holds 6 different fields:\n\n* `session: requests.Session` The session used for all requests.\n* `robots: urllib.robotparser.RobotFileParser` robots.txt handler\n* `user_agent: str` user agent used by the session (property, cannot be set)\n* `crawl_delay: float` crawl delay from robots.txt (property, cannot be set)\n* `last_get: float` time of last get (UNIX time)\n* `raise_for_unauthorized: bool = True` if set to `True`, raises an exception if a request is made and the resulting\n page is not from a login session\n* `timeout: int | None = None` requests timeout in seconds for both page requests (e.g. submissions) and files\n\n#### Init\n\n`__init__(cookies: list[dict[str, str]] | CookieJar, session_class: Type[Session] = Session)`\n\nA FAAPI object must be initialised with a cookies object in the format mentioned above in [#Cookies](#cookies).\n\nAn optional `session_class` argument can be given to modify the class used by `FAAPI.session`. Any class based\non `requests.Session` is accepted.\n\n#### Methods & Properties\n\n* `load_cookies(cookies: list[dict[str, str]] | CookieJar)`<br/>\n Load new cookies and create a new session.<br/>\n *Note:* This method removes any cookies currently in use, to update/add single cookies access them from the session\n object.\n* `handle_delay()`<br/>\n Handles the crawl delay as set in the robots.txt\n* `check_path(path: str, *, raise_for_disallowed: bool = False) -> bool`<br/>\n Checks whether a given path is allowed by the robots.txt. If `raise_for_disallowed` is set to `True`\n a `DisallowedPath` exception is raised on non-allowed paths.\n* `connection_status -> bool`<br/>\n Returns the status of the connection.\n* `login_status -> bool`<br/>\n Returns the login status.\n* `get(path: str, **params) -> requests.Response`<br/>\n This returns a response object containing the result of the get operation on the given URL with the\n optional `**params` added to it (url provided is considered as path from \'https://www.furaffinity.net/\').\n* `get_parsed(path: str, *, skip_page_check: bool = False, skip_auth_check: bool = False, **params) -> bs4.BeautifulSoup`<br/>\n Similar to `get()` but returns the parsed HTML from the normal get operation. If the GET request encountered an error,\n an `HTTPError` exception is raised. If `skip_page_check` is set to `True`, the parsed page is not checked for errors (\n e.g. non-existing submission). If `skip_auth_check` is set to `True`, the page is not checked for login status.\n* `me() -> User | None`<br/>\n Returns the logged-in user as a `User` object if the cookies are from a login session.\n* `frontpage() -> list[SubmissionPartial]`<br/>\n Fetch the latest submissions from Fur Affinity\'s front page.\n* `submission(submission_id: int, get_file: bool = False, *, chunk_size: int = None) -> tuple[Submission, bytes | None]`<br/>\n Given a submission ID, it returns a `Submission` object containing the various metadata of the submission itself and\n a `bytes` object with the submission file if `get_file` is passed as `True`. The optional `chunk_size` argument is\n used for the request; if left to `None` or set to 0 the download is performed directly without streaming.<br/>\n *Note:* the author `UserPartial` object of the submission does not contain the `join_date` field as it does not appear\n on submission pages.\n* `submission_file(submission: Submission, *, chunk_size: int = None) -> bytes`<br/>\n Given a submission object, it downloads its file and returns it as a `bytes` object. The optional `chunk_size`\n argument is used for the request; if left to `None` or set to 0 the download is performed directly without streaming.\n* `journal(journal_id: int) -> Journal`<br/>\n Given a journal ID, it returns a `Journal` object containing the various metadata of the journal.\n* `user(user: str) -> User`<br/>\n Given a username, it returns a `User` object containing information regarding the user.\n* `gallery(user: str, page: int = 1) -> tuple[list[SubmissionPartial], int | None]`<br/>\n Returns the list of submissions found on a specific gallery page, and the number of the next page. The returned page\n number is set to `None` if it is the last page.\n* `scraps(user: str, page: int = 1) -> -> tuple[list[SubmissionPartial], int | None]`<br/>\n Returns the list of submissions found on a specific scraps page, and the number of the next page. The returned page\n number is set to `None` if it is the last page.\n* `favorites(user: str, page: str = "") -> tuple[list[SubmissionPartial], str | None]`<br/>\n Downloads a user\'s favorites page. Because of how favorites pages work on FA, the `page` argument (and the one\n returned) are strings. If the favorites page is the last then a `None` is returned as next page. An empty page\n value as argument is equivalent to page 1.<br/>\n *Note:* favorites page "numbers" do not follow any scheme and are only generated server-side.\n* `journals(user: str, page: int = 1) -> -> tuple[list[JournalPartial], int | None]`<br/>\n Returns the list of submissions found on a specific journals page, and the number of the next page. The returned page\n number is set to `None` if it is the last page.\n* `watchlist_to(self, user: str, page:int = 1) -> tuple[list[UserPartial], int | None]`<br/>\n Given a username, returns a list of `UserPartial` objects for each user that is watching the given user and the next\n page, if it is not the last, in which case a `None` is returned.\n* `watchlist_by(self, user: str, page:int = 1) -> tuple[list[UserPartial], int | None]`<br/>\n Given a username, returns a list of `UserPartial` objects for each user that is watched by the given user and the next\n page, if it is not the last, in which case a `None` is returned.\n\n*Note:* The last page returned by the `watchlist_to` and `watchlist_by` may not be correct as Fur Affinity doesn\'t seem\nto have a consistent behaviour when rendering the next page button, as such it is safer to use an external algorithm to\ncheck whether the method is advancing the page but returning the same/no users.\n\n### UserPartial\n\nA stripped-down class that holds basic user information. It is used to hold metadata gathered when parsing a submission,\njournal, gallery, scraps, etc.\n\n* `name: str` display name with capital letters and extra characters such as "_"\n* `status: str` user status (~, !, etc.)\n* `title: str` the user title as it appears on their userpage\n* `join_date: datetime` the date the user joined (defaults to timestamp 0)\n* `avatar_url: str` the URL to the user icon (used only when available)\n* `user_tag: bs4.element.Tag` the user element used to parse information (placeholder, `UserPartial` is filled\n externally)\n\n`UserPartial` objects can be directly cast to a dict object and iterated through.\n\nComparison with `UserPartial` can be made with either another `UserPartial` or `User` object (the URL names are\ncompared), or a string (the URL name is compared to the given string).\n\n#### Init\n\n`__init__(user_tag: bs4.element.Tag = None)`\n\nTo initialise the object, an optional `bs4.element.Tag` object is needed containing the user element from a user page or\nuser folder.\n\nIf no `user_tag` is passed then the object fields will remain at their default - empty - value.\n\n#### Methods\n\n* `name_url -> str`<br/>\n Property method that returns the URL-safe username\n* `url -> str`<br/>\n Property method that returns the Fur Affinity URL to the user (`https://www.furaffinity.net/user/{name_url}`).\n* `generate_avatar_url() -> str`<br/>\n Generates the URl for the current user icon.\n* `parse(user_page: bs4.BeautifulSoup = None)`<br/>\n Parses the stored user page for metadata. If `user_page` is passed, it overwrites the existing `user_page` value.\n\n### User\n\nThe main class storing all of a user\'s metadata.\n\n* `name: str` display name with capital letters and extra characters such as "_"\n* `status: str` user status (~, !, etc.)\n* `title: str` the user title as it appears on their userpage\n* `join_date: datetime` the date the user joined (defaults to timestamp 0)\n* `profile: str` profile text in HTML format\n* `profile_bbcode: str` profile text in BBCode format\n* `stats: UserStats` user statistics sorted in a `namedtuple` (`views`, `submissions`, `favorites`, `comments_earned`\n , `comments_made`, `journals`, `watched_by`, `watching`)\n* `info: dict[str, str]` profile information (e.g. "Accepting Trades", "Accepting Commissions", "Character Species",\n etc.)\n* `contacts: dict[str, str]` contact links (e.g. Twitter, Steam, etc.)\n* `avatar_url: str` the URL to the user icon\n* `banner_url: str | None` the URL to the user banner (if any is set, otherwise `None`)\n* `watched: bool` `True` if the user is watched, `False` otherwise\n* `watched_toggle_link: str | None` The link to toggle the watch status (`/watch/` or `/unwatch/` type link)\n* `blocked: bool` `True` if the user is blocked, `False` otherwise\n* `blocked_toggle_link: str | None` The link to toggle the block status (`/block/` or `/unblock/` type link)\n* `user_page: bs4.BeautifulSoup` the user page used to parse the object fields\n\n`User` objects can be directly cast to a dict object and iterated through.\n\nComparison with `User` can be made with either another `User` or `UserPartial` object (the URL names are compared), or a\nstring (the URL name is compared to the given string).\n\n#### Init\n\n`__init__(user_page: bs4.BeautifulSoup = None)`\n\nTo initialise the object, an optional `bs4.BeautifulSoup` object is needed containing the parsed HTML of a submission\npage.\n\nIf no `user_page` is passed then the object fields will remain at their default - empty - value.\n\n#### Methods\n\n* `name_url -> str`<br/>\n Property method that returns the URL-safe username\n* `url -> str`<br/>\n Property method that returns the Fur Affinity URL to the user (`https://www.furaffinity.net/user/{name_url}`).\n* `generate_avatar_url() -> str`<br/>\n Generates the URl for the current user icon.\n* `parse(user_page: bs4.BeautifulSoup = None)`<br/>\n Parses the stored user page for metadata. If `user_page` is passed, it overwrites the existing `user_page` value.\n\n### JournalPartial\n\nThis object contains partial information gathered when parsing a journals folder. It contains the following fields:\n\n* `id: int` journal ID\n* `title: str` journal title\n* `date: datetime` upload date as a [`datetime` object](https://docs.python.org/3/library/datetime.html) (defaults to\n timestamp 0)\n* `author: UserPartial` journal author (filled only if the journal is parsed from a `bs4.BeautifulSoup` page)\n* `stats: JournalStats` journal statistics stored in a named tuple (`comments` (count))\n* `content: str` journal content in HTML format\n* `content_bbcode: str` journal content in BBCode format\n* `mentions: list[str]` the users mentioned in the content (if they were mentioned as links, e.g. `:iconusername:`,\n `@username`, etc.)\n* `journal_tag: bs4.element.Tag` the journal tag used to parse the object fields\n\n`JournalPartial` objects can be directly cast to a dict object or iterated through.\n\nComparison with `JournalPartial` can be made with either another `JournalPartial` or `Journal` object (the IDs are\ncompared), or an integer (the `JournalPartial.id` value is compared to the given integer).\n\n#### Init\n\n`__init__(journal_tag: bs4.element.Tag = None)`\n\n`Journal` takes one optional parameters: a journal section tag from a journals page.\n\nIf no `journal_tag` is passed then the object fields will remain at their default - empty - value.\n\n#### Methods\n\n* `url -> str`<br/>\n Property method that returns the Fur Affinity URL to the journal (`https://www.furaffinity.net/journal/{id}`).\n* `parse(journal_item: bs4.element.Tag = None)`<br/>\n Parses the stored journal tag for information. If `journal_tag` is passed, it overwrites the existing `journal_tag`\n value.\n\n### Journal\n\nThis object contains full information gathered when parsing a journal page. It contains the same fields\nas `JournalPartial` with the addition of comments:\n\n* `id: int` journal ID\n* `title: str` journal title\n* `date: datetime` upload date as a [`datetime` object](https://docs.python.org/3/library/datetime.html) (defaults to\n timestamp 0)\n* `author: UserPartial` journal author (filled only if the journal is parsed from a `bs4.BeautifulSoup` page)\n* `stats: JournalStats` journal statistics stored in a named tuple (`comments` (count))\n* `content: str` journal content in HTML format\n* `content_bbcode: str` journal content in BBCode format\n* `header: str` journal header in HTML format (if present)\n* `footer: str` journal footer in HTML format (if present)\n* `mentions: list[str]` the users mentioned in the content (if they were mentioned as links, e.g. `:iconusername:`,\n `@username`, etc.)\n* `comments: list[Comments]` the comments to the journal, organised in a tree structure\n* `journal_page: bs4.BeautifulSoup` the journal page used to parse the object fields\n\n`Journal` objects can be directly cast to a dict object or iterated through.\n\nComparison with `Journal` can be made with either another `Journal` or `JournalPartial` object (the IDs are compared),\nor an integer (the `Journal.id` value is compared to the given integer).\n\n#### Init\n\n`__init__(journal_page: bs4.BeautifulSoup = None)`\n\n`Journal` takes one optional journal page argument.\n\nIf no `journal_page` is passed then the object fields will remain at their default - empty - value.\n\n#### Methods\n\n* `url -> str`<br/>\n Property method that returns the Fur Affinity URL to the journal (`https://www.furaffinity.net/journal/{id}`).\n* `parse(journal_page: bs4.BeautifulSoup = None)`<br/>\n Parses the stored journal tag for information. If `journal_tag` is passed, it overwrites the existing `journal_tag`\n value.\n\n### SubmissionPartial\n\nThis lightweight submission object is used to contain the information gathered when parsing gallery, scraps, and\nfavorites pages. It contains only the following fields:\n\n* `id: int` submission ID\n* `title: str` submission title\n* `author: UserPartial` submission author (only the `name` field is filled)\n* `rating: str` submission rating [general, mature, adult]\n* `type: str` submission type [text, image, etc...]\n* `thumbnail_url: str` the URL to the submission thumbnail\n* `submission_figure: bs4.element.Tag` the figure tag used to parse the object fields\n\n`SubmissionPartial` objects can be directly cast to a dict object or iterated through.\n\nComparison with `Submission` can be made with either another `SubmissionPartial` or `Submission` object (the IDs are\ncompared), or an integer (the `Submission.id` value is compared to the given integer).\n\n#### Init\n\n`__init__(submission_figure: bs4.element.Tag = None)`\n\nTo initialise the object, an optional `bs4.element.Tag` object is needed containing the parsed HTML of a submission\nfigure tag.\n\nIf no `submission_figure` is passed then the object fields will remain at their default - empty - value.\n\n#### Methods\n\n* `url -> str`<br/>\n Property method that returns the Fur Affinity URL to the submission (`https://www.furaffinity.net/view/{id}`).\n* `parse(submission_figure: bs4.element.Tag = None)`<br/>\n Parses the stored submission figure tag for information. If `submission_figure` is passed, it overwrites the\n existing `submission_figure` value.\n\n### Submission\n\nThe main class that parses and holds submission metadata.\n\n* `id: int` submission ID\n* `title: str` submission title\n* `author: UserPartial` submission author (only the `name`, `title`, and `avatar_url` fields are filled)\n* `date: datetime` upload date as a [`datetime` object](https://docs.python.org/3/library/datetime.html) (defaults to\n timestamp 0)\n* `tags: list[str]` tags list\n* `category: str` category\n* `species: str` species\n* `gender: str` gender\n* `rating: str` rating\n* `stats: SubmissionStats` submission statistics stored in a named tuple (`views`, `comments` (count), `favorites`)\n* `type: str` submission type (text, image, etc...)\n* `description: str` description in HTML format\n* `description_bbcode: str` description in BBCode format\n* `footer: str` footer in HTML format\n* `mentions: list[str]` the users mentioned in the description (if they were mentioned as links, e.g. `:iconusername:`,\n `@username`, etc.)\n* `folder: str` the submission folder (gallery or scraps)\n* `user_folders: list[SubmissionUserFolder]` user folders stored in a list of named tuples (`name`, `url`, `group` (\n if any))\n* `file_url: str` the URL to the submission file\n* `thumbnail_url: str` the URL to the submission thumbnail\n* `prev: int` the ID of the previous submission (if any)\n* `next: int` the ID of the next submission (if any)\n* `favorite: bool` `True` if the submission is a favorite, `False` otherwise\n* `favorite_toggle_link: str` the link to toggle the favorite status (`/fav/` or `/unfav/` type URL)\n* `comments: list[Comments]` the comments to the submission, organised in a tree structure\n* `submission_page: bs4.BeautifulSoup` the submission page used to parse the object fields\n\n`Submission` objects can be directly cast to a dict object and iterated through.\n\nComparison with `Submission` can be made with either another `Submission` or `SubmissionPartial` object (the IDs are\ncompared), or an integer (the `Submission.id` value is compared to the given integer).\n\n#### Init\n\n`__init__(submission_page: bs4.BeautifulSoup = None)`\n\nTo initialise the object, an optional `bs4.BeautifulSoup` object is needed containing the parsed HTML of a submission\npage.\n\nIf no `submission_page` is passed then the object fields will remain at their default - empty - value.\n\n#### Methods\n\n* `url -> str`<br/>\n Property method that returns the Fur Affinity URL to the submission (`https://www.furaffinity.net/view/{id}`).\n* `parse(submission_page: bs4.BeautifulSoup = None)`<br/>\n Parses the stored submission page for metadata. If `submission_page` is passed, it overwrites the\n existing `submission_page` value.\n\n### Comment\n\nThis object class contains comment metadata and is used to build a tree structure with the comments and their replies.\n\n* `id: int` the comment ID\n* `author: UserPartial` the user who posted the comment\n* `date: datetime` the date the comment was posted\n* `text: str` the comment text in HTML format\n* `text_bbcode: str` the comment text in BBCode format\n* `replies: list[Comment]` list of replies to the comment\n* `reply_to: Comment | int | None` the parent comment, if the comment is a reply. The variable type is `int` only if the\n comment is parsed outside the parse method of a `Submission` or `Journal` (e.g. by creating a new comment with a\n comment tag), and when iterating over the parent object (to avoid infinite recursion errors), be it `Submission`\n , `Journal` or another `Comment`.\n* `edited: bool` `True` if the comment was edited, `False` otherwise\n* `hidden: bool` `True` if the comment was hidden, `False` otherwise (if the comment was hidden, the author and date\n fields will default to their empty values)\n* `parent: Submission | Journal | None` the `Submission` or `Journal` object the comments are connected to\n* `comment_tag: bs4.element.Tag` the comment tag used to parse the object fields\n\n`Comment` objects can be directly cast to a dict object and iterated through.\n\nComparison with `Comment` can be made with either another comment (the IDs are compared), or an integer (\nthe `Comment.id` value is compared to the given integer).\n\n*Note:* The `__iter__` method of `Comment` objects automatically removes recursion. The `parent` variable is set\nto `None` and `reply_to` is set to the comment\'s ID.<br/>\n*Note:* Because each comment contains the parent `Submission` or `Journal` object (which contains the comment itself)\nand the replied comment object, some iterations may cause infinite recursion errors, for example when using\nthe `copy.deepcopy` function. If such iterations are needed, simply set the `parent` variable to `None` and\nthe `reply_to` variable to `None` or the comment\'s ID (this can be done easily after flattening the comments list\nwith `faapi.comment.flatten_comments`, the comments can then be sorted again with `faapi.comment.sort_comments` which\nwill also restore the `reply_to` values to `Comment` objects).\n\n#### Init\n\n`__init__(self, tag: bs4.element.Tag = None, parent: Submission | Journal = None)`\n\nTo initialise the object, an optional `bs4.element.Tag` object is needed containing the comment tag as taken from a\nsubmission/journal page.\n\nThe optional `parent` argument sets the `parent` variable described above.\n\nIf no `tag` is passed then the object fields will remain at their default - empty - value.\n\n#### Methods\n\n* `url -> str`<br/>\n Property method that returns the Fur Affinity URL to the comment (\n e.g. `https://www.furaffinity.net/view/12345678#cid:1234567890`). If the `parent` variable is `None`, the property\n returns an empty string.\n* `parse(tag: bs4.element.Tag = None)`<br/>\n Parses the stored tag for metadata. If `tag` is passed, it overwrites the existing `tag` value.\n\n#### Extra Functions\n\nThese extra functions can be used to operate on a list of comments. They only alter the order and structure, but they do\nnot touch any of the metadata.\n\n* `faapi.comment.sort_comments(comments: list[Comment]) -> list[Comment]`<br/>\n Sorts a list of comments into a tree structure. Replies are overwritten.\n* `faapi.comment.flatten_comments(comments: list[Comment]) -> list[Comment]`<br/>\n Flattens a list of comments. Replies are not modified.\n\n#### Comment Tree Graphs\n\nUsing the tree structure generated by the library, it is trivial to build a graph visualisation of the comment tree\nusing the [DOT](https://www.graphviz.org/doc/info/lang.html) language.\n\n```python\nsubmission, _ = api.submission(12345678)\ncomments = faapi.comment.flatten_comments(submission.comments)\nwith open("comments.dot", "w") as f:\n f.write("digraph {\\n")\n for comment in [c for c in comments if c.reply_to is None]:\n f.write(f" parent -> {comment.id}\\n")\n for comment in comments:\n for reply in comment.replies:\n f.write(f" {comment.id} -> {reply.id}\\n")\n f.write("}")\n```\n\n```dot\ndigraph {\n parent -> 157990848\n parent -> 157993838\n parent -> 157997294\n 157990848 -> 158014077\n 158014077 -> 158014816\n 158014816 -> 158093180\n 158093180 -> 158097024\n 157993838 -> 157998464\n 157993838 -> 158014126\n 157997294 -> 158014135\n 158014135 -> 158014470\n 158014135 -> 158030074\n 158014470 -> 158093185\n 158030074 -> 158093199\n}\n```\n\n<img alt="comments tree graph" width="400" src="https://quickchart.io/graphviz?graph=digraph%7B%0Aparent-%3E157990848%0Aparent-%3E157993838%0Aparent-%3E157997294%0A157990848-%3E158014077%0A158014077-%3E158014816%0A158014816-%3E158093180%0A158093180-%3E158097024%0A157993838-%3E157998464%0A157993838-%3E158014126%0A157997294-%3E158014135%0A158014135-%3E158014470%0A158014135-%3E158030074%0A158014470-%3E158093185%0A158030074-%3E158093199%0A%7D">\n\n_The graph above was generated with [quickchart.io](https://quickchart.io/documentation/graphviz-api/)_\n\n## BBCode Conversion\n\nUsing the BBCode fields allows to convert between the raw HTMl recovered from Fur Affinity and BBCode tags that follow\nFA\'s guidelines. Conversion from HTML to BBCode covers all known tags and preserves all newlines and spacing.\n\nBBCode text can be converted to Fur Affinity\'s HTMl using the `faapi.parse.bbcode_to_html()` function. The majority of\nsubmissions can be converted back and forth between HTML and BBCode without any information loss, however, the parser\nrules are still a work in progress and there are many edge cases where unusual text and formatting cause the parser to\ngenerate incorrect HTML.\n\n## Exceptions\n\nThe following are the exceptions explicitly raised by the FAAPI functions. The exceptions deriving from `ParsingError`\nare chosen depending on the content of the page. Because Fur Affinity doesn\'t use HTTP status codes besides 404, the\npage is checked against a static list of known error messages/page titles in order to determine the specific error to be\nused. If no match is found, then the `ServerError` (if the page has the "Server Error" title) or the more\ngeneral `NoticeMessage` exceptions are used instead. The actual error message parsed from the page is used as argument\nfor the exceptions, so that it can be analysed when caught.\n\n* `DisallowedPath(Exception)` The path is not allowed by the robots.txt.\n* `Unauthorized(Exception)` The user is not logged-in.\n* `ParsingError(Exception)` An error occurred while parsing the page.\n * `NonePage(ParsingError)` The parsed page is `None`.\n * `NotFound(ParsingError)` The resource could not be found (general 404 page or non-existing submission, user, or\n journal).\n * `NoTitle(ParsingError)` The parsed paged is missing a title.\n * `DisabledAccount(ParsingError)` The resource belongs to a disabled account.\n * `ServerError(ParsingError)` The page contains a server error notice.\n * `NoticeMessage(ParsingError)` A notice of unknown type was found in the page.\n\n## Beautiful Soup Warnings\n\nWhen parsing some pages or converting HTML to BBCode, the [Beautiful Soup](https://pypi.org/project/beautifulsoup4/)\nlibrary may give some warnings, for example `MarkupResemblesLocatorWarning`. These warnings are left enabled for\nclarity, but can be disabled manually using the `warnings.filterwarnings` function.\n\n## Contributing\n\nAll contributions and suggestions are welcome!\n\nIf you have suggestions for fixes or improvements, you can open an issue with your idea, see [#Issues](#issues) for\ndetails.\n\n## Issues\n\nIf any problem is encountered during usage of the program, an issue can be opened\non [GitHub](https://github.com/FurryCoders/FAAPI/issues).\n\nIssues can also be used to suggest improvements and features.\n\nWhen opening an issue for a problem, please copy the error message and describe the operation in progress when the error\noccurred.\n',
22
22
  'author': 'Matteo Campinoti',
@@ -1 +0,0 @@
1
- __version__ = "3.11.7"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes