hfr-api 0.0.2__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.
hfr_api-0.0.2/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 dotvav
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.
hfr_api-0.0.2/PKG-INFO ADDED
@@ -0,0 +1,27 @@
1
+ Metadata-Version: 2.2
2
+ Name: hfr_api
3
+ Version: 0.0.2
4
+ Summary: A Python library to interface with forum.hardware.fr
5
+ Home-page: https://gitea.ruk.info/roukine/hfr
6
+ Author: MycRub
7
+ Author-email: mycrub@mycrub.net
8
+ License: MIT
9
+ Requires-Python: >=3.13
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: requests==2.32.3
13
+ Requires-Dist: beautifulsoup4==4.12.3
14
+ Requires-Dist: sortedcontainers==2.4.0
15
+ Dynamic: author
16
+ Dynamic: author-email
17
+ Dynamic: description
18
+ Dynamic: description-content-type
19
+ Dynamic: home-page
20
+ Dynamic: license
21
+ Dynamic: requires-dist
22
+ Dynamic: requires-python
23
+ Dynamic: summary
24
+
25
+ # HFR
26
+
27
+ A python library to interface with forum.hardware.fr
@@ -0,0 +1,3 @@
1
+ # HFR
2
+
3
+ A python library to interface with forum.hardware.fr
@@ -0,0 +1,5 @@
1
+ """hfr_api: A Python library to interface with forum.hardware.fr"""
2
+
3
+ from . import bb
4
+ from .message import Message
5
+ from .topic import Topic
@@ -0,0 +1,118 @@
1
+ """BB code handling"""
2
+
3
+ from bs4 import BeautifulSoup, NavigableString
4
+
5
+
6
+ def convert_inline_tags(html: str, context: dict = {}) -> str:
7
+ soup = BeautifulSoup(html, "html.parser")
8
+
9
+ for element in soup.find_all(True, recursive=False):
10
+ if element.name == "strong":
11
+ element.replace_with(
12
+ NavigableString(
13
+ f"[b]{convert_inline_tags(element.decode_contents(), context)}[/b]"
14
+ )
15
+ )
16
+ elif element.name == "span" and "u" in element.get("class", []):
17
+ element.replace_with(
18
+ NavigableString(
19
+ f"[u]{convert_inline_tags(element.decode_contents(), context)}[/u]"
20
+ )
21
+ )
22
+ elif element.name == "span" and element.get("style"):
23
+ style = element.get("style")
24
+ if len(style) > 7 and style[:7] == "color:#":
25
+ color = style[-6:]
26
+ element.replace_with(
27
+ NavigableString(
28
+ f"[#{color}]{convert_inline_tags(element.decode_contents(), context)}[/#{color}]"
29
+ )
30
+ )
31
+ elif element.name == "em":
32
+ element.replace_with(
33
+ NavigableString(
34
+ f"[i]{convert_inline_tags(element.decode_contents(), context)}[/i]"
35
+ )
36
+ )
37
+ elif element.name == "strike":
38
+ element.replace_with(
39
+ NavigableString(
40
+ f"[strike]{convert_inline_tags(element.decode_contents(), context)}[/strike]"
41
+ )
42
+ )
43
+ elif element.name in ("ul", "ol"):
44
+ context["not_first_line"] = False
45
+ element.replace_with(
46
+ NavigableString(
47
+ f"{convert_inline_tags(element.decode_contents(), context)}"
48
+ )
49
+ )
50
+ context["not_first_line"] = True
51
+ elif element.name == "li":
52
+ table_class = context.get("table_class", "")
53
+ bullet_style = "" if table_class == "code" else "[*]"
54
+ new_line = "\n" if context.get("not_first_line", False) else ""
55
+ context["not_first_line"] = True
56
+ element.replace_with(
57
+ NavigableString(
58
+ f"{new_line}{bullet_style}{convert_inline_tags(element.decode_contents(), context)}"
59
+ )
60
+ )
61
+ elif element.name == "a":
62
+ href = element.get("href", "")
63
+ if len(href) > 6 and href[:7] == "mailto:":
64
+ element.replace_with(NavigableString(f"[email]{href[7:]}[/email]"))
65
+ else:
66
+ element.replace_with(
67
+ NavigableString(
68
+ f"[url={href}]{convert_inline_tags(element.decode_contents(), context)}[/url]"
69
+ )
70
+ )
71
+ elif element.name == "img":
72
+ src = element.get("src", "")
73
+ alt = element.get("alt", "")
74
+ if alt[0] in ("[", ":"):
75
+ # For smileys, just use the alt text
76
+ element.replace_with(NavigableString(alt))
77
+ else:
78
+ # For regular images, convert to BB code format
79
+ element.replace_with(NavigableString(f"[img]{src}[/img]"))
80
+ elif element.name == "table":
81
+ table_class = element.get("class")[0] if element.get("class") else ""
82
+ new_context = {"table_class": table_class}
83
+ if "citation" in table_class:
84
+ bb_tag = "quotemsg"
85
+ href = element.find("a").get("href")
86
+ bb_details = f"={href.split('#t')[1]},0,0" # TODO create reference =msgid,?,userid
87
+ else:
88
+ bb_tag = table_class
89
+ bb_details = ""
90
+ content = element.find(["p", "ol"])
91
+ if content:
92
+ converted_content = convert_inline_tags(
93
+ content.decode_contents(), new_context
94
+ )
95
+ if len(converted_content) and converted_content[-1] == "\n":
96
+ converted_content = converted_content[
97
+ :-1
98
+ ] # remove stupid trailing "\n"
99
+ element.replace_with(
100
+ NavigableString(
101
+ f"[{bb_tag}{bb_details}]{converted_content}[/{bb_tag}]"
102
+ )
103
+ )
104
+ else:
105
+ if not isinstance(element, NavigableString):
106
+ element.replace_with(
107
+ NavigableString(
108
+ convert_inline_tags(element.decode_contents(), context)
109
+ )
110
+ )
111
+
112
+ return soup.get_text()
113
+
114
+
115
+ def html_to_bb(html: str) -> str:
116
+ return convert_inline_tags(
117
+ html.replace("&nbsp;", "").replace("\n", "").replace("<br />", "\n")
118
+ ).strip()
@@ -0,0 +1,67 @@
1
+ """An HFR message"""
2
+
3
+ from datetime import datetime
4
+ from typing import TYPE_CHECKING
5
+
6
+ from bs4 import NavigableString
7
+
8
+ from . import bb
9
+
10
+ if TYPE_CHECKING:
11
+ from .topic import Topic
12
+
13
+
14
+ class Message:
15
+ def __init__(
16
+ self, topic, id: int, posted_at: datetime, author: str, text: str
17
+ ) -> None:
18
+ self.topic = topic
19
+ self.id = id
20
+ self.posted_at = posted_at
21
+ self.author = author
22
+ self.text = text
23
+
24
+ @classmethod
25
+ def from_html(cls, topic: "Topic", html: NavigableString):
26
+ case1 = html.find("td", class_="messCase1")
27
+
28
+ author = case1.find("b", class_="s2").string.replace("\u200b", "")
29
+ if author == "Publicité":
30
+ return None
31
+
32
+ id = case1.find("a", rel="nofollow").attrs["href"][2:]
33
+
34
+ case2 = html.find("td", class_="messCase2")
35
+ posted_at_str = (
36
+ case2.find("div", class_="toolbar").find("div", class_="left").string
37
+ )
38
+ posted_at = Message.parse_timestamp(posted_at_str)
39
+
40
+ text_tag = case2.find("div", id=f"para{id}")
41
+ text = bb.html_to_bb(text_tag.decode_contents())
42
+
43
+ return cls(topic, id, posted_at, author, text)
44
+
45
+ @staticmethod
46
+ def parse_timestamp(timestamp_str: str) -> datetime:
47
+ d = timestamp_str[9:19]
48
+ t = timestamp_str[22:30]
49
+ return datetime.strptime(f"{d} {t}", "%d-%m-%Y %H:%M:%S")
50
+
51
+ def to_dict(self) -> dict:
52
+ return {
53
+ "id": self.id,
54
+ "author": self.author,
55
+ "posted_at": str(self.posted_at),
56
+ "text": self.text,
57
+ }
58
+
59
+ @classmethod
60
+ def from_dict(cls, topic, data: dict):
61
+ return cls(
62
+ topic,
63
+ data["id"],
64
+ datetime.fromtimestamp(int(data["posted_at"])),
65
+ data["author"],
66
+ data["text"],
67
+ )
@@ -0,0 +1,147 @@
1
+ """An HFR Topic"""
2
+
3
+ import logging
4
+ import time
5
+ from datetime import date, datetime
6
+
7
+ import requests
8
+ from bs4 import BeautifulSoup
9
+ from sortedcontainers import SortedDict
10
+
11
+ from .message import Message
12
+
13
+ logger = logging.getLogger()
14
+
15
+
16
+ def date_to_str(some_date: str | date | datetime) -> str:
17
+ if isinstance(some_date, datetime):
18
+ return str(some_date.date())
19
+ elif isinstance(some_date, date):
20
+ return str(some_date)
21
+ elif isinstance(some_date, str):
22
+ return some_date
23
+ return None
24
+
25
+
26
+ class Topic:
27
+ def __init__(
28
+ self,
29
+ cat: int,
30
+ subcat: int,
31
+ post: int,
32
+ title: str = "",
33
+ max_page: int = 0,
34
+ max_date: str = "1970-01-01",
35
+ ) -> None:
36
+ self.cat = cat
37
+ self.subcat = subcat
38
+ self.post = post
39
+ self.title = title
40
+ self.max_page = max_page
41
+ self.max_date = max_date
42
+ self.messages = dict()
43
+
44
+ @property
45
+ def id(self) -> str:
46
+ return f"{self.cat}#{self.subcat}#{self.post}"
47
+
48
+ def parse_page_html(self, html: str) -> dict:
49
+ soup = BeautifulSoup(html, "html.parser")
50
+ self.title = soup.find("h3").text
51
+
52
+ # Find highest page number
53
+ pages_block = soup.find("tr", class_="fondForum2PagesHaut")
54
+ page_links = pages_block.find_all("a", class_="cHeader")
55
+
56
+ if self.max_page == 0:
57
+ max_page = 1
58
+ for page in page_links:
59
+ href = page.attrs["href"]
60
+ for param in href.split("&"):
61
+ kv = param.split("=")
62
+ if kv[0] == "page":
63
+ if int(kv[1]) > max_page:
64
+ max_page = int(kv[1])
65
+ break
66
+ self.max_page = max_page
67
+
68
+ ts_min = 0
69
+ ts_max = 0
70
+
71
+ # Find all messages in the page
72
+ messages_soup = soup.find_all("table", class_="messagetable")
73
+ for message_block in messages_soup:
74
+ message = Message.from_html(self, message_block)
75
+ if message:
76
+ self.add_message(message)
77
+ if ts_min == 0 or ts_min > message.posted_at:
78
+ ts_min = message.posted_at
79
+ if ts_max == 0 or ts_max < message.posted_at:
80
+ ts_max = message.posted_at
81
+
82
+ return {"ts_min": ts_min, "ts_max": ts_max}
83
+
84
+ def add_message(self, message) -> None:
85
+ msg_date = date_to_str(message.posted_at)
86
+
87
+ if msg_date in self.messages:
88
+ messages_for_date = self.messages[msg_date]
89
+ else:
90
+ if msg_date > self.max_date:
91
+ self.max_date = msg_date
92
+ logger.debug(f"Got a message at date {msg_date}")
93
+ messages_for_date = SortedDict()
94
+ self.messages[msg_date] = messages_for_date
95
+ messages_for_date[message.id] = message
96
+
97
+ def load_page(self, page: int) -> dict:
98
+ time.sleep(1)
99
+
100
+ url = f"https://forum.hardware.fr/forum2.php?config=hfr.inc&cat={self.cat}&subcat={self.subcat}&post={self.post}&print=1&page={page}"
101
+
102
+ r = requests.get(
103
+ url,
104
+ headers={
105
+ "Accept": "text/html",
106
+ "Accept-Encoding": "gzip, deflate, br, zstd",
107
+ "User-Agent": "HFRTopicSummarizer",
108
+ },
109
+ )
110
+ html = r.text
111
+
112
+ return self.parse_page_html(html)
113
+
114
+ def has_date(self, msg_date: str | date | datetime) -> bool:
115
+ return date_to_str(msg_date) in self.messages.keys()
116
+
117
+ def messages_on_date(self, msg_date: str):
118
+ date_str = str(msg_date)
119
+ if date_str in self.messages:
120
+ return self.messages[date_str].values()
121
+ else:
122
+ return ()
123
+
124
+ def to_dict(self) -> dict:
125
+ return {
126
+ "topic_id": f"{self.cat}#{self.subcat}#{self.post}",
127
+ "title": self.title,
128
+ "max_page": self.max_page,
129
+ "max_date": self.max_date,
130
+ }
131
+
132
+ @classmethod
133
+ def from_dict(cls, data: dict):
134
+ if "topic_id" in data:
135
+ (cat, subcat, post) = str.split(data["topic_id"], "#")
136
+ return cls(
137
+ cat, subcat, post, data["title"], data["max_page"], data["max_date"]
138
+ )
139
+ else:
140
+ return cls(
141
+ data["cat"],
142
+ data["subcat"],
143
+ data["post"],
144
+ data["title"],
145
+ data["max_page"],
146
+ data["max_date"],
147
+ )
@@ -0,0 +1,27 @@
1
+ Metadata-Version: 2.2
2
+ Name: hfr_api
3
+ Version: 0.0.2
4
+ Summary: A Python library to interface with forum.hardware.fr
5
+ Home-page: https://gitea.ruk.info/roukine/hfr
6
+ Author: MycRub
7
+ Author-email: mycrub@mycrub.net
8
+ License: MIT
9
+ Requires-Python: >=3.13
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: requests==2.32.3
13
+ Requires-Dist: beautifulsoup4==4.12.3
14
+ Requires-Dist: sortedcontainers==2.4.0
15
+ Dynamic: author
16
+ Dynamic: author-email
17
+ Dynamic: description
18
+ Dynamic: description-content-type
19
+ Dynamic: home-page
20
+ Dynamic: license
21
+ Dynamic: requires-dist
22
+ Dynamic: requires-python
23
+ Dynamic: summary
24
+
25
+ # HFR
26
+
27
+ A python library to interface with forum.hardware.fr
@@ -0,0 +1,15 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ setup.cfg
5
+ setup.py
6
+ hfr/__init__.py
7
+ hfr/bb.py
8
+ hfr/message.py
9
+ hfr/topic.py
10
+ hfr_api.egg-info/PKG-INFO
11
+ hfr_api.egg-info/SOURCES.txt
12
+ hfr_api.egg-info/dependency_links.txt
13
+ hfr_api.egg-info/requires.txt
14
+ hfr_api.egg-info/top_level.txt
15
+ tests/test_bb.py
@@ -0,0 +1,3 @@
1
+ requests==2.32.3
2
+ beautifulsoup4==4.12.3
3
+ sortedcontainers==2.4.0
@@ -0,0 +1 @@
1
+ hfr
@@ -0,0 +1,2 @@
1
+ [tool.pytest.ini_options]
2
+ asyncio_mode = "auto"
@@ -0,0 +1,7 @@
1
+ [metadata]
2
+ description-file = README.md
3
+
4
+ [egg_info]
5
+ tag_build =
6
+ tag_date = 0
7
+
hfr_api-0.0.2/setup.py ADDED
@@ -0,0 +1,20 @@
1
+ import setuptools
2
+
3
+ with open("README.md", encoding="utf-8") as fh:
4
+ long_description = fh.read()
5
+
6
+ setuptools.setup(
7
+ name="hfr_api",
8
+ version="0.0.2",
9
+ author="MycRub",
10
+ author_email="mycrub@mycrub.net",
11
+ description="A Python library to interface with forum.hardware.fr",
12
+ long_description=long_description,
13
+ long_description_content_type="text/markdown",
14
+ license="MIT",
15
+ url="https://gitea.ruk.info/roukine/hfr",
16
+ packages=setuptools.find_packages(exclude=("tests", "tests.*")),
17
+ install_requires=["requests==2.32.3","beautifulsoup4==4.12.3","sortedcontainers==2.4.0"],
18
+ python_requires=">=3.13",
19
+ include_package_data=True,
20
+ )
@@ -0,0 +1,64 @@
1
+ from hfr import bb
2
+
3
+ def test_html_to_bb_basic():
4
+ assert bb.html_to_bb("<strong>x</strong>") == "[b]x[/b]"
5
+ assert bb.html_to_bb("<em>x</em>") == "[i]x[/i]"
6
+ assert bb.html_to_bb("<span class=\"u\">x</span>") == "[u]x[/u]"
7
+ assert bb.html_to_bb("<strike>x</strike>") == "[strike]x[/strike]"
8
+ assert bb.html_to_bb("<a href=\"x\">y</a>") == "[url=x]y[/url]"
9
+ assert bb.html_to_bb("<img src=\"x\" alt=\"y\"/>") == "[img]x[/img]"
10
+ assert bb.html_to_bb("<img src=\"x\" alt=\":o\"/>") == ":o"
11
+ assert bb.html_to_bb("<img src=\"x\" alt=\"[:y]\"/>") == "[:y]"
12
+ assert bb.html_to_bb("<ul><li> x</li></ul>") == "[*] x"
13
+ assert bb.html_to_bb("<a href=\"mailto:lolcat@lol.cat\">") == "[email]lolcat@lol.cat[/email]"
14
+
15
+
16
+ def test_html_to_bb_advanced():
17
+ input = """<p>Un test avec du <strong>gras et de <em>l'italique</em> dans le gras</strong>. Du <span class="u">souligné</span> et du <strike>barré</strike>. Aussi, un smiley perso <img src="https://forum-images.hardware.fr/images/perso/mycrub.gif" alt="[:mycrub]" title="[:mycrub]" /> et un smiley de base <img src="https://forum-images.hardware.fr/icones/redface.gif" alt=":o" title=":o" /> ainsi que
18
+ <br /></p><ul><li> une image <img src="https://forum-images.hardware.fr/images/perso/1/mycrub.gif" alt="https://forum-images.hardware.fr/images/perso/1/mycrub.gif" title="https://forum-images.hardware.fr/images/perso/1/mycrub.gif" onload="md_verif_size(this,'Cliquez pour agrandir','2','250')" style="margin: 5px"/>
19
+ </li><li> <a rel="nofollow" href="https://lolcat.lol.cat" target="_blank" class="cLink">un lien</a></li></ul><p><div style="clear: both;"> </div></p></div>"""
20
+ expected = """Un test avec du [b]gras et de [i]l'italique[/i] dans le gras[/b]. Du [u]souligné[/u] et du [strike]barré[/strike]. Aussi, un smiley perso [:mycrub] et un smiley de base :o ainsi que
21
+ [*] une image [img]https://forum-images.hardware.fr/images/perso/1/mycrub.gif[/img]
22
+ [*] [url=https://lolcat.lol.cat]un lien[/url]"""
23
+ output = bb.html_to_bb(input)
24
+ assert expected == output
25
+
26
+ input = """<div id="para1980038934"><p>Un quote qui contient du souligné:
27
+ <br /></p><div class="container"><table class="citation"><tr class="none"><td><b class="s1"><a href="/forum2.php?config=hfr.inc&amp;cat=prive&amp;post=3042031&amp;page=1&amp;p=1&amp;sondage=0&amp;owntopic=0&amp;trash=0&amp;trash_post=0&amp;print=0&amp;numreponse=0&amp;quote_only=0&amp;new=0&amp;nojs=0#t1980038387" class="Topic">MycRub a écrit :</a></b><br /><br /><p><span class="u">souligné</span><br /></p></td></tr></table></div><p>
28
+ <br />&nbsp;<br />Une citation :
29
+ <br /></p><div class="container"><table class="quote"><tr class="none"><td><b class="s1">Citation :</b><br /><br /><p>Il fait chaud.<br /></p></td></tr></table></div><p>
30
+ <br />&nbsp;<br />Un spoiler :
31
+ <br /></p><div class="container"><table class="spoiler" onclick="javascript:swap_spoiler_states(this)" style="cursor:pointer;"><tr class="none"><td><b class="s1Topic">Spoiler :</b><br /><br /><div class="Topic masque"><p>Coucou</p></div></td></tr></table></div><p>
32
+ <br />&nbsp;<br />Un bloc fx :
33
+ <br /></p><table class="fixed"><tr class="none"><td><p>Monospace ?</p></td></tr></table><p>
34
+ <br />&nbsp;<br />Du code :
35
+ <br /></p><table class="code"><tr class="none"><td><b class="s1" style="font-family: Verdana, Helvetica, Arial, Sans-serif;">Code :</b><br /><ol id="code1" class="olcode"><li>toto=1;</li><li>tata=2;</li></ol></td></tr></table><p>
36
+ <br />&nbsp;<br />Un email :
37
+ <br /><a rel="nofollow" href="mailto:lolcat@lol.cat" class="cLink">lolcat@lol.cat</a>
38
+ <br />&nbsp;<br />De la couleur :
39
+ <br />Texte <span style="color:#0000FF">bleu</span> et <span style="color:#FF0000">rouge</span><div style="clear: both;"> </div></p></div>"""
40
+ expected = """Un quote qui contient du souligné:
41
+ [quotemsg=1980038387,0,0][u]souligné[/u][/quotemsg]
42
+
43
+ Une citation :
44
+ [quote]Il fait chaud.[/quote]
45
+
46
+ Un spoiler :
47
+ [spoiler]Coucou[/spoiler]
48
+
49
+ Un bloc fx :
50
+ [fixed]Monospace ?[/fixed]
51
+
52
+ Du code :
53
+ [code]toto=1;
54
+ tata=2;[/code]
55
+
56
+ Un email :
57
+ [email]lolcat@lol.cat[/email]
58
+
59
+ De la couleur :
60
+ Texte [#0000FF]bleu[/#0000FF] et [#FF0000]rouge[/#FF0000]"""
61
+ output = bb.html_to_bb(input)
62
+ assert expected == output
63
+
64
+ pass