--------------------------------------------------------------------------------
/page_gen/README.md:
--------------------------------------------------------------------------------
1 | # Формирование html страницы треда
2 |
3 | Файлы
4 | * `index.html` - шаблон страницы
5 | * `style.css` - стили для шаблона
6 | * blocks - директория с блоками из шаблона
7 | * `op_post.html` - вырезка оп-поста из шаблона
8 | * `post.html` - вырезка поста из шаблона
9 | * `dashboard.html` - боковая панель с навигацией
10 | * `script.js` - скрипт для генерации навигации
11 |
12 | Во время генерации страницы значения в фигурных скобках заменяются. Это происходит в классе `HtmlGenerator`.
13 |
--------------------------------------------------------------------------------
/page_gen/blocks/post.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Аноним
4 |
5 | {date}
6 |
7 |
8 | №{num}
9 |
10 |
11 | {order}
12 |
13 |
14 |
15 |
16 | {images}
17 |
18 |
19 | {msg}
20 |
21 |
24 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Dmitry
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.
22 |
--------------------------------------------------------------------------------
/popular.py:
--------------------------------------------------------------------------------
1 | import dvach
2 | import time
3 |
4 | # Ключевые слова. Если список пустой, то отбора тредов не будет.
5 | KEY_WORDS = [
6 | # "цуиь",
7 | # "mp4"
8 | ]
9 | text_limit = 164 # Длина строки
10 | max_lines = 55 # Ограничить количество строк. Если равен 0, то ограничений нет
11 |
12 |
13 | def print_threads(threads):
14 | # Если max_lines = 0, то ограничений нет
15 | limit = len(threads.keys()) if max_lines == 0 else max_lines
16 |
17 | for key_index in range(0, limit):
18 | key = list(threads.keys())[key_index]
19 | thread = threads[key]
20 |
21 | if not thread.IsOk(KEY_WORDS):
22 | continue
23 |
24 | # Отформатировать строку с информацией о треде и вывести на экран
25 | comment_formatted = (
26 | '{0:' + str(text_limit) + '}').format(thread.comment[:text_limit:])
27 | posts_count_str = '{0:3}'.format(round(thread.posts_count))
28 |
29 | print(f"{posts_count_str} | {comment_formatted} | {thread.get_link}")
30 |
31 |
32 | if __name__ == '__main__':
33 | # доска, которую нужно парсить
34 | board_name = 'b'
35 |
36 | # Инициализация доски с тредами
37 | board = dvach.Board.from_json(dvach.Board.json_download(board_name))
38 |
39 | while True:
40 | # Обновление тредов
41 | try:
42 | board.update_threads()
43 | except:
44 | print('.', end='')
45 | time.sleep(3)
46 | continue
47 |
48 | # Сортировка по количеству постов
49 | board.sort_threads_by_posts()
50 |
51 | # Вывести на экран
52 | print_threads(board.threads)
53 |
54 | # 15 секунд интервал обновления
55 | time.sleep(15)
56 |
--------------------------------------------------------------------------------
/test_files/threads_json_removed2.json:
--------------------------------------------------------------------------------
1 | {
2 | "board": "b",
3 | "threads": [
4 | {
5 | "comment": "Привет двач, немного игривое настроение. Да, и выпал в одной игре, достаточно интересное задание, вообще на ваши роллы выполняюю реквесты, практически любые ",
6 | "lasthit": 1619954784,
7 | "num": "1",
8 | "posts_count": 175,
9 | "score": 21.875,
10 | "subject": "Привет двач, немного игривое настроение. Да,",
11 | "timestamp": 1619947499,
12 | "views": 351
13 | },
14 | {
15 | "comment": "Анон, ты не задумывался, почему разделяют шахматный спорт? Почему вообще разделяют любой умственный спорт, если там, казалось бы, никто силу не применяет и никакие физиологические факторы человека не влияют? Неужто всё-таки фемки тупые и не понимают простой физиологии, отрицая реальное положение дел?",
16 | "lasthit": 1619951578,
17 | "num": "4",
18 | "posts_count": 22,
19 | "score": 6.3125,
20 | "subject": "Анон, ты не задумывался, почему разделяют шахматный",
21 | "timestamp": 1619946478,
22 | "views": 102
23 | },
24 | {
25 | "comment": "Двач, на связи кун 25 лвл без пары шагов на пути к успеху, уволился с работы и еду жить к маме нахлебником. Задавайте ваши вопросы.",
26 | "lasthit": 1619951533,
27 | "num": "5",
28 | "posts_count": 1,
29 | "score": 1.22222222222,
30 | "subject": "Двач, на связи кун 25 лвл без пары шагов на пути к успеху, уволился с работы и еду жить к маме нахлебником. Задавайте ваши вопросы.",
31 | "timestamp": 1619951044,
32 | "views": 12
33 | }
34 | ]
35 | }
--------------------------------------------------------------------------------
/page_gen/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | {h1}
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | Аноним
20 |
21 | {date}
22 |
23 |
24 | №{num}
25 |
26 |
27 |
28 |
29 |
30 |
31 | {msg}
32 |
33 |
34 |
35 |
36 |
37 | Аноним
38 |
39 | {date}
40 |
41 |
42 | №{num}
43 |
44 |
45 | {order}
46 |
47 |
48 |
49 |
50 |
54 |
55 |
56 | {msg}
57 |
58 |
61 |
62 |
63 |
64 |
65 |
--------------------------------------------------------------------------------
/test_files/threads_json_removed.json:
--------------------------------------------------------------------------------
1 | {
2 | "board": "b",
3 | "threads": [
4 | {
5 | "comment": "Продолжаем наблюдать за тупорылостью зумерков",
6 | "lasthit": 1619954786,
7 | "num": "1",
8 | "posts_count": 54,
9 | "score": 0,
10 | "subject": "Продолжаем наблюдать за тупорылостью зумерков",
11 | "timestamp": 1619952370,
12 | "views": 0
13 | },
14 | {
15 | "comment": "Привет двач, немного игривое настроение. Да, и выпал в одной игре, достаточно интересное задание, вообще на ваши роллы выполняюю реквесты, практически любые ",
16 | "lasthit": 1619954784,
17 | "num": "2",
18 | "posts_count": 175,
19 | "score": 21.875,
20 | "subject": "Привет двач, немного игривое настроение. Да,",
21 | "timestamp": 1619947499,
22 | "views": 351
23 | },
24 | {
25 | "comment": "Анон, ты не задумывался, почему разделяют шахматный спорт? Почему вообще разделяют любой умственный спорт, если там, казалось бы, никто силу не применяет и никакие физиологические факторы человека не влияют? Неужто всё-таки фемки тупые и не понимают простой физиологии, отрицая реальное положение дел?",
26 | "lasthit": 1619951578,
27 | "num": "4",
28 | "posts_count": 22,
29 | "score": 6.3125,
30 | "subject": "Анон, ты не задумывался, почему разделяют шахматный",
31 | "timestamp": 1619946478,
32 | "views": 102
33 | },
34 | {
35 | "comment": "Двач, на связи кун 25 лвл без пары шагов на пути к успеху, уволился с работы и еду жить к маме нахлебником. Задавайте ваши вопросы.",
36 | "lasthit": 1619951533,
37 | "num": "5",
38 | "posts_count": 1,
39 | "score": 1.22222222222,
40 | "subject": "Двач, на связи кун 25 лвл без пары шагов на пути к успеху, уволился с работы и еду жить к маме нахлебником. Задавайте ваши вопросы.",
41 | "timestamp": 1619951044,
42 | "views": 12
43 | }
44 | ]
45 | }
--------------------------------------------------------------------------------
/tracker.py:
--------------------------------------------------------------------------------
1 | # Мониторинг доск
2 |
3 | from typing import List
4 | import dvach
5 | import time
6 |
7 | text_limit = 155 # Длина строки вывода в консоль
8 | # Ключевые слова. Если список пустой, то выводятся все треды.
9 | KEY_WORDS = [
10 | # "цуиь",
11 | # "mp4"
12 | ]
13 |
14 |
15 | def print_new_threads(new_threads: List[dvach.Thread]):
16 | for thread in new_threads:
17 |
18 | if not thread.IsOk(KEY_WORDS):
19 | continue
20 |
21 | board_name = '{0:5}'.format(thread.board_name)
22 | comment_formatted = ('{0:' + str(text_limit) + '}').format(thread.comment[:text_limit:])
23 |
24 | print(f"/{board_name} | {comment_formatted} | {thread.get_link}")
25 |
26 | # Если операционная система linux, то можно отправить уведомление
27 | # os.system(f'notify-send -t 25000 \"/{board_name} {thread.comment}\"')
28 |
29 |
30 | if __name__ == '__main__':
31 | board_names = 'b news sex v hw gg dev soc rf ma psy fet'
32 | boards = []
33 |
34 | # Добавить все доски в общий список
35 | for board_name in board_names.split(' '):
36 | board_json = dvach.Board.json_download(board_name)
37 | boards.append(dvach.Board.from_json(board_json))
38 |
39 | del board_json
40 |
41 | while True:
42 | # Мониторинг обновлений
43 | new_threads = []
44 |
45 | # Заполнить список новыми тредами
46 | for i in range(len(boards)):
47 | b = boards[i]
48 |
49 | # Скачать доску с новыми тредами
50 | try:
51 | board_json = dvach.Board.json_download(b.name)
52 | updated_board = dvach.Board.from_json(board_json)
53 | except:
54 | print('.', end='')
55 | time.sleep(3)
56 | continue
57 |
58 | # Сравнить скачанную доску с существующей
59 | new = b.get_new_threads(updated_board.threads)
60 |
61 | # Добавить все новые треды в общий список
62 | # Так как это словарь, цикл перебирает ключи
63 | for key in new.keys():
64 | new_threads.append(new[key])
65 |
66 | # Заменить старую доску новой, чтобы потом сравнивать с новой
67 | boards[i].threads = updated_board.threads
68 | time.sleep(1)
69 |
70 | # Вывести новые треды
71 | print_new_threads(new_threads)
72 |
73 | # Задержка между обновлениями
74 | time.sleep(15)
75 |
--------------------------------------------------------------------------------
/test_files/threads_json.json:
--------------------------------------------------------------------------------
1 | {
2 | "board": "b",
3 | "threads": [
4 | {
5 | "comment": "Продолжаем наблюдать за тупорылостью зумерков",
6 | "lasthit": 1619954786,
7 | "num": "1",
8 | "posts_count": 54,
9 | "score": 0,
10 | "subject": "Продолжаем наблюдать за тупорылостью зумерков",
11 | "timestamp": 1619952370,
12 | "views": 0
13 | },
14 | {
15 | "comment": "Привет двач, немного игривое настроение. Да, и выпал в одной игре, достаточно интересное задание, вообще на ваши роллы выполняюю реквесты, практически любые ",
16 | "lasthit": 1619954784,
17 | "num": "2",
18 | "posts_count": 175,
19 | "score": 21.875,
20 | "subject": "Привет двач, немного игривое настроение. Да,",
21 | "timestamp": 1619947499,
22 | "views": 351
23 | },
24 | {
25 | "comment": "Ободрал немного ноготок. Зацените аноны",
26 | "lasthit": 1619954783,
27 | "num": "3",
28 | "posts_count": 5,
29 | "score": 3.33333333333,
30 | "subject": "Ободрал немного ноготок. Зацените аноны",
31 | "timestamp": 1619949613,
32 | "views": 31
33 | },
34 | {
35 | "comment": "Анон, ты не задумывался, почему разделяют шахматный спорт? Почему вообще разделяют любой умственный спорт, если там, казалось бы, никто силу не применяет и никакие физиологические факторы человека не влияют? Неужто всё-таки фемки тупые и не понимают простой физиологии, отрицая реальное положение дел?",
36 | "lasthit": 1619951578,
37 | "num": "4",
38 | "posts_count": 22,
39 | "score": 6.3125,
40 | "subject": "Анон, ты не задумывался, почему разделяют шахматный",
41 | "timestamp": 1619946478,
42 | "views": 102
43 | },
44 | {
45 | "comment": "Двач, на связи кун 25 лвл без пары шагов на пути к успеху, уволился с работы и еду жить к маме нахлебником. Задавайте ваши вопросы.",
46 | "lasthit": 1619951533,
47 | "num": "5",
48 | "posts_count": 1,
49 | "score": 1.22222222222,
50 | "subject": "Двач, на связи кун 25 лвл без пары шагов на пути к успеху, уволился с работы и еду жить к маме нахлебником. Задавайте ваши вопросы.",
51 | "timestamp": 1619951044,
52 | "views": 12
53 | }
54 | ]
55 | }
--------------------------------------------------------------------------------
/page_gen/style.css:
--------------------------------------------------------------------------------
1 | * {
2 | box-sizing: border-box;
3 | margin: 0;
4 | }
5 |
6 | a {
7 | text-decoration: none;
8 | color: #f68439;
9 | }
10 |
11 | .container {
12 | display: flex;
13 | flex-direction: column;
14 | margin-left: 30%;
15 | margin-right: 5%;
16 | }
17 |
18 | .op_post {
19 | display: block;
20 | margin-top: 20px;
21 | }
22 |
23 | .op_post_header {
24 | display: flex;
25 | margin-bottom: 5px;
26 | }
27 |
28 | .op_post_header span,
29 | .post_header span {
30 | color: rgb(164, 164, 164);
31 | font-size: 0.9em;
32 | margin-right: 10px;
33 | }
34 |
35 | .op_post_header_time,
36 | .post_header_time {
37 | color: #a6a6a6;
38 | margin-right: 10px;
39 | }
40 |
41 | .op_post_header_num,
42 | .post_header_num {
43 | color: #a6a6a6;
44 | }
45 |
46 | .op_post_img {
47 | display: inline;
48 | cursor: pointer;
49 | float: left;
50 | margin-right: 10px;
51 | }
52 |
53 | .op_post_text {
54 | font-size: 15px;
55 | line-height: 1.3;
56 | color: #333333;
57 | font-family: 'Trebuchet MS', sans-serif;
58 | }
59 |
60 | .post {
61 | margin-top: 15px;
62 | background-color: #dddddd;
63 | line-height: 1.3;
64 | border-radius: 5px;
65 | padding: 10px;
66 | }
67 |
68 | .post_header {
69 | display: flex;
70 | }
71 |
72 | .post_header_order {
73 | color: #789922;
74 | margin-left: 20px;
75 | }
76 |
77 | .post_msg {
78 | word-wrap: normal;
79 | }
80 |
81 | .post_msg a:visited {
82 | color: grey;
83 | }
84 |
85 | .post_footer {
86 | display: flex;
87 | margin-top: 20px;
88 | flex-wrap: wrap;
89 | }
90 |
91 | .post_footer a {
92 | font-size: small;
93 | margin-right: 5px;
94 | }
95 |
96 | .post_footer a:visited {
97 | color: grey;
98 | }
99 |
100 | .post_footer_answer {
101 | margin-right: 7px;
102 | line-height: 1.3;
103 | color: #f68439;
104 | text-decoration: none;
105 | }
106 |
107 | .dashboard {
108 | display: flex;
109 | flex-direction: column;
110 | position: fixed;
111 | overflow-y: scroll;
112 | padding-left: 5px;
113 | padding-right: 15px;
114 | max-height: 100vh;
115 | max-width: 30%;
116 | }
117 |
118 | .dashboard a {
119 | white-space: nowrap;
120 | }
121 |
122 | .dashboard a:visited {
123 | color: grey;
124 | }
125 |
126 | .spam {
127 | color: grey;
128 | text-decoration: line-through;
129 | font-size: smaller;
130 | }
--------------------------------------------------------------------------------
/test_files/threads_json_withnew.json:
--------------------------------------------------------------------------------
1 | {
2 | "board": "b",
3 | "threads": [
4 | {
5 | "comment": "СПАСИБО ДЯДЬ ВОВ",
6 | "lasthit": 1619954787,
7 | "num": "6",
8 | "posts_count": 355,
9 | "score": 22.0277777778,
10 | "subject": "СПАСИБО ДЯДЬ ВОВ",
11 | "timestamp": 1619937405,
12 | "views": 794
13 | },
14 | {
15 | "comment": "Продолжаем наблюдать за тупорылостью зумерков с пориджем в голове. ",
16 | "lasthit": 1619954786,
17 | "num": "1",
18 | "posts_count": 54,
19 | "score": 0,
20 | "subject": "Продолжаем наблюдать за тупорылостью зумерков с пориджем в голове. ",
21 | "timestamp": 1619952370,
22 | "views": 0
23 | },
24 | {
25 | "comment": "Привет двач, немного игривое настроение. Да, и выпал в одной игре, достаточно интересное задание, вообще на ваши роллы выполняюю реквесты, практически любые ",
26 | "lasthit": 1619954784,
27 | "num": "2",
28 | "posts_count": 175,
29 | "score": 21.875,
30 | "subject": "Привет двач, немного игривое настроение. Да,",
31 | "timestamp": 1619947499,
32 | "views": 351
33 | },
34 | {
35 | "comment": "Ободрал немного ноготок. Зацените аноны",
36 | "lasthit": 1619954783,
37 | "num": "3",
38 | "posts_count": 5,
39 | "score": 3.33333333333,
40 | "subject": "Ободрал немного ноготок. Зацените аноны",
41 | "timestamp": 1619949613,
42 | "views": 31
43 | },
44 | {
45 | "comment": "Анон, ты не задумывался, почему разделяют шахматный спорт? Почему вообще разделяют любой умственный спорт, если там, казалось бы, никто силу не применяет и никакие физиологические факторы человека не влияют? Неужто всё-таки фемки тупые и не понимают простой физиологии, отрицая реальное положение дел?",
46 | "lasthit": 1619951578,
47 | "num": "4",
48 | "posts_count": 22,
49 | "score": 6.3125,
50 | "subject": "Анон, ты не задумывался, почему разделяют шахматный",
51 | "timestamp": 1619946478,
52 | "views": 102
53 | },
54 | {
55 | "comment": "Двач, на связи кун 25 лвл без пары шагов на пути к успеху, уволился с работы и еду жить к маме нахлебником. Задавайте ваши вопросы.",
56 | "lasthit": 1619951533,
57 | "num": "5",
58 | "posts_count": 1,
59 | "score": 1.22222222222,
60 | "subject": "Двач, на связи кун 25 лвл без пары шагов на пути к успеху, уволился с работы и еду жить к маме нахлебником. Задавайте ваши вопросы.",
61 | "timestamp": 1619951044,
62 | "views": 12
63 | }
64 | ]
65 | }
--------------------------------------------------------------------------------
/test_files/threads_json_withnew2.json:
--------------------------------------------------------------------------------
1 | {
2 | "board": "b",
3 | "threads": [
4 | {
5 | "comment": "СПАСИБО ДЯДЬ ВОВ",
6 | "lasthit": 1619954787,
7 | "num": "6",
8 | "posts_count": 355,
9 | "score": 22.0277777778,
10 | "subject": "СПАСИБО ДЯДЬ ВОВ",
11 | "timestamp": 1619937405,
12 | "views": 794
13 | },
14 | {
15 | "comment": "Продолжаем наблюдать за тупорылостью зумерков с пориджем в голове. ",
16 | "lasthit": 1619954786,
17 | "num": "1",
18 | "posts_count": 54,
19 | "score": 0,
20 | "subject": "Продолжаем наблюдать за тупорылостью зумерков с пориджем в голове. ",
21 | "timestamp": 1619952370,
22 | "views": 0
23 | },
24 | {
25 | "comment": "Привет двач, немного игривое настроение. Да, и выпал в одной игре, достаточно интересное задание, вообще на ваши роллы выполняюю реквесты, практически любые ",
26 | "lasthit": 1619954784,
27 | "num": "2",
28 | "posts_count": 175,
29 | "score": 21.875,
30 | "subject": "Привет двач, немного игривое настроение. Да,",
31 | "timestamp": 1619947499,
32 | "views": 351
33 | },
34 | {
35 | "comment": "Животноводство",
36 | "lasthit": 1619954987,
37 | "num": "7",
38 | "posts_count": 450,
39 | "score": 22.0277877778,
40 | "subject": "Животноводство",
41 | "timestamp": 1619967405,
42 | "views": 960
43 | },
44 | {
45 | "comment": "Ободрал немного ноготок. Зацените аноны",
46 | "lasthit": 1619954783,
47 | "num": "3",
48 | "posts_count": 5,
49 | "score": 3.33333333333,
50 | "subject": "Ободрал немного ноготок. Зацените аноны",
51 | "timestamp": 1619949613,
52 | "views": 31
53 | },
54 | {
55 | "comment": "Анон, ты не задумывался, почему разделяют шахматный спорт? Почему вообще разделяют любой умственный спорт, если там, казалось бы, никто силу не применяет и никакие физиологические факторы человека не влияют? Неужто всё-таки фемки тупые и не понимают простой физиологии, отрицая реальное положение дел?",
56 | "lasthit": 1619951578,
57 | "num": "4",
58 | "posts_count": 22,
59 | "score": 6.3125,
60 | "subject": "Анон, ты не задумывался, почему разделяют шахматный",
61 | "timestamp": 1619946478,
62 | "views": 102
63 | },
64 | {
65 | "comment": "Двач, на связи кун 25 лвл без пары шагов на пути к успеху, уволился с работы и еду жить к маме нахлебником. Задавайте ваши вопросы.",
66 | "lasthit": 1619951533,
67 | "num": "5",
68 | "posts_count": 1,
69 | "score": 1.22222222222,
70 | "subject": "Двач, на связи кун 25 лвл без пары шагов на пути к успеху, уволился с работы и еду жить к маме нахлебником. Задавайте ваши вопросы.",
71 | "timestamp": 1619951044,
72 | "views": 12
73 | }
74 | ]
75 | }
--------------------------------------------------------------------------------
/filecompare.py:
--------------------------------------------------------------------------------
1 | from cv2 import imread, resize, cvtColor, threshold, INTER_AREA, COLOR_BGR2GRAY
2 | import hashlib
3 |
4 |
5 | def are_similar(img1, img2) -> bool:
6 | """Похожи ли изображения
7 |
8 | Args:
9 | img1 (str): путь к первому изображению
10 | img2 (str): путь к второму изображению
11 |
12 | Returns:
13 | bool: являются ли изображения схожими
14 | """
15 | try:
16 | hash1 = CalcImageHash(img1)
17 | hash2 = CalcImageHash(img2)
18 | # Чем меньше число, тем похожей изображения
19 | similarity = CompareHash(hash1, hash2)
20 |
21 | return similarity < 7
22 | except:
23 | # Файл не картинка или другая ошибка
24 | return False
25 |
26 |
27 | def CalcVideoHash(video: str) -> str:
28 | for i in range(2):
29 | try:
30 | BLOCK_SIZE = 65536
31 | file_hash = hashlib.sha256()
32 | with open(video, 'rb') as f:
33 | fb = f.read(BLOCK_SIZE)
34 | while len(fb) > 0:
35 | file_hash.update(fb)
36 | fb = f.read(BLOCK_SIZE)
37 | break
38 | except:
39 | print("Ошибка во время чтения видео, повтор")
40 |
41 | # Get the hexadecimal digest of the hash
42 | return file_hash.hexdigest()
43 |
44 |
45 | def CalcImageHash(img: str) -> str:
46 | """Преобразовать картинку в строку
47 |
48 | Args:
49 | img (str): путь к изображению
50 |
51 | Returns:
52 | str: строковое представление картинки
53 | """
54 | for i in range(2):
55 | try:
56 | image = imread(img) # Прочитаем картинку
57 | # Уменьшим картинку
58 | resized = resize(image, (8, 8), interpolation=INTER_AREA)
59 | # Переведем в черно-белый формат
60 | gray_image = cvtColor(resized, COLOR_BGR2GRAY)
61 | avg = gray_image.mean() # Среднее значение пикселя
62 | _, threshold_image = threshold(
63 | gray_image, avg, 255, 0) # Бинаризация по порогу
64 | break
65 | except:
66 | print("Ошибка во время обработки изображения, повтор")
67 |
68 | # Рассчитаем хэш
69 | _hash = ""
70 | for x in range(8):
71 | for y in range(8):
72 | val = threshold_image[x, y]
73 | if val == 255:
74 | _hash += "1"
75 | else:
76 | _hash += "0"
77 |
78 | return _hash
79 |
80 |
81 | def CompareHash(hash1, hash2):
82 | i = 0
83 | count = 0
84 | while i < len(hash1):
85 | if hash1[i] != hash2[i]:
86 | count = count + 1
87 | i = i + 1
88 | return count
89 |
90 |
91 | def get_better_img(img1: str, img2: str) -> str:
92 | """Получить изображение с лучшим расширением
93 |
94 | Args:
95 | img1 (str): Первая картинка
96 | img2 (str): Вторая картинка
97 |
98 | Returns:
99 | str: Лучшая картинка
100 | """
101 | i1 = imread(img1)
102 | i2 = imread(img2)
103 | height1, width1, channels1 = i1.shape
104 | height2, width2, channels2 = i2.shape
105 |
106 | if width1 > width2:
107 | return img1
108 | else:
109 | return img2
110 |
--------------------------------------------------------------------------------
/thread_saver.py:
--------------------------------------------------------------------------------
1 | import dvach
2 | import time
3 | import os
4 | import sys
5 |
6 | DELAY = 15
7 | SAVE_MEDIA = True
8 | global FOLDER
9 | FOLDER = "saver"
10 |
11 |
12 | def get_board(board_name: str) -> dvach.Board:
13 | while True:
14 | try:
15 | board = dvach.Board.from_json(
16 | dvach.Board.json_download(board_name))
17 | board.update_threads()
18 | return board
19 | except Exception as e:
20 | print(f"Не удалось подключиться, повтор {type(e).__name__}")
21 | time.sleep(5)
22 |
23 |
24 | def get_thread(board, thread_num):
25 | thread_num = int(thread_num)
26 | while True:
27 | if thread_num not in list(board.threads.keys()):
28 | raise Exception('Тред не существует')
29 | try:
30 | thread = board.threads[thread_num]
31 | thread.update_posts()
32 | return thread
33 | except:
34 | print("Не удалось получить тред, повтор")
35 | time.sleep(5)
36 |
37 |
38 | def is_post_in_list(post: dvach.Post, posts_list):
39 | for i in posts_list:
40 | if i.num == post.num:
41 | return True
42 | return False
43 |
44 |
45 | def save_post_files(post: dvach.Post):
46 | for f in post.files:
47 | path = os.path.normpath(f"{FOLDER}/{f.name}")
48 | if os.path.exists(path):
49 | print(f"Файл {f.name} существует")
50 | continue
51 | f.save(path)
52 | print(f"Скачан файл: {f.name}")
53 |
54 |
55 | def add_new_posts(safe_thread: dvach.Thread, posts):
56 | for post in posts:
57 | if is_post_in_list(post, safe_thread.posts):
58 | continue
59 |
60 | safe_thread.posts.append(post)
61 | print(f'Новый пост: {post.num}')
62 |
63 | if SAVE_MEDIA:
64 | try:
65 | save_post_files(post)
66 | except Exception:
67 | print(f'❌ Не удалось скачать файлы из поста {post.num}')
68 | continue
69 |
70 |
71 | def print_deleted_posts(safe_thread: dvach.Thread, posts):
72 | for post in safe_thread.posts:
73 | if not is_post_in_list(post, posts):
74 | if post.num not in deleted_posts:
75 | print(f"Удаленный пост: {post.num}")
76 | deleted_posts.append(post.num)
77 |
78 |
79 | def get_input():
80 | """Скрипт можно запустить, передав параметры из консоли.
81 |
82 | Параметры:
83 | 1 - название доски
84 | 2 - номер треда
85 | 3 (не обязательный) - имя папки
86 |
87 | Returns:
88 | board_name, thread_num
89 | """
90 | if len(sys.argv) >= 1:
91 | # Первый аргумент это название файла, он не нужен
92 | sys.argv = sys.argv[1:]
93 |
94 | if len(sys.argv) == 0:
95 | print('Введите название доски: ', end='')
96 | board_name = input()
97 | print('Введите номер треда: ', end='')
98 | thread_num = input()
99 | return (board_name, thread_num)
100 |
101 | if len(sys.argv) >= 2:
102 | return (sys.argv[0], sys.argv[1])
103 | else:
104 | raise Exception("Переданны не корректные параметры")
105 |
106 |
107 | deleted_posts = []
108 | if __name__ == '__main__':
109 | board_name, thread_num = get_input()
110 |
111 | if len(sys.argv) >= 3:
112 | FOLDER = sys.argv[2]
113 |
114 | if not os.path.exists(FOLDER):
115 | os.mkdir(FOLDER)
116 |
117 | board = get_board(board_name)
118 |
119 | # Скаченный тред с двача
120 | thread = get_thread(board, thread_num)
121 |
122 | # Локальный тред на диске
123 | # Так как тред это ссылочный тип
124 | # Скопированы самые важные поля
125 | safe_thread = dvach.Thread(board_name)
126 | safe_thread.lasthit = thread.lasthit
127 | safe_thread.num = thread.num
128 | safe_thread.comment_html = thread.comment_html
129 |
130 | while True:
131 | add_new_posts(safe_thread, thread.posts)
132 |
133 | print_deleted_posts(safe_thread, thread.posts)
134 |
135 | safe_thread.save(FOLDER)
136 |
137 | time.sleep(DELAY)
138 |
139 | try:
140 | # Снова скачать тред
141 | thread = get_thread(board, thread_num)
142 | except:
143 | print("Ошибка, повтор")
144 | time.sleep(5)
145 |
--------------------------------------------------------------------------------
/page_gen/blocks/script.js:
--------------------------------------------------------------------------------
1 | let posts = document.getElementsByClassName('post');
2 |
3 | // "Пост" - "На кого отвечает"
4 | var dict = [];
5 |
6 | for (let i = 0; i < posts.length; i++) {
7 | const post = posts[i];
8 | // Пост
9 | post_id = post.id.split('_')[1];
10 |
11 | // На кого отвечает
12 | post_replies = [];
13 | let replies_count = 0;
14 |
15 | replies_links = post.getElementsByClassName('post-reply-link');
16 | for (let j = 0; j < replies_links.length; j++) {
17 | const reply = replies_links[j];
18 | let text = reply.text;
19 | let reply_id = text.substring(2).split(' ')[0];
20 | post_replies.push(reply_id)
21 | replies_count += 1;
22 | }
23 |
24 | dict.push({
25 | post_id: post_id, // Пост
26 | replies: post_replies, // На кого отвечает
27 | replies_count: replies_count
28 | });
29 | }
30 |
31 | // Конвертировать из "Пост" - "На кого этот пост отвечает"
32 | // в "Пост" - "Ответы на этот пост"
33 | posts_answers = [];
34 |
35 | for (let i = 0; i < dict.length; i++) {
36 | const post_id = dict[i].post_id;
37 | answers = [];
38 |
39 | for (let j = 0; j < dict.length; j++) {
40 | const replies_on = dict[j].replies;
41 | if (replies_on.includes(post_id)) {
42 | answers.push(dict[j].post_id);
43 | continue;
44 | }
45 | }
46 |
47 | posts_answers.push({
48 | post_id: post_id,
49 | answers: answers,
50 | });
51 | }
52 |
53 | function post_answering_on_count(post_id) {
54 | for (let i = 0; i < dict.length; i++) {
55 | const element = dict[i];
56 | if (post_id == element.post_id) {
57 | return element.replies_count;
58 | }
59 | }
60 | }
61 |
62 | // Заполнить боковое меню
63 | let dashboard = document.getElementById('dashboard');
64 |
65 | // Получить элемент ссылки для меню навигации
66 | function get_link(post_id, prefix) {
67 | var link = document.createElement("a");
68 | postfix = '';
69 | if (post_answering_on_count(post_id) > 10) {
70 | // Пост считается спамом если отвечает
71 | // более чем на 10 постов
72 | postfix += ' (spam)';
73 | link.classList.add('spam')
74 | }
75 | link.textContent = prefix + post_id + postfix;
76 | link.href = '#post_' + post_id;
77 | return link;
78 | }
79 |
80 | // Функция для рекурсивного заполнения ответов
81 | function print_answers(answers, prefix) {
82 | for (let i = 0; i < answers.length; i++) {
83 | const answer = answers[i];
84 | printed_as_answers.push(answer);
85 | dashboard.appendChild(get_link(answer, prefix));
86 | // Рекурсивный вызов с изменением префикса
87 | print_answers(get_answers_for(answer), prefix + '> ')
88 | }
89 | }
90 |
91 | // Получить ответы на пост с заданным айди
92 | function get_answers_for(post_id) {
93 | for (let i = 0; i < posts_answers.length; i++) {
94 | const id = posts_answers[i].post_id;
95 | if (id === post_id) {
96 | return posts_answers[i].answers;
97 | }
98 | }
99 | }
100 |
101 | let printed_as_answers = [];
102 |
103 | for (let i = 0; i < posts_answers.length; i++) {
104 | const post_id = posts_answers[i].post_id;
105 | const answers = posts_answers[i].answers;
106 |
107 | if (printed_as_answers.includes(post_id)) {
108 | continue;
109 | }
110 |
111 | // Вывести номер текущего поста
112 | dashboard.appendChild(get_link(post_id, ''));
113 |
114 | // Вывести ответы на этот пост рекурсивно
115 | print_answers(answers, '> ')
116 | }
117 |
118 | // Заменить ссылки
119 | for (let i = 0; i < posts.length; i++) {
120 | const post = posts[i];
121 | replies_links = post.getElementsByClassName('post-reply-link');
122 | for (let j = 0; j < replies_links.length; j++) {
123 | const reply = replies_links[j];
124 | let text = reply.text;
125 | let reply_id = text.substring(2).split(' ')[0];
126 | replies_links[j].href = "#post_" + reply_id;
127 | }
128 |
129 | // Добавить footer ответы
130 | footer = post.getElementsByClassName('post_footer')[0];
131 | answers_for_current = get_answers_for(posts[i].id.split('_')[1])
132 |
133 | for (let i = 0; i < answers_for_current.length; i++) {
134 | const element = answers_for_current[i];
135 |
136 | let curr_link = get_link(element, '>>')
137 |
138 | footer.appendChild(curr_link);
139 | }
140 | }
--------------------------------------------------------------------------------
/test_dvach.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | import dvach
3 | import filecompare
4 | import os
5 |
6 |
7 | class TestAnalytics(unittest.TestCase):
8 |
9 | def read_file(self, file_name) -> str:
10 | """Прочитать файл в директории test_files/
11 |
12 | В директории `test_files/` собраны файлы которые используются для тестирования
13 |
14 | Args:
15 | file_name (str): название файла в директории test_files/
16 |
17 | Returns:
18 | str: Содержимое файла
19 | """
20 | f = open(os.path.normpath(f'test_files/{file_name}'))
21 | text = f.read()
22 | f.close()
23 | return text
24 |
25 | def test_read_json(self):
26 | """Тест парсинга json доски
27 | """
28 | json_plain = self.read_file('threads_json.json')
29 |
30 | # Тест этой функции
31 | b = dvach.Board.from_json(json_plain)
32 |
33 | self.assertEqual(b.name, 'b')
34 | self.assertEqual(len(b.threads.keys()), 5)
35 |
36 | # Проверка правильности первого треда
37 | id_of_first = list(b.threads.keys())[0]
38 | i = id_of_first
39 | self.assertEqual(b.threads[i].board_name, 'b')
40 | self.assertEqual(b.threads[i].comment, 'Продолжаем наблюдать за тупорылостью зумерков')
41 | self.assertEqual(b.threads[i].lasthit, 1619954786)
42 | self.assertEqual(b.threads[i].num, "1")
43 | self.assertEqual(b.threads[i].posts_count, 54)
44 | self.assertEqual(b.threads[i].score, 0)
45 | self.assertEqual(b.threads[i].subject, "Продолжаем наблюдать за тупорылостью зумерков")
46 | self.assertEqual(b.threads[i].timestamp, 1619952370)
47 | self.assertEqual(b.threads[i].views, 0)
48 |
49 | def test_new_thread(self):
50 | """Тест обнаружения новых тредов на доске
51 | """
52 | json_plain = self.read_file('threads_json.json')
53 | b = dvach.Board.from_json(json_plain)
54 |
55 | json_withnew_plain = self.read_file('threads_json_withnew.json')
56 | threads_withnew_1 = dvach.Board.from_json(json_withnew_plain).threads
57 |
58 | json_withnew2_plain = self.read_file('threads_json_withnew2.json')
59 | threads_withnew_2 = dvach.Board.from_json(json_withnew2_plain).threads
60 |
61 | # Тест если добавляется один тред
62 | new = b.get_new_threads(threads_withnew_1)
63 | self.assertEqual(len(new), 1)
64 | self.assertEqual(list(new.keys())[0], "6")
65 |
66 | # Тест если добавляется два треда
67 | new = b.get_new_threads(threads_withnew_2)
68 | self.assertEqual(len(new), 2)
69 | self.assertEqual(list(new.keys())[0], "6")
70 | self.assertEqual(list(new.keys())[1], "7")
71 |
72 | def test_dead_thread(self):
73 | """Тест обнаружения умерших тредов на доске
74 | """
75 | json_plain = self.read_file('threads_json.json')
76 | b = dvach.Board.from_json(json_plain)
77 |
78 | json_deleted_plain = self.read_file('threads_json_removed.json')
79 | threads_deleted_1 = dvach.Board.from_json(json_deleted_plain).threads
80 |
81 | json_deleted_plain2 = self.read_file('threads_json_removed2.json')
82 | threads_deleted_2 = dvach.Board.from_json(json_deleted_plain2).threads
83 |
84 | # Тест если удаляется один тред
85 | deleted = b.get_dead_threads(threads_deleted_1)
86 | self.assertEqual(len(deleted), 1)
87 | self.assertEqual(list(deleted.keys())[0], "3")
88 |
89 | # Тест если удаляется два треда
90 | deleted = b.get_dead_threads(threads_deleted_2)
91 | self.assertEqual(len(deleted), 2)
92 | self.assertEqual(list(deleted.keys())[0], "2")
93 | self.assertEqual(list(deleted.keys())[1], "3")
94 |
95 | def test_read_posts(self):
96 | """Тест парсига постов в треде по json
97 | """
98 | # taken from: https://2ch.hk/{self.board_name}/res/{self.num}.json
99 | json_plain = self.read_file('posts_json.json')
100 |
101 | thread = dvach.Thread('b')
102 | # Тест этой функции
103 | thread.get_posts(json_plain)
104 |
105 | self.assertEqual(thread.unique_posters, 43)
106 | self.assertEqual(len(thread.posts), 7)
107 | self.assertEqual(len(thread.posts[0].files), 1)
108 |
109 | # Проверка правильности первого поста.
110 | self.assertEqual(thread.posts[0].comment, "О чём говорить с тней на свидании? Помоги, двач, умоляю.")
111 | self.assertEqual(thread.posts[0].num, "245701589")
112 | self.assertEqual(thread.posts[0].files[0].displayname, "3741000.jpg")
113 | self.assertEqual(thread.posts[0].files[0].name, "16199547970060.jpg")
114 | self.assertEqual(thread.posts[0].files[0].path, "/b/src/245701589/16199547970060.jpg")
115 | self.assertEqual(thread.posts[0].files[0].width, 1000)
116 | self.assertEqual(thread.posts[0].files[0].height, 666)
117 |
118 | def test_similar_img(self):
119 | """Тест поиска похожих изображений
120 | """
121 | # Эти фото отличаются размером
122 | file1_1 = os.path.normpath('test_files/images/img1_1.jpg')
123 | file1_2 = os.path.normpath('test_files/images/img1_2.jpg')
124 | r1 = filecompare.are_similar(file1_1, file1_2)
125 |
126 | # Эти фото различаются незначительными деталями, но они не одинаковые
127 | file2_1 = os.path.normpath('test_files/images/img2_1.png')
128 | file2_2 = os.path.normpath('test_files/images/img2_2.png')
129 | r2 = filecompare.are_similar(file2_1, file2_2)
130 |
131 | # Эти фото значительно отличаются
132 | file3_1 = os.path.normpath('test_files/images/img3_1.jpg')
133 | file3_2 = os.path.normpath('test_files/images/img3_2.jpg')
134 | r3 = filecompare.are_similar(file3_1, file3_2)
135 |
136 | self.assertEqual(r1, True)
137 | self.assertEqual(r2, False)
138 | self.assertEqual(r3, False)
139 |
140 | def test_better_img(self):
141 | """Тест поиска лучшего изображения из двух
142 | """
143 | # Первая картинка лучше
144 | file1 = os.path.normpath('test_files/images/img1_1.jpg')
145 | file2 = os.path.normpath('test_files/images/img1_2.jpg')
146 |
147 | best = filecompare.get_better_img(file1, file2)
148 |
149 | self.assertEqual(best, file1)
150 |
151 | def test_isOk(self):
152 | json_plain = self.read_file('threads_json.json')
153 | b = dvach.Board.from_json(json_plain)
154 | thread_num = list(b.threads.keys())[0]
155 | thread = b.threads[thread_num]
156 |
157 | self.assertEqual(thread.IsOk([]), True)
158 | self.assertEqual(thread.IsOk(["зумер"]), True)
159 | self.assertEqual(thread.IsOk(["продолжаем"]), True)
160 | self.assertEqual(thread.IsOk(["зумеры"]), False)
161 |
--------------------------------------------------------------------------------
/board_media.py:
--------------------------------------------------------------------------------
1 | import dvach
2 | import filecompare
3 | from typing import List
4 | import time
5 | import os
6 |
7 |
8 | # Скачать все файлы со всех тредов доски
9 | BOARD = 'b'
10 | FOLDER_NAME = 'media' # название папки, куда будут скачиваться файлы
11 | KEY_WORDS = [ # список ключевых слов
12 | # "WEBM",
13 | # "webm",
14 | # "цуиь"
15 | ]
16 | EXTENSIONS = [
17 | 'png',
18 | 'jpg',
19 | 'webm',
20 | 'mp4',
21 | 'gif'
22 | ]
23 |
24 | # Задать максимальный размер файла в Килобайтах
25 | # Если равен 0, то ограничений нет
26 | MAX_FILE_SIZE = 0
27 |
28 | # Задать минимальный размер файла в Килобайтах
29 | # Если равен 0, то ограничений нет
30 | MIN_FILE_SIZE = 0
31 |
32 |
33 | class Hashtable:
34 | """ Файл с информацией об уже скачанных картинках"""
35 | path: str
36 | table: dict
37 |
38 | def __init__(self, path: str):
39 | self.path = path
40 | if not os.path.exists(path):
41 | open(path, 'w', encoding='utf-8').close()
42 | self.load_file()
43 |
44 | def load_file(self):
45 | """ Загрузить информацию из файла"""
46 | self.table = dict()
47 | f = open(self.path, encoding='utf-8')
48 | lines = f.readlines()
49 | for line in lines:
50 | try:
51 | hash = line.split('|')[0]
52 | path = line.split('|')[1]
53 | self.table[hash] = path
54 | finally:
55 | pass
56 | f.close()
57 |
58 | def add_hash(self, path: str, hash: str):
59 | """Добавить хеш в словарь
60 |
61 | Args:
62 | path (str): путь к файлу
63 | hash (str): строковое представление файла
64 | """
65 | try:
66 | self.table[hash] = path
67 | self.__write_to_file(f'{hash}|{path}\n')
68 | finally:
69 | pass
70 |
71 | def __write_to_file(self, text: str):
72 | f = open(self.path, 'a', encoding='utf-8')
73 | f.write(text)
74 | f.close()
75 |
76 | def save_file(self):
77 | """Сохранить словарь в файл"""
78 | f = open(self.path, 'w', encoding='utf-8')
79 | f.write('') # отчистить предыдущие
80 | for key in self.table.keys():
81 | hash = key
82 | path = self.table[key]
83 | line = f"{hash}|{path}\n"
84 | f.write(line)
85 | f.close()
86 |
87 |
88 | class FileDownloadInfo:
89 | Succeed = 0
90 | Exists = 1
91 | IsNotOk = 2
92 | LinkCreated = 3
93 | Error = 4
94 |
95 |
96 | class BoardMedia:
97 | hashtable: Hashtable
98 | download_folder: str
99 |
100 | def __init__(self, path_to_folder: str) -> None:
101 | self.download_folder = path_to_folder
102 | self.hashtable = Hashtable(
103 | os.path.normpath(f'{path_to_folder}/hashtable'))
104 |
105 | def findInTable(self, hash: str):
106 | """Найти такую-же фотографию в базе
107 |
108 | Args:
109 | hash (str): строковое представление фото
110 |
111 | Returns:
112 | str: путь к такой же фотографии, если дубликата нет - пустая строка
113 | """
114 | if hash in self.hashtable.table.keys():
115 | return str(self.hashtable.table[hash]).strip()
116 | else:
117 | return ''
118 |
119 | def download_file(self, thread_num: str, file: dvach.Post_file):
120 | """Скачать файл и вернуть информацию о результате
121 |
122 | Args:
123 | thread_num (str): Номер треда
124 | file (dvach.Post_file): Файл
125 |
126 | Returns:
127 | int: FileDownloadInfo
128 | """
129 | thread_folder = os.path.normpath(
130 | f'{self.download_folder}/{thread_num}')
131 | download_path = os.path.normpath(f'{thread_folder}/' + file.name)
132 |
133 | if os.path.exists(download_path):
134 | return FileDownloadInfo.Exists
135 |
136 | # Проверяем подходит ли файл
137 | if not file.IsOk(EXTENSIONS, MAX_FILE_SIZE, MIN_FILE_SIZE):
138 | return FileDownloadInfo.IsNotOk
139 |
140 | try:
141 | file.save(download_path)
142 | except:
143 | # Если не получилось скачать файл
144 | return FileDownloadInfo.Error
145 |
146 | # Получить хэш
147 | try:
148 | if file.IsImage:
149 | file_hash = filecompare.CalcImageHash(download_path)
150 | elif file.IsVideo:
151 | file_hash = filecompare.CalcVideoHash(download_path)
152 | except:
153 | # Не получилось посчитать хэш
154 | return FileDownloadInfo.Succeed
155 |
156 |
157 | same_file = self.findInTable(file_hash)
158 |
159 | if same_file != '':
160 | # Если такой же файл есть, то создать ссылку
161 | os.remove(download_path)
162 | os.symlink(os.path.abspath(same_file),
163 | download_path, target_is_directory=False)
164 | return FileDownloadInfo.LinkCreated
165 |
166 | # Если такого же файла нет, то сохранить хеш в таблицу
167 | self.hashtable.add_hash(download_path, file_hash)
168 |
169 | return FileDownloadInfo.Succeed
170 |
171 |
172 | def download_thread_files(posts: List[dvach.Post], thread_num: str):
173 | """Скачать файлы постов треда
174 |
175 | Args:
176 | posts (List[dvach.Post]): список постов
177 | """
178 | for post in posts:
179 | for file in post.files:
180 | i = boardmedia.download_file(thread_num, file)
181 |
182 | if i == FileDownloadInfo.Succeed:
183 | print(f'Скачан файл из треда {thread_num}: {file.name}')
184 | elif i == FileDownloadInfo.Exists:
185 | # Файл существует
186 | pass
187 | elif i == FileDownloadInfo.IsNotOk:
188 | # Файл не подошел по критериям отбора
189 | pass
190 | elif i == FileDownloadInfo.LinkCreated:
191 | # Ссылка создана
192 | print(f'Создана ссылка в {thread_num}: {file.name}')
193 | elif i == FileDownloadInfo.Error:
194 | print('.', end='')
195 | time.sleep(3)
196 |
197 | time.sleep(0.1)
198 |
199 |
200 | def create_thread_folder(download_folder: str, thread_folder: str):
201 | download_folder = os.path.normpath(download_folder)
202 | thread_folder = os.path.normpath(thread_folder)
203 | # Создаём папку с media
204 | if not os.path.exists(download_folder):
205 | os.mkdir(download_folder)
206 | # Создаём папку треда в media
207 | if not os.path.exists(thread_folder):
208 | os.mkdir(thread_folder)
209 |
210 |
211 | if __name__ == '__main__':
212 | # Создаём папку с медиа
213 | if not os.path.exists(FOLDER_NAME):
214 | os.mkdir(FOLDER_NAME)
215 |
216 | print('Загрузка файла')
217 | global boardmedia
218 | boardmedia = BoardMedia(FOLDER_NAME)
219 |
220 | # Скачиваем доску с тредами
221 | board = dvach.Board(BOARD)
222 | while True:
223 | try:
224 | board.update_threads()
225 | except:
226 | # Если не получилось скачать список тредов
227 | time.sleep(3)
228 | continue
229 |
230 | for thread_num in board.threads.keys():
231 | # Тред с которого скачивать файлы
232 | thread = board.threads[thread_num]
233 |
234 | if not thread.IsOk(KEY_WORDS):
235 | continue # Если не подходит - пропускаем
236 |
237 | # Скачиваем посты треда
238 | try:
239 | thread.update_posts()
240 | except:
241 | # Если не получислось скачать список постов
242 | print('.', end='')
243 | time.sleep(3)
244 | continue
245 |
246 | # Создаем папку, в которую сохраняется тред
247 | create_thread_folder(FOLDER_NAME, f"{FOLDER_NAME}/{thread.num}")
248 |
249 | # Скачиваем файлы в папку media/{thread_num}
250 | download_thread_files(thread.posts, thread.num)
251 |
252 | # Скачать посты треда
253 | thread.save(FOLDER_NAME + f'/{thread.num}')
254 |
--------------------------------------------------------------------------------
/test_files/posts_json.json:
--------------------------------------------------------------------------------
1 | {
2 | "advert_bottom_image": "/banners/wRODOKgFWnwdjCon.png",
3 | "advert_bottom_link": "/banners/wRODOKgFWnwdjCon/",
4 | "advert_mobile_image": "/banners/BXi6uAfJFvSkBs2s.png",
5 | "advert_mobile_link": "/banners/BXi6uAfJFvSkBs2s/",
6 | "advert_top_image": "/banners/WDag2UnsxpBHPgkb.png",
7 | "advert_top_link": "/banners/WDag2UnsxpBHPgkb/",
8 | "board": {
9 | "bump_limit": 500,
10 | "category": "Разное",
11 | "default_name": "Аноним",
12 | "enable_dices": false,
13 | "enable_flags": false,
14 | "enable_icons": false,
15 | "enable_likes": false,
16 | "enable_names": false,
17 | "enable_oekaki": true,
18 | "enable_posting": true,
19 | "enable_sage": true,
20 | "enable_shield": false,
21 | "enable_subject": false,
22 | "enable_thread_tags": false,
23 | "enable_trips": false,
24 | "file_types": [
25 | "jpg",
26 | "png",
27 | "gif",
28 | "webm",
29 | "sticker",
30 | "mp4",
31 | "youtube"
32 | ],
33 | "id": "b",
34 | "info": "",
35 | "info_outer": "бред",
36 | "max_comment": 15000,
37 | "max_files_size": 20480,
38 | "max_pages": 10,
39 | "name": "Бред",
40 | "threads_per_page": 21
41 | },
42 | "board_banner_image": "/ololo/tes_6.gif",
43 | "board_banner_link": "tes",
44 | "current_thread": 274234325,
45 | "files_count": 1,
46 | "is_board": false,
47 | "is_closed": 0,
48 | "is_index": false,
49 | "max_num": 274240131,
50 | "posts_count": 27,
51 | "thread_first_image": "/b/src/245701589/16199547970060.jpg",
52 | "threads": [
53 | {
54 | "posts": [
55 | {
56 | "banned": 0,
57 | "board": "b",
58 | "closed": 0,
59 | "comment": "О чём говорить с тней на свидании? Помоги, двач, умоляю.",
60 | "date": "14/09/22 Срд 17:50:56",
61 | "email": "",
62 | "endless": 0,
63 | "files": [
64 | {
65 | "displayname": "3741000.jpg",
66 | "fullname": "16199547970060.jpg",
67 | "height": 666,
68 | "md5": "fa656ba178adfcf2870f3b884926a8a8",
69 | "name": "16199547970060.jpg",
70 | "path": "/b/src/245701589/16199547970060.jpg",
71 | "size": 59,
72 | "thumbnail": "/b/thumb/274234325/16631670566660s.jpg",
73 | "tn_height": 666,
74 | "tn_width": 1000,
75 | "type": 1,
76 | "width": 1000
77 | }
78 | ],
79 | "lasthit": 1663175385,
80 | "name": "Аноним",
81 | "num": 245701589,
82 | "number": 1,
83 | "op": 0,
84 | "parent": 0,
85 | "sticky": 0,
86 | "subject": "Как люди играют по 6+ часов? Я сейчас поиграл 2 часа в хитмана, когда вышел с игры ахуел, тупо всё т",
87 | "tags": "",
88 | "timestamp": 1663167056,
89 | "trip": "",
90 | "views": 100
91 | },
92 | {
93 | "banned": 0,
94 | "board": "b",
95 | "closed": 0,
96 | "comment": "Бамп",
97 | "date": "14/09/22 Срд 17:51:23",
98 | "email": "",
99 | "endless": 0,
100 | "files": null,
101 | "lasthit": 1663175385,
102 | "name": "Аноним",
103 | "num": 274234341,
104 | "number": 2,
105 | "op": 0,
106 | "parent": 274234325,
107 | "sticky": 0,
108 | "subject": "",
109 | "timestamp": 1663167083,
110 | "trip": "",
111 | "views": 0
112 | },
113 | {
114 | "banned": 0,
115 | "board": "b",
116 | "closed": 0,
117 | "comment": "Бамп",
118 | "date": "14/09/22 Срд 17:53:48",
119 | "email": "",
120 | "endless": 0,
121 | "files": null,
122 | "lasthit": 1663175385,
123 | "name": "Аноним",
124 | "num": 274234448,
125 | "number": 3,
126 | "op": 0,
127 | "parent": 274234325,
128 | "sticky": 0,
129 | "subject": "",
130 | "timestamp": 1663167228,
131 | "trip": "",
132 | "views": 0
133 | },
134 | {
135 | "banned": 0,
136 | "board": "b",
137 | "closed": 0,
138 | "comment": "Чо, на какой локации сейчас бегаешь? Хоккайдо самая пиздатая. ",
139 | "date": "14/09/22 Срд 17:59:39",
140 | "email": "",
141 | "endless": 0,
142 | "files": null,
143 | "lasthit": 1663175385,
144 | "name": "Аноним",
145 | "num": 274234686,
146 | "number": 4,
147 | "op": 0,
148 | "parent": 274234325,
149 | "sticky": 0,
150 | "subject": "",
151 | "timestamp": 1663167579,
152 | "trip": "",
153 | "views": 0
154 | },
155 | {
156 | "banned": 0,
157 | "board": "b",
158 | "closed": 0,
159 | "comment": "\u003ca href=\"/b/res/274234325.html#274234686\" class=\"post-reply-link\" data-thread=\"274234325\" data-num=\"274234686\"\u003e\u003e\u003e274234686\u003c/a\u003e\u003cbr\u003eПерепрохожу все хитманы, щас на Сапиенца, самая унылая карта как по мне, хотя может из-за того что я задрочил её от и до.",
160 | "date": "14/09/22 Срд 18:05:23",
161 | "email": "",
162 | "endless": 0,
163 | "files": null,
164 | "lasthit": 1663175385,
165 | "name": "Аноним",
166 | "num": 274234905,
167 | "number": 5,
168 | "op": 0,
169 | "parent": 274234325,
170 | "sticky": 0,
171 | "subject": "",
172 | "timestamp": 1663167923,
173 | "trip": "",
174 | "views": 0
175 | },
176 | {
177 | "banned": 0,
178 | "board": "b",
179 | "closed": 0,
180 | "comment": "\u003ca href=\"/b/res/274234325.html#274234905\" class=\"post-reply-link\" data-thread=\"274234325\" data-num=\"274234905\"\u003e\u003e\u003e274234905\u003c/a\u003e\u003cbr\u003eОп",
181 | "date": "14/09/22 Срд 18:06:14",
182 | "email": "",
183 | "endless": 0,
184 | "files": null,
185 | "lasthit": 1663175385,
186 | "name": "Аноним",
187 | "num": 274234944,
188 | "number": 6,
189 | "op": 0,
190 | "parent": 274234325,
191 | "sticky": 0,
192 | "subject": "",
193 | "timestamp": 1663167974,
194 | "trip": "",
195 | "views": 0
196 | },
197 | {
198 | "banned": 0,
199 | "board": "b",
200 | "closed": 0,
201 | "comment": "Бамп\u003cbr\u003eЧому метка не работает",
202 | "date": "14/09/22 Срд 18:08:42",
203 | "email": "",
204 | "endless": 0,
205 | "files": null,
206 | "lasthit": 1663175385,
207 | "name": "Аноним",
208 | "num": 274235064,
209 | "number": 7,
210 | "op": 0,
211 | "parent": 274234325,
212 | "sticky": 0,
213 | "subject": "",
214 | "timestamp": 1663168122,
215 | "trip": "",
216 | "views": 0
217 | }
218 | ]
219 | }
220 | ],
221 | "title": "Как люди играют по 6+ часов? Я сейчас поиграл 2 часа в хитмана, когда вышел с игры ахуел, тупо всё т",
222 | "unique_posters": 43
223 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # О проекте
2 | 
3 | 
4 | 
5 | 
6 |
7 | Хочешь скачать все файлы с /b или другого раздела? И без дубликатов? И только файлы больше/меньше X Килобайт? Или только картинки/только видео? Или файлы только с конкретного треда? Или тебе нужен трекер, который будет отбирать треды по ключевым словам? *Всё это здесь! И даже больше!*
8 |
9 | В репозитории представлен готовый набор скриптов для [двача](https://2ch.hk), все скрипты можно кастомизировать под свои задачи. При минимальных знаниях питона можно с легкостью написать скрипт под свои нужды. Вся информация ниже.
10 |
11 | # Установка
12 | [Видеоинструкция](https://drive.google.com/file/d/1LLfW1EWSTYcFTAoCR02h_Ai04-IzOlba/)
13 |
14 | Установите [python](https://www.python.org)
15 |
16 | Скачайте zip архив или:
17 | ```
18 | git clone https://github.com/diademoff/2ch
19 | ```
20 |
21 | Установите зависимости:
22 | ```
23 | cd 2ch
24 | pip install -r requirements.txt
25 | ```
26 |
27 | Запускайте нужный скрипт:
28 | ```
29 | python {название скрипта}.py
30 | ```
31 |
32 | # Список скриптов
33 | * Скачать все файлы и посты треда [`thread_saver.py`](./thread_saver.py)
34 | * Уведомления о новых тредах на досках [`tracker.py`](./tracker.py)
35 | * Самые популярные треды на доске [`popular.py`](./popular.py)
36 | * Скачивать все файлы доски [`board_media.py`](./board_media.py)
37 |
38 | # Редактирование скриптов
39 | Все скрипты можно редактировать под ваши задачи.
40 | * `thread_saver.py`
41 | * `FOLDER = 'saver'` - Изменить имя папки, в которую будут сохраняться файлы
42 | * `SAVE_MEDIA` - Сохранять ли изображения и видео
43 | * `DELAY` - Интервал обновления в секундах
44 | * `tracker.py`
45 | * `text_limit = 155` - Изменить длину строки
46 | * `board_names = 'b news sex v hw gg dev soc rf ma psy fet'` - Изменить список досок (писать через пробел)
47 | * `KEY_WORDS` - Указать ключевые слова
48 | * `popular.py`
49 | * `text_limit = 164` - Длина строки
50 | * `max_lines = 55` - Максимальное количество строк в выводе
51 | * `board_name = 'b'` - Доска, которая парсится
52 | * `KEY_WORDS` - Выводить треды только с ключевыми словами
53 | * `board_media.py`
54 | * `BOARD = 'b'` - Имя борды, с которой скачивать файлы
55 | * `FOLDER_NAME = 'media'` - Имя папки, в которую скачивать файлы
56 | * `KEY_WORDS = []` - Отбирать треды по ключевым словам, если ключевые слова не указаны, то будут скачиваться файлы всех тредов
57 | * `EXTENSIONS = []` - Файлы с какими расширениями скачивать
58 | * `MAX_FILE_SIZE` - Задать максимальный размер файла в Килобайтах
59 | * `MIN_FILE_SIZE` - Задать минимальный размер файла в Килобайтах
60 |
61 |
62 | # FAQ
63 | * Скрипт не запускается.
64 |
65 | Проверьте установлены ли зависимости: `pip install -r requirements.txt`. Проверьте кодировку файлов. Проверьте, что у вас установлена версия Python > 3.
66 |
67 | * Как сравниваются изображения?
68 |
69 | Изображения сравниваются по содержимому. Даже если у изображений разное расширение `png` и `jpg`, или разный размер они всё равно будут распознаны как одинаковые.
70 |
71 | * Ты используешь api двача?
72 |
73 | Да. А конкретно:
74 | ```
75 | https://2ch.hk/makaba/mobile.fcgi?task=get_thread&board={board_name}&thread={num}&post=1
76 | http://2ch.hk/{name}/threads.json
77 | ```
78 | * Зачем тебе beautiful soup?
79 |
80 | Преимущественно чтобы убирать html теги в постах. Если в посте **жирный текст**, то получается так:
81 | `текст`. Этот тэг нужно убрать, чтобы остался только текст.
82 |
83 | * Как указать ключевые слова?
84 |
85 | Откройте нужный скрипт и отредактируйте по образцу. Обратите внимание на форматирование, запятые и кавычки.
86 | ```py
87 | KEY_WORDS = [
88 | "цуиь",
89 | "mp4"
90 | ]
91 | ```
92 |
93 | * Скрипты кроссплатформенные?
94 |
95 | Да. Скрипты были проверены на Linux и Windows.
96 |
97 | # Для разработчиков
98 | Весь api хранится в файле `dvach.py`. Подключаем:
99 |
100 | ```py
101 | import dvach
102 | ```
103 | ## Структура
104 | * **Board**
105 | * `name: str` - Имя доски
106 | * `posts: dict` - Список постов, это словарь. Ключ - это номер треда, значение - переменная типа `Thread`
107 | * `json_link: str` - Ссылка на json тредов
108 | * `from_json()` - Получить объект `Board` из json'а
109 | * `json_download()` - Скачать json доски
110 | * `thread_exists()` - Есть ли на доске тред с указанным номером
111 | * `update_threads()` - Обновить список тредов на доске
112 | * `sort_threads_by_posts()` - Отсортировать список тредов по количеству постов, чем ближе элемент к началу списка, тем больше в нем постов
113 | * `get_new_threads()` - Сравнить текущий список тредов с другим и получить словарь новых тредов
114 | * `get_dead_threads()` - Сравнить текущий список тредов с другим и получить словарь утонувших тредов
115 | * **Thread**
116 | * `comment: str` - Текст в ОП посте
117 | * `num: str` - Номер треда
118 | * `posts_count: int` - Количество постов
119 | * `score: float` - Сколько очков у треда
120 | * `subject: str` - Сокращенный `comment`
121 | * `views: int` - Количество просмотров
122 | * `unique_posters: int` - Количество уникальных просмотров (появится после обновления постов)
123 | * `board_name: str` - Какой доске принадлежит тред
124 | * `posts = []` - Список постов
125 | * `get_link: str` - Ссылка на тред
126 | * `get_op_post: Post` - Получить ОП-пост
127 | * `json_posts_link: str` - Ссылка на json треда
128 | * `save(path)` - сохранить в html посты треда в указанную папку
129 | * `IsOk()` - Подходит ли тред по заданным ключевым словам
130 | * `update_posts()` - Скачать json и обновить их список, вызывает функцию `get_posts()`
131 | * `get_posts()` - Спарсить json и обновить `unique_posters` и `posts`
132 | * `json_download()` - Получить json постов в чистом виде
133 | * **Post**
134 | * `comment: str` - Текст
135 | * `date: str` - Дата поста
136 | * `email: str`
137 | * `op: int`
138 | * `num: str` - Номер
139 | * `files: []` - Список файлов
140 | * **Post_file**
141 | * `displayname: str` - Отображаемое имя
142 | * `name: str` - Имя
143 | * `download_link: str` - Ссылка на скачивание
144 | * `width: int` - Ширина
145 | * `height: int` - Высота
146 | * `size: int` - Размер файла
147 | * `IsImage: bool` - Является ли файл изображением
148 | * `IsVideo: bool` - Является ли файл видео
149 | * `save()` - Сохранить файл по указанному пути
150 | * `IsOk()` - Подходит ли файл по заданным расширениям, максимальному и минимальному размеру
151 |
152 |
153 | ## Доски
154 | Класс `Board` позволяет взаимодействовать с досками (b, news, po, soc и т.д).
155 |
156 | Объявление:
157 | ```py
158 | board = dvach.Board('b')
159 | ```
160 |
161 | Теперь в переменной `board` хранится доска `b`, но там нет никакой информации, кроме названия доски. *Чтобы получить список тредов на доске*:
162 | ```py
163 | board.update_threads()
164 | ```
165 |
166 | Теперь в поле `threads` находится словарь с тредами. Ключ - это номер треда, значение - это тред (`Thread`).
167 |
168 | Получить список с номерами тредов:
169 | ```py
170 | # Список из номеров тредов, каждый номер имеет строковой тип.
171 | thread_nums = list(board.threads.keys())
172 | ```
173 |
174 | Отсортируем по популярности и снова получим список номеров тредов:
175 | ```py
176 | board.sort_threads_by_posts()
177 | thread_nums = list(board.threads.keys())
178 | ```
179 |
180 | Первый элемент теперь является номером самого популярного треда:
181 | ```py
182 | most_popular_num = thread_nums[0]
183 | ```
184 |
185 | ## Треды
186 | Мы получили *номер* самого популярного треда, теперь получим сам тред из словаря `threads`:
187 | ```
188 | thread = board.threads[most_popular_num]
189 | ```
190 | В этом словаре значение имеет тип `Thread`. Посмотрим тип переменной `thread`:
191 | ```py
192 | print(type(thread))
193 | ```
194 |
195 | Получим: ``
196 |
197 | Получим список постов в треде:
198 | ```py
199 | print(f"Количество постов (длина posts): {len(thread.posts)}")
200 | print(f"Количество постов (posts_count): {thread.posts_count}")
201 |
202 | thread.update_posts()
203 |
204 | print(f"Количество постов (длина posts): {len(thread.posts)}")
205 | print(f"Уникальных просмотров: {thread.unique_posters}")
206 | ```
207 |
208 | На выходе получим:
209 | ```
210 | Количество постов (длина posts): 0
211 | Количество постов (posts_count): 60
212 | Количество постов (длина posts): 64
213 | Уникальных просмотров: 34
214 | ```
215 |
216 | `unique_posters` - появляется только после вызова `update_posts()` или `get_posts()`.
217 |
218 | Получение количества постов с помощью `len(thread.posts)` является более точным, но требует загрузки всех постов, в то время как `thread.posts_count` известно во время *получения тредов на доске*.
219 |
220 | ## Сохранение треда в html
221 | Для сохранения треда используйте класс `HtmlGenerator` и метод `get_thread_htmlpage`. Этот метод возвращает html код, который можно сохранить в файл.
222 | ```py
223 | op_file = thread.posts[0].files[0] # Картинка в ОП-посте
224 | img_path = os.path.normpath(f'./{op_file.name}') # Путь, куда мы ее сохраним
225 | op_file.save(img_path) # Сохраняем картинку
226 |
227 | # Получаем html
228 | html = dvach.HtmlGenerator.get_thread_htmlpage(thread, img_path)
229 |
230 | # Создаём файл
231 | file = open(f'thread_{thread.num}.html', 'w')
232 |
233 | # Записывает туда html страницу
234 | file.write(html)
235 | ```
236 |
237 | Или используйте функцию:
238 | ```py
239 | # Файл сохранится в папку, в которой выполняется скрипт с именем thread_{num}.html
240 | thread.save('.')
241 | ```
242 |
243 | ## Посты
244 | После получения списка постов с помощью `update_posts()` в поле `posts` появился список постов начиная с ОП-поста.
245 |
246 | Посмотрим второй пост в треде:
247 | ```py
248 | post = thread.posts[1]
249 |
250 | print(f"Номер: {post.num}")
251 | print(f"Текст: {post.comment}")
252 | print(f"Количество файлов: {len(post.files)}")
253 | ```
254 |
255 | На выходе получаем:
256 | ```
257 | Номер: 210762237
258 | Текст: Бамп
259 | Количество файлов: 1
260 | ```
261 |
262 | ## Файлы
263 | Теперь получим первый файл в посте, если файл есть:
264 | ```py
265 | if len(post.files) > 0:
266 | file = post.files[0]
267 | print(type(file))
268 | ```
269 |
270 | На выходе получим: ``
271 |
272 | Посмотрим больше информации о файле:
273 | ```py
274 | print(f"Имя файла: {file.name}")
275 | print(f"Ширина: {file.width}")
276 | print(f"Высота: {file.height}")
277 | print(f"Отображаемое имя: {file.displayname}")
278 | print(f"Ссылка: {file.download_link}")
279 | ```
280 |
281 | На выходе:
282 | ```
283 | Имя файла: 16200245064090.jpg
284 | Ширина: 3118
285 | Высота: 1754
286 | Отображаемое имя: 1620024504280.jpg
287 | Ссылка: https://2ch.hk/b/src/245763818/16200245064090.jpg
288 | ```
289 |
290 | Можно легко сохранить файл:
291 | ```py
292 | file.save(file.name)
293 | ```
294 |
295 | Файл будет сохранен в директорию в которой выполняется скрипт с именем `16200245064090.jpg`
296 |
297 | Можно указать кастомный путь:
298 | ```py
299 | file.save(f"/home/username/{file.name}")
300 | ```
301 |
302 | ## Итого
303 | Весь код, используемый в примерах:
304 | ```py
305 | import dvach
306 | import os
307 |
308 | # Объявить доску
309 | board = dvach.Board('b')
310 |
311 | # Скачать треды
312 | board.update_threads()
313 |
314 | # Получить список номеров тредов
315 | thread_nums = list(board.threads.keys())
316 |
317 | # Отсортировать по количеству постов
318 | board.sort_threads_by_posts()
319 |
320 | # Обновить список с номерами тредов
321 | thread_nums = list(board.threads.keys())
322 |
323 | # Номер самого популярного треда
324 | most_popular_num = thread_nums[0]
325 |
326 | # Самый популярный тред
327 | thread = board.threads[most_popular_num]
328 |
329 | # Посмотреть тип переменной
330 | print(type(thread))
331 |
332 | print(f"Количество постов (длина posts): {len(thread.posts)}")
333 | print(f"Количество постов (posts_count): {thread.posts_count}")
334 |
335 | # Скачать посты
336 | thread.update_posts()
337 |
338 | print(f"Количество постов (длина posts): {len(thread.posts)}")
339 | print(f"Уникальных просмотров: {thread.unique_posters}")
340 |
341 | op_file = thread.posts[0].files[0] # Картинка в ОП-посте
342 | img_path = os.path.normpath(f'./{op_file.name}') # Путь, куда мы ее сохраним
343 | op_file.save(img_path) # Сохраняем картинку
344 |
345 | # Получаем html
346 | html = dvach.HtmlGenerator.get_thread_htmlpage(thread, img_path)
347 |
348 | # Создаём файл
349 | file = open(f'thread_{thread.num}.html', 'w')
350 |
351 | # Записывает туда html страницу
352 | file.write(html)
353 |
354 | # Получить второй пост (который сразу после ОП-поста)
355 | post = thread.posts[1]
356 |
357 | print(f"Номер: {post.num}")
358 | print(f"Текст: {post.comment}")
359 | print(f"Количество файлов: {len(post.files)}")
360 |
361 | if len(post.files) > 0:
362 | # Получить первый файл
363 | file = post.files[0]
364 | print(type(file))
365 |
366 | print(f"Имя файла: {file.name}")
367 | print(f"Ширина: {file.width}")
368 | print(f"Высота: {file.height}")
369 | print(f"Отображаемое имя: {file.displayname}")
370 | print(f"Ссылка: {file.download_link}")
371 |
372 | # Сохранить файл
373 | file.save(file.name)
374 | # file.save(f"/home/username/{file.name}")
375 | ```
376 |
--------------------------------------------------------------------------------
/dvach.py:
--------------------------------------------------------------------------------
1 | import json
2 | from constants import domain
3 | from typing import List
4 | import requests
5 | from bs4 import BeautifulSoup
6 | import os
7 | from requests.models import Response
8 | import time
9 |
10 | headers = {
11 | "Accept": "image/webp,*/*",
12 | "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:88.0) Gecko/20100101 Firefox/88.0",
13 | "Accept-Encoding": "gzip, deflate, br",
14 | "Accept-Language": "ru-RU,ru;q=0.8,en-US;q=0.5,en;q=0.3",
15 | "Cookie": "usercode_auth=3c86e2be7602c264ffddd9723be0688b; wakabastyle=Futaba;"
16 | }
17 |
18 |
19 | class Post_file:
20 | displayname: str
21 | name: str
22 | path: str
23 | width: int
24 | height: int
25 | size: int
26 |
27 | def __init__(self, json_data):
28 | self.displayname = json_data['displayname']
29 | self.name = json_data['name']
30 | self.path = json_data['path']
31 | self.width = json_data['width']
32 | self.height = json_data['height']
33 | self.size = json_data['size']
34 |
35 | def save(self, path: str):
36 | """Сохранить файл
37 |
38 | Args:
39 | path (str): Путь к файлу, в которой сохранить файл
40 | """
41 | if os.path.isdir(path):
42 | raise Exception("Вы указали папку, но требуется файл")
43 | r = download_link(self.download_link)
44 | with open(path, 'wb') as output:
45 | output.write(r.content)
46 |
47 | @property
48 | def IsImage(self) -> bool:
49 | """Является ли файл .png или .jpg
50 |
51 | Args:
52 | fileName (str): имя файла
53 | """
54 | ext = self.name.split('.')[1]
55 | return ext == 'png' or ext == 'jpg'
56 |
57 | @property
58 | def IsVideo(self) -> bool:
59 | """Является ли файл видео
60 | """
61 | # Так как файл либо фото, либо видео
62 | return not self.IsImage
63 |
64 | def IsOk(self, EXTENSIONS: List[str], MAX_FILE_SIZE: int, MIN_FILE_SIZE: int):
65 | """Подходит ли файл по заданным расширениям, максимальному и минимальному размеру
66 |
67 | Args:
68 | EXTENSIONS (List[str]): Список разрешенных расширений
69 | MAX_FILE_SIZE (int): Максимальный размер файла
70 | MIN_FILE_SIZE (int): Минимальный размер файла
71 |
72 | Returns:
73 | bool: Подходит ли
74 | """
75 | if self.name.split('.')[1].lower() not in EXTENSIONS:
76 | return False
77 |
78 | if MAX_FILE_SIZE != 0 and self.size > MAX_FILE_SIZE:
79 | return False
80 |
81 | if MIN_FILE_SIZE != 0 and self.size < MIN_FILE_SIZE:
82 | return False
83 |
84 | return True
85 |
86 | @property
87 | def download_link(self):
88 | return f'https://2ch{domain}{self.path}'
89 |
90 |
91 | class Post:
92 | comment: str
93 | comment_html: str # не очищенное от html
94 | date: str
95 | email: str
96 | op: int
97 | num: str
98 | files: List[Post_file]
99 |
100 | def __init__(self, json_post_data):
101 | self.comment = json_post_data['comment']
102 | # отчистить если есть открывающий тег
103 | if '<' in self.comment:
104 | self.comment = BeautifulSoup(json_post_data['comment'], 'lxml').text.strip()
105 | self.comment_html = json_post_data['comment']
106 | self.num = str(json_post_data['num'])
107 | self.date = json_post_data['date']
108 | self.email = json_post_data['email']
109 | self.op = json_post_data['op']
110 | self.files = []
111 | if json_post_data.get('files'):
112 | for json_file_data in json_post_data['files']:
113 | self.files.append(Post_file(json_file_data))
114 |
115 |
116 | class Thread:
117 | """Тред доски."""
118 | comment: str
119 | comment_html: str # не очищенное от html
120 | lasthit: int
121 | num: str
122 | posts_count: int
123 | score: float
124 | subject: str
125 | timestamp: int
126 | views: int
127 | unique_posters: int # определяется по оп посту
128 |
129 | board_name: str # тред должен знать на какой он доске
130 | posts = []
131 | score_history = []
132 |
133 | def __init__(self, board_name: str, json_thread_data=''):
134 | """
135 | Инициализация треда.
136 |
137 | json_thread_data - json от доски (там нет списка постов)
138 | """
139 | if json_thread_data != '':
140 | self.comment = json_thread_data['comment']
141 | self.comment_html = json_thread_data['comment']
142 | self.lasthit = int(json_thread_data['lasthit'])
143 | self.num = json_thread_data['num']
144 | self.posts_count = int(json_thread_data['posts_count'])
145 | self.score = float(json_thread_data['score'])
146 | self.subject = json_thread_data['subject']
147 | self.timestamp = int(json_thread_data['timestamp'])
148 | self.views = int(json_thread_data['views'])
149 | # отчистить от html, если есть открывающий тег
150 | if '<' in self.comment:
151 | self.comment = BeautifulSoup(self.comment, 'lxml').text.strip()
152 |
153 | self.score_history = [self.score]
154 | self.board_name = board_name
155 |
156 | @property
157 | def get_op_post(self):
158 | if len(self.posts) == 0:
159 | raise Exception("Посты не скачаны")
160 | return self.posts[0]
161 |
162 | def get_op_img_path(self) -> str:
163 | """Получить путь к скаченному изображению из ОП-поста. Видео игнорируются
164 |
165 | Returns:
166 | str: путь к изображению или пустая строка
167 | """
168 | if len(self.posts) == 0:
169 | raise Exception("Посты не скачаны")
170 | files = self.posts[0].files
171 | for file in files:
172 | if file.IsImage:
173 | path = os.path.normpath(f'{file.name}')
174 | return path
175 | return ""
176 |
177 | def save(self, folder_path: str) -> str:
178 | """Сохранить тред в папку (html файл)
179 |
180 | Args:
181 | folder_path (str): папка, в которую сохранять
182 |
183 | Returns:
184 | str: путь, куда сохранен файл
185 | """
186 | if len(self.posts) == 0:
187 | raise Exception("Посты не скачаны")
188 | if os.path.isfile(folder_path):
189 | raise Exception("Вы указали файл, но требуется директория")
190 |
191 | img_path = self.get_op_img_path()
192 | html = HtmlGenerator.get_thread_htmlpage(self, img_path)
193 | save_path = os.path.normpath(f'{folder_path}/thread_{self.num}.html')
194 | open(save_path, 'w', encoding='utf-8').write(html)
195 | return save_path
196 |
197 | def update_posts(self):
198 | """Скачать посты и обновить их список"""
199 | json_posts = self.json_download()
200 | self.get_posts(json_posts)
201 |
202 | def get_posts(self, json_posts):
203 | """Перезаписать список постов в треде из json"""
204 | posts_json = json.loads(json_posts)
205 | self.unique_posters = int(posts_json['unique_posters'])
206 | self.posts = []
207 | for post in posts_json['threads'][0]['posts']:
208 | self.posts.append(Post(post))
209 |
210 | def IsOk(self, KEY_WORDS: List[str]):
211 | """Подходит ли тред по ключевым словам
212 |
213 | Если хотя бы одно ключевое слово есть в тексте, тогда подходит.
214 |
215 | Args:
216 | KEY_WORDS (List[str]): Ключевые слова
217 |
218 | Returns:
219 | bool: Подходит ли по ключевым словам
220 | """
221 | if len(KEY_WORDS) != 0:
222 | for word in KEY_WORDS:
223 | if word.lower() in self.comment.lower():
224 | return True # подходит если есть одно из ключевых слов
225 | else:
226 | return True # Подходит если ключевые слова не указаны.
227 | return False
228 |
229 | def json_download(self) -> str:
230 | """Скачать json постов
231 |
232 | Returns:
233 | str: json постов
234 | """
235 | return download_link(self.json_posts_link).text
236 |
237 | @property
238 | def json_posts_link(self) -> str:
239 | # return
240 | # f"https://2ch{domain}/makaba/mobile.fcgi?task=get_thread&board={self.board_name}&thread={self.num}&post=1"
241 | return f"https://2ch{domain}/{self.board_name}/res/{self.num}.json"
242 |
243 | @property
244 | def get_link(self) -> str:
245 | """Ссылка на тред"""
246 | return f'https://2ch{domain}/{self.board_name}/res/{self.num}.html'
247 |
248 |
249 | class BoardRefreshInfo:
250 | """Информация об изменениях на доске."""
251 |
252 | deadThreads = List[Thread]
253 | newThreads = List[Thread]
254 |
255 | def __init__(self, deadThreads, newThreads):
256 | """Инициализация"""
257 | self.deadThreads = deadThreads
258 | self.newThreads = newThreads
259 |
260 |
261 | class Board:
262 | """Доска."""
263 |
264 | name: str
265 | threads: dict
266 |
267 | def __init__(self, name: str, threads=dict()):
268 | """Инициализировать доску. Пример имени: `b`."""
269 | self.name = name
270 | self.threads = threads
271 |
272 | @staticmethod
273 | def from_json(json_text: str):
274 | """Спарсить json и вернуть доску
275 |
276 | Args:
277 | json_text (str): json с списом тредов и именем доски
278 |
279 | Returns:
280 | Board: Возвращает доску сформированную из json
281 | """
282 | json_data = json.loads(json_text)
283 | threads_json = json_data['threads']
284 | name_json = json_data['board']
285 |
286 | downloaded_threads = dict()
287 | for thread_json in threads_json:
288 | thread = Thread(name_json, thread_json)
289 | downloaded_threads[thread.num] = thread
290 |
291 | return Board(name_json, downloaded_threads)
292 |
293 | def sort_threads_by_posts(self):
294 | """ Сортировка тредов по количеству постов"""
295 | for i in range(len(self.threads.keys())):
296 | for j in range(i, len(self.threads.keys())):
297 | key_i = list(self.threads.keys())[i]
298 | key_j = list(self.threads.keys())[j]
299 | if self.threads[key_i].posts_count < self.threads[key_j].posts_count:
300 | self.threads[key_i], self.threads[key_j] = self.threads[key_j], self.threads[key_i]
301 |
302 | def update_threads(self):
303 | """ Скачать треды"""
304 | self.threads = Board.from_json(Board.json_download(self.name)).threads
305 |
306 | def get_dead_threads(self, comparewith):
307 | """`comparewith` это новый скачанный список тредов с которым сравнивать текущие треды"""
308 | dead = dict()
309 | for t in self.threads.keys():
310 | if t not in comparewith.keys():
311 | dead[str(t)] = self.threads[t]
312 | return dead
313 |
314 | def get_new_threads(self, comparewith):
315 | """`comparewith` это новый скачанный список тредов с которым сравнивать текущие треды"""
316 | new_threads = dict()
317 | for new in comparewith.keys():
318 | if not (new in self.threads.keys()):
319 | new_threads[str(new)] = comparewith[new]
320 | return new_threads
321 |
322 | @staticmethod
323 | def json_download(board_name: str) -> str:
324 | """Скачать json"""
325 | link = Board(board_name).json_link
326 | json_downloaded = download_link(link).text
327 | return json_downloaded
328 |
329 | def thread_exists(self, num: str) -> bool:
330 | """Существует ли тред"""
331 | for thread in self.threads:
332 | if thread.num == num:
333 | return True
334 | return False
335 |
336 | @property
337 | def json_link(self) -> str:
338 | """Ссылка на список тредов json"""
339 | return f'https://2ch{domain}/{self.name}/threads.json'
340 |
341 |
342 | class HtmlGenerator:
343 | """Создаёт html-страницу"""
344 |
345 | @staticmethod
346 | def _read_block(name: str) -> str:
347 | return open(os.path.normpath(f'page_gen/blocks/{name}'), encoding='utf-8').read()
348 |
349 | @staticmethod
350 | def _replace_str_in_html(html: str, key: str, value: str):
351 | """Заменить значения в фигурных скобах на нужные
352 |
353 | Args:
354 | html (str): сам код
355 | key (str): что заменить, например "{num}"
356 | value (str): на что заменить
357 |
358 | Returns:
359 | str: возвращает исправленный html
360 | """
361 | return html.replace(key, value)
362 |
363 | @staticmethod
364 | def get_htmlhead(thread: Thread) -> str:
365 | return f"""
366 |
367 |
368 |
369 |
372 | {thread.get_op_post.comment}
373 |
374 | """
375 |
376 | @staticmethod
377 | def get_htmldashboard() -> str:
378 | return HtmlGenerator._read_block('dashboard.html')
379 |
380 | @staticmethod
381 | def get_js_script() -> str:
382 | return HtmlGenerator._read_block('script.js')
383 |
384 | @staticmethod
385 | def get_post_images(post: Post) -> str:
386 | #
387 | images = [] # Изображения поста (видео игнорируются)
388 | for file in post.files:
389 | if file.IsImage:
390 | images.append(file)
391 |
392 | result_html = ""
393 | for i in images:
394 | img_path = i.name
395 | image_html = f"\n"
396 | result_html += f"{image_html}\n"
397 | return result_html
398 |
399 | @staticmethod
400 | def get_post_htmlpage(post: Post, order: int) -> str:
401 | """ html для одного поста"""
402 | htmlcode = HtmlGenerator._read_block('post.html')
403 | htmlcode = HtmlGenerator._replace_str_in_html(htmlcode, '{date}', post.date)
404 | htmlcode = HtmlGenerator._replace_str_in_html(htmlcode, '{num}', str(post.num))
405 | htmlcode = HtmlGenerator._replace_str_in_html(htmlcode, '{order}', str(order))
406 | htmlcode = HtmlGenerator._replace_str_in_html(htmlcode, '{msg}', post.comment_html)
407 | htmlcode = HtmlGenerator._replace_str_in_html(htmlcode, '{answers}', "")
408 | htmlcode = HtmlGenerator._replace_str_in_html(htmlcode, '{images}', HtmlGenerator.get_post_images(post))
409 | return htmlcode
410 |
411 | @staticmethod
412 | def get_posts_htmlpage(thread: Thread) -> str:
413 | posts: List[Post] = thread.posts[1:] # Без оп-поста
414 | htmlcode = ""
415 | for i in range(0, len(posts)):
416 | htmlcode += HtmlGenerator.get_post_htmlpage(posts[i], i + 2)
417 | return htmlcode
418 |
419 | @staticmethod
420 | def get_op_post_htmlpage(thread: Thread, img_src: str) -> str:
421 | htmlcode = HtmlGenerator._read_block('op_post.html')
422 | htmlcode = HtmlGenerator._replace_str_in_html(htmlcode, '{date}', thread.get_op_post.date)
423 | htmlcode = HtmlGenerator._replace_str_in_html(htmlcode, '{num}', str(thread.num))
424 | htmlcode = HtmlGenerator._replace_str_in_html(htmlcode, '{img_src}', img_src)
425 | htmlcode = HtmlGenerator._replace_str_in_html(htmlcode, '{msg}', thread.comment_html)
426 | return htmlcode
427 |
428 | @staticmethod
429 | def get_thread_htmlpage(thread: Thread, img_src: str) -> str:
430 | """ Создать html страницу для треда"""
431 | code = f"""
432 |
433 |
434 | {HtmlGenerator.get_htmlhead(thread)}
435 |
436 | {HtmlGenerator.get_htmldashboard()}
437 |
468 |
469 |
470 | >>4280795 Если на дороге долбаёб не соблюдающий скоростные
472 | режимы, в любом случае кто-то пострадает, разницы нет мотоцикл или авто.
473 |
474 |
475 |
476 |
477 |
478 |
538 |
539 |
540 | >>4280802 Чудесец, сколько стоит хиккемашина? Вижу, ты её
542 | уже себе приобрёл, постоянно в этой хиккемашине сидишь и не вылазишь. Как там тебе живётся, в
543 | хиккемашине этой, чудесец? Не тесно там?
Если есть хиккемашина, то нужен и хиккегараж, а
544 | то не гоже хикке рядом с обычными машинами парковаться. Он у тебя тоже есть?
545 |
546 |
547 |
680 |
681 |
682 | >>4280808 В моем случае бесплатно досталась мне. Ложусь,
684 | закрываю глаза и представляю как я катаюсь на хиккемашине. Нет, не тесно, она когда
685 | трансформируется, то становится достаточно большой и просторной, чудесец. Хиккегаража нет, у
686 | моей хиккемашины есть карманный режим, она становится такой маленькой, что её можно поставить на
687 | полочку и она будет там стоять никому не мешать, чудесец.
688 |
689 |
690 |
755 |
756 |
757 | >>4280812 По наследству Сави, чудесец? Чудесец новые
759 | разработки получил. Она же меньше пальца будет, чудесец?
760 |
761 |
762 |
899 |
900 |
901 | >>4280824 А если в машине случайно оставить руку, чудесец,
903 | она тоже станет маленькой?, что ещё там есть, чудесец, скажи скажи скажи! Чуднесец!
904 |
905 |
906 |
971 |
972 |
973 | >>4280827 Нет, тогда хиккемашина выплюнет руку, чудесец. Там
975 | есть много чего, когда хиккемашина в режиме убежища, там есть ванная комната, кухня, мощный пк с
976 | играми и аниме, уютный диван с пледом и дакимакурой, бесконечный запас всяких вкусностей и
977 | различных напитков, ещё много чего, хиккемашина в режиме убежища это целый дворец, чудесец.
978 |
979 |
980 |
1045 |
1046 |
1047 | >>4280834 Главный вопрос, там есть Сави, чудесец. Мне он
1049 | очень нужен сейчас Сави, я не знаю что это, он мне нужен, чудесец. Чудесец! Чудесец-чудесец!
1050 |
1051 |
1052 |
1189 |
1190 |
1191 | >>4280840 Хорошо, чудесец, хорошо, чудесец. Хорошо! Хорошо,
1193 | чудесец! Лучше быть и не может и сейчас, мне очень нужен Сави, чудесец. Тебе тоже нужен Сави,
1194 | чудесец.
1195 |
1196 |
1197 |
1332 |
1333 |
1334 | >>4280792 (OP) Ну и на какой именно упал твой
1336 | взгляд? Полагаю, если с приставкой "хикки" - то какой-то совковый малыш который будет
1337 | постоянно на выебонах и хуй, блять, когда нормально поедет.
Возьми себе лучше ебучего
1338 | китайца, на японца а уж тем более америкоса бабла хуй хватит и запчастей не так много будет.
1339 |
1340 |
1341 |
1342 |
1343 |
1344 |
1767 |
1768 |
1769 | >>4280866 Поместятся, чудесец, конечно поместятся! Вот купим
1771 | хиккемашину, тогда и увидишь
Будем зелёный чай с лимоном пить, чудесец, роллтон
1772 | заваривать и живот больше никогда болеть не будет. Втуберов всяких смотреть.. Для счастья и не
1773 | нужно более. Верно, чудесец?
1774 |
1775 |
1776 |