├── constants.py ├── .gitignore ├── requirements.txt ├── test_files ├── images │ ├── img1_1.jpg │ ├── img1_2.jpg │ ├── img2_1.png │ ├── img2_2.png │ ├── img3_1.jpg │ └── img3_2.jpg ├── threads_json_removed2.json ├── threads_json_removed.json ├── threads_json.json ├── threads_json_withnew.json ├── threads_json_withnew2.json ├── posts_json.json └── thread_html ├── page_gen ├── blocks │ ├── dashboard.html │ ├── op_post.html │ ├── post.html │ └── script.js ├── README.md ├── index.html └── style.css ├── .github └── workflows │ └── python-test.yml ├── LICENSE ├── popular.py ├── tracker.py ├── filecompare.py ├── thread_saver.py ├── test_dvach.py ├── board_media.py ├── README.md └── dvach.py /constants.py: -------------------------------------------------------------------------------- 1 | domain = ".su" 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | .vscode/ 3 | media 4 | saver -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | lxml 3 | beautifulsoup4 4 | opencv-python -------------------------------------------------------------------------------- /test_files/images/img1_1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diademoff/2ch/HEAD/test_files/images/img1_1.jpg -------------------------------------------------------------------------------- /test_files/images/img1_2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diademoff/2ch/HEAD/test_files/images/img1_2.jpg -------------------------------------------------------------------------------- /test_files/images/img2_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diademoff/2ch/HEAD/test_files/images/img2_1.png -------------------------------------------------------------------------------- /test_files/images/img2_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diademoff/2ch/HEAD/test_files/images/img2_2.png -------------------------------------------------------------------------------- /test_files/images/img3_1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diademoff/2ch/HEAD/test_files/images/img3_1.jpg -------------------------------------------------------------------------------- /test_files/images/img3_2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diademoff/2ch/HEAD/test_files/images/img3_2.jpg -------------------------------------------------------------------------------- /page_gen/blocks/dashboard.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
-------------------------------------------------------------------------------- /.github/workflows/python-test.yml: -------------------------------------------------------------------------------- 1 | name: Run unit tests 2 | on: [push] 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@master 8 | - uses: actions/setup-python@v2 9 | with: 10 | python-version: '3.8' 11 | - name: Run unit tests 12 | uses: onichandame/python-test-action@master 13 | with: 14 | deps_list: 'requirements.txt' -------------------------------------------------------------------------------- /page_gen/blocks/op_post.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | Аноним 4 |
5 | {date} 6 |
7 |
8 | №{num} 9 |
10 |
11 |
12 | 13 |
14 |
15 | {msg} 16 |
17 |
-------------------------------------------------------------------------------- /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 |
22 | {answers} 23 |
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 | ![GitHub repo size](https://img.shields.io/github/repo-size/diademoff/2ch) 3 | ![CodeFactor Grade](https://img.shields.io/codefactor/grade/github/diademoff/2ch) 4 | ![GitHub](https://img.shields.io/github/license/diademoff/2ch) 5 | ![GitHub Repo stars](https://img.shields.io/github/stars/diademoff/2ch?style=social) 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 |
438 | {HtmlGenerator.get_op_post_htmlpage(thread, img_src)} 439 | {HtmlGenerator.get_posts_htmlpage(thread)} 440 |
441 | 444 | 445 | 446 | """ 447 | return code 448 | 449 | 450 | def download_link(link: str, retries=3) -> Response: 451 | """Скачать json 452 | 453 | Args: 454 | link (str): ссылка на скачивание 455 | """ 456 | for _ in range(retries): 457 | try: 458 | return requests.get(link, timeout=15, stream=True) 459 | except requests.exceptions.SSLError as e: 460 | time.sleep(1) 461 | raise 462 | 463 | -------------------------------------------------------------------------------- /test_files/thread_html: -------------------------------------------------------------------------------- 1 |
2 | 52 | 53 |
54 | 55 | 56 |
57 | 58 | 59 | 60 |
61 |
62 | 63 |
64 | 65 | 66 | 67 | 68 | Хочу хиккимотоцикл, какие подводные? 69 | 70 | 71 | 72 | Обреченный 73 | 74 | 75 | 76 | 77 | 02/05/21 Вск 15:26:44 78 | 79 | 42807921 83 | 86 | 87 | 88 | 90 | 91 | 93 | 94 | 97 | 98 | 99 | 100 | 101 | 102 |
103 |
104 | 105 |
106 |
107 |
108 | oxxy! .jpg 110 | 111 | 112 | 113 | 114 | 81Кб, 521x954 115 |
116 | 118 | 521x954 123 | 124 | 125 |
126 |
127 | 128 |
129 | Хочу хиккимотоцикл, какие подводные? 130 | 131 |
132 |
>>4280795 >>4280800 >>4280846 >>4280852
141 | 142 | 143 |
144 |
145 | 146 | 147 | 148 |
149 |
150 | 151 |
152 | 153 | 154 | 155 | 156 | Обреченный 157 | 158 | 159 | 160 | 161 | 02/05/21 Вск 15:28:58 162 | 163 | 42807952 167 | 169 | 170 | 173 | 174 | 175 | 176 | 177 | 178 |
179 |
180 | 181 |
182 |
183 |
184 | 27.jpg 186 | 187 | 188 | 189 | 190 | 107Кб, 1000x1495 191 |
192 | 194 | 1000x1495 199 | 200 | 201 |
202 |
203 | 204 |
205 | >>4280792 (OP)
>какие 207 | подводные?
Кому-то придётся отскребать твои хиккимозги от асфальта и собирать твои 208 | хиккиконечности по обочинам. 209 | 210 |
211 |
>>4280805
214 | 215 | 216 |
217 |
218 | 219 | 220 | 221 |
222 |
223 | 224 |
225 | 226 | 227 | 228 | 229 | Обреченный 230 | 231 | 232 | 233 | 234 | 02/05/21 Вск 15:32:16 235 | 236 | 42808003 240 | 242 | 243 | 246 | 247 | 248 | 249 | 250 | 251 |
252 |
253 | 254 |
255 |
256 |
257 | nazrin.jpg 259 | 260 | 261 | 262 | 263 | 356Кб, 850x1250 264 |
265 | 267 | 850x1250 272 | 273 | 274 |
275 |
276 | 277 |
278 | >>4280792 (OP)
Чем хиккимотоцикл отличается от обычного 280 | мотоцикла, чудесец?
А хиккемашина от машины чем отличается? М?

281 | 282 |
283 |
>>4280802 >>4280803
288 | 289 | 290 |
291 |
292 | 293 | 294 | 295 |
296 |
297 | 298 |
299 | 300 | 301 | 302 | 303 | Обреченный 304 | 305 | 306 | 307 | 308 | 02/05/21 Вск 15:33:31 309 | 310 | 42808024 314 | 316 | 317 | 320 | 321 | 322 | 323 | 324 | 325 |
326 |
327 | 328 |
329 |
330 |
331 | 1530108844398.jpg 333 | 334 | 335 | 336 | 337 | 32Кб, 640x479 338 |
339 | 341 | 640x479 346 | 347 | 348 |
349 |
350 | 351 |
352 | >>4280800
Хиккемашина это как трансформер, может быть 354 | машиной, а может быть убежищем для хикки. 355 | 356 |
357 |
>>4280808
360 | 361 | 362 |
363 |
364 | 365 | 366 | 367 |
368 |
369 | 370 |
371 | 372 | 373 | 374 | 375 | Обреченный 376 | 377 | 378 | 379 | 380 | 02/05/21 Вск 15:34:15 381 | 382 | 42808035 386 | 388 | 389 | 392 | 393 | 394 | 395 | 396 | 397 |
398 |
399 | 400 |
401 |
402 |
403 | Чун Юнь 62.jpg 405 | 406 | 407 | 408 | 409 | 83Кб, 966x1252 410 |
411 | 413 | 966x1252 418 | 419 | 420 |
421 |
422 | 423 |
424 | >>4280800
Хикки мотоцикл не захочет выезжать из гаража. 426 | 427 |
428 | 429 | 430 | 431 |
432 |
433 | 434 | 435 | 436 |
437 |
438 | 439 |
440 | 441 | 442 | 443 | 444 | Обреченный 445 | 446 | 447 | 448 | 449 | 02/05/21 Вск 15:35:21 450 | 451 | 42808056 455 | 457 | 458 | 461 | 462 | 463 | 464 | 465 | 466 |
467 |
468 | 469 |
470 | >>4280795
Если на дороге долбаёб не соблюдающий скоростные 472 | режимы, в любом случае кто-то пострадает, разницы нет мотоцикл или авто. 473 | 474 |
475 | 476 | 477 | 478 |
479 |
480 | 481 | 482 | 483 |
484 |
485 | 486 |
487 | 488 | 489 | 490 | 491 | Обреченный 492 | 493 | 494 | 495 | 496 | 02/05/21 Вск 15:39:01 497 | 498 | 42808087 502 | 504 | 505 | 508 | 509 | 510 | 511 | 512 | 513 |
514 |
515 | 516 |
517 |
518 |
519 | nazrin.jpg 521 | 522 | 523 | 524 | 525 | 356Кб, 850x1250 526 |
527 | 529 | 850x1250 534 | 535 | 536 |
537 |
538 | 539 |
540 | >>4280802
Чудесец, сколько стоит хиккемашина? Вижу, ты её 542 | уже себе приобрёл, постоянно в этой хиккемашине сидишь и не вылазишь. Как там тебе живётся, в 543 | хиккемашине этой, чудесец? Не тесно там?

Если есть хиккемашина, то нужен и хиккегараж, а 544 | то не гоже хикке рядом с обычными машинами парковаться. Он у тебя тоже есть? 545 | 546 |
547 |
>>4280812
550 | 551 | 552 |
553 |
554 | 555 | 556 | 557 |
558 |
559 | 560 |
561 | 562 | 563 | 564 | 565 | Обреченный 566 | 567 | 568 | 569 | 570 | 02/05/21 Вск 15:39:05 571 | 572 | 42808098 576 | 578 | 579 | 582 | 583 | 584 | 585 | 586 | 587 |
588 |
589 | 590 |
591 |
592 |
593 | Суисей330.png 595 | 596 | 597 | 598 | 599 | 402Кб, 537x621 600 |
601 | 603 | 537x621 608 | 609 | 610 |
611 |
612 | 613 |
614 | В хиккебаре отожмут, чудесец. 615 | 616 |
617 | 618 | 619 | 620 |
621 |
622 | 623 | 624 | 625 |
626 |
627 | 628 |
629 | 630 | 631 | 632 | 633 | Обреченный 634 | 635 | 636 | 637 | 638 | 02/05/21 Вск 15:42:46 639 | 640 | 42808129 644 | 646 | 647 | 650 | 651 | 652 | 653 | 654 | 655 |
656 |
657 | 658 |
659 |
660 |
661 | 1579579061403.jpg 663 | 664 | 665 | 666 | 667 | 124Кб, 640x479 668 |
669 | 671 | 640x479 676 | 677 | 678 |
679 |
680 | 681 |
682 | >>4280808
В моем случае бесплатно досталась мне. Ложусь, 684 | закрываю глаза и представляю как я катаюсь на хиккемашине. Нет, не тесно, она когда 685 | трансформируется, то становится достаточно большой и просторной, чудесец. Хиккегаража нет, у 686 | моей хиккемашины есть карманный режим, она становится такой маленькой, что её можно поставить на 687 | полочку и она будет там стоять никому не мешать, чудесец. 688 | 689 |
690 |
>>4280818
693 | 694 | 695 |
696 |
697 | 698 | 699 | 700 |
701 |
702 | 703 |
704 | 705 | 706 | 707 | 708 | Обреченный 709 | 710 | 711 | 712 | 713 | 02/05/21 Вск 15:47:17 714 | 715 | 428081810 719 | 721 | 722 | 725 | 726 | 727 | 728 | 729 | 730 |
731 |
732 | 733 |
734 |
735 |
736 | Суисей329.png 738 | 739 | 740 | 741 | 742 | 400Кб, 518x627 743 |
744 | 746 | 518x627 751 | 752 | 753 |
754 |
755 | 756 |
757 | >>4280812
По наследству Сави, чудесец? Чудесец новые 759 | разработки получил. Она же меньше пальца будет, чудесец? 760 | 761 |
762 |
>>4280824
765 | 766 | 767 |
768 |
769 | 770 | 771 | 772 |
773 |
774 | 775 |
776 | 777 | 778 | 779 | 780 | Обреченный 781 | 782 | 783 | 784 | 785 | 02/05/21 Вск 15:52:02 786 | 787 | 428082411 791 | 793 | 794 | 797 | 798 | 799 | 800 | 801 | 802 |
803 |
804 | 805 |
806 |
807 |
808 | 1546705447915.jpg 810 | 811 | 812 | 813 | 814 | 32Кб, 640x479 815 |
816 | 818 | 640x479 823 | 824 | 825 |
826 |
827 | 828 |
829 | >>4280818
Да, по наследству Сави, чудесец. Да, такая вот она 831 | маленькая миниатюрная хиккемашина, чудесец. 832 | 833 |
834 |
>>4280827
837 | 838 | 839 |
840 |
841 | 842 | 843 | 844 |
845 |
846 | 847 |
848 | 849 | 850 | 851 | 852 | Обреченный 853 | 854 | 855 | 856 | 857 | 02/05/21 Вск 15:54:09 858 | 859 | 428082712 863 | 865 | 866 | 869 | 870 | 871 | 872 | 873 | 874 |
875 |
876 | 877 |
878 |
879 |
880 | Суисей328.png 882 | 883 | 884 | 885 | 886 | 473Кб, 493x657 887 |
888 | 890 | 493x657 895 | 896 | 897 |
898 |
899 | 900 |
901 | >>4280824
А если в машине случайно оставить руку, чудесец, 903 | она тоже станет маленькой?, что ещё там есть, чудесец, скажи скажи скажи! Чуднесец! 904 | 905 |
906 |
>>4280834
909 | 910 | 911 |
912 |
913 | 914 | 915 | 916 |
917 |
918 | 919 |
920 | 921 | 922 | 923 | 924 | Обреченный 925 | 926 | 927 | 928 | 929 | 02/05/21 Вск 15:59:51 930 | 931 | 428083413 935 | 937 | 938 | 941 | 942 | 943 | 944 | 945 | 946 |
947 |
948 | 949 |
950 |
951 |
952 | 1481336468072.jpg 954 | 955 | 956 | 957 | 958 | 124Кб, 640x479 959 |
960 | 962 | 640x479 967 | 968 | 969 |
970 |
971 | 972 |
973 | >>4280827
Нет, тогда хиккемашина выплюнет руку, чудесец. Там 975 | есть много чего, когда хиккемашина в режиме убежища, там есть ванная комната, кухня, мощный пк с 976 | играми и аниме, уютный диван с пледом и дакимакурой, бесконечный запас всяких вкусностей и 977 | различных напитков, ещё много чего, хиккемашина в режиме убежища это целый дворец, чудесец. 978 | 979 |
980 |
>>4280835
983 | 984 | 985 |
986 |
987 | 988 | 989 | 990 |
991 |
992 | 993 |
994 | 995 | 996 | 997 | 998 | Обреченный 999 | 1000 | 1001 | 1002 | 1003 | 02/05/21 Вск 16:02:43 1004 | 1005 | 428083514 1009 | 1011 | 1012 | 1015 | 1016 | 1017 | 1018 | 1019 | 1020 |
1021 |
1022 | 1023 |
1024 |
1025 |
1026 | Суисей330.png 1028 | 1029 | 1030 | 1031 | 1032 | 402Кб, 537x621 1033 |
1034 | 1036 | 537x621 1041 | 1042 | 1043 |
1044 |
1045 | 1046 |
1047 | >>4280834
Главный вопрос, там есть Сави, чудесец. Мне он 1049 | очень нужен сейчас Сави, я не знаю что это, он мне нужен, чудесец. Чудесец! Чудесец-чудесец! 1050 | 1051 |
1052 |
>>4280840
1055 | 1056 | 1057 |
1058 |
1059 | 1060 | 1061 | 1062 |
1063 |
1064 | 1065 |
1066 | 1067 | 1068 | 1069 | 1070 | Обреченный 1071 | 1072 | 1073 | 1074 | 1075 | 02/05/21 Вск 16:05:03 1076 | 1077 | 428084015 1081 | 1083 | 1084 | 1087 | 1088 | 1089 | 1090 | 1091 | 1092 |
1093 |
1094 | 1095 |
1096 |
1097 |
1098 | 1618230083186.jpg 1100 | 1101 | 1102 | 1103 | 1104 | 32Кб, 640x479 1105 |
1106 | 1108 | 640x479 1113 | 1114 | 1115 |
1116 |
1117 | 1118 |
1119 | >>4280835
Конечно там есть Сави, чудесец! Там есть Сави, да, 1121 | чудесец. 1122 | 1123 |
1124 |
>>4280841
1127 | 1128 | 1129 |
1130 |
1131 | 1132 | 1133 | 1134 |
1135 |
1136 | 1137 |
1138 | 1139 | 1140 | 1141 | 1142 | Обреченный 1143 | 1144 | 1145 | 1146 | 1147 | 02/05/21 Вск 16:06:18 1148 | 1149 | 428084116 1153 | 1155 | 1156 | 1159 | 1160 | 1161 | 1162 | 1163 | 1164 |
1165 |
1166 | 1167 |
1168 |
1169 |
1170 | Суисей327.png 1172 | 1173 | 1174 | 1175 | 1176 | 354Кб, 397x576 1177 |
1178 | 1180 | 397x576 1185 | 1186 | 1187 |
1188 |
1189 | 1190 |
1191 | >>4280840
Хорошо, чудесец, хорошо, чудесец. Хорошо! Хорошо, 1193 | чудесец! Лучше быть и не может и сейчас, мне очень нужен Сави, чудесец. Тебе тоже нужен Сави, 1194 | чудесец. 1195 | 1196 |
1197 |
>>4280857
1200 | 1201 | 1202 |
1203 |
1204 | 1205 | 1206 | 1207 |
1208 |
1209 | 1210 |
1211 | 1212 | 1213 | 1214 | 1215 | Обреченный 1216 | 1217 | 1218 | 1219 | 1220 | 02/05/21 Вск 16:11:32 1221 | 1222 | 428084617 1226 | 1228 | 1229 | 1232 | 1233 | 1234 | 1235 | 1236 | 1237 |
1238 |
1239 | 1240 |
1241 |
1242 |
1243 | upload202104210[...].png 1245 | 1246 | 1247 | 1248 | 1249 | 363Кб, 906x794 1250 |
1251 | 1253 | 906x794 1258 | 1259 | 1260 |
1261 |
1262 | 1263 |
1264 | >>4280792 (OP)
Нормальный такой хиккимотоцыкл сильно так 1266 | ударит по твоему хиккикарману, полагаю. 1267 | 1268 |
1269 | 1270 | 1271 | 1272 |
1273 |
1274 | 1275 | 1276 | 1277 |
1278 |
1279 | 1280 |
1281 | 1282 | 1283 | 1284 | 1285 | Обреченный 1286 | 1287 | 1288 | 1289 | 1290 | 02/05/21 Вск 16:16:03 1291 | 1292 | 428085218 1296 | 1298 | 1299 | 1302 | 1303 | 1304 | 1305 | 1306 | 1307 |
1308 |
1309 | 1310 |
1311 |
1312 |
1313 | 1619961358085.jpg 1315 | 1316 | 1317 | 1318 | 1319 | 339Кб, 1067x1600 1320 |
1321 | 1323 | 1067x1600 1328 | 1329 | 1330 |
1331 |
1332 | 1333 |
1334 | >>4280792 (OP)
Ну и на какой именно упал твой 1336 | взгляд?
Полагаю, если с приставкой "хикки" - то какой-то совковый малыш который будет 1337 | постоянно на выебонах и хуй, блять, когда нормально поедет.

Возьми себе лучше ебучего 1338 | китайца, на японца а уж тем более америкоса бабла хуй хватит и запчастей не так много будет. 1339 | 1340 |
1341 | 1342 | 1343 | 1344 |
1345 |
1346 | 1347 | 1348 | 1349 |
1350 |
1351 | 1352 |
1353 | 1354 | 1355 | 1356 | 1357 | Обреченный 1358 | 1359 | 1360 | 1361 | 1362 | 02/05/21 Вск 16:20:38 1363 | 1364 | 428085719 1368 | 1370 | 1371 | 1374 | 1375 | 1376 | 1377 | 1378 | 1379 |
1380 |
1381 | 1382 |
1383 |
1384 |
1385 | nazrin.jpg 1387 | 1388 | 1389 | 1390 | 1391 | 271Кб, 850x1157 1392 |
1393 | 1395 | 850x1157 1400 | 1401 | 1402 |
1403 |
1404 | 1405 |
1406 | >>4280841
Чудесец-чудесец, дай потрогать ушки твои, чудесец! 1408 | Мягкие ушки, чудесецно-кошачьи ушки! 1409 | 1410 |
1411 |
>>4280859
1414 | 1415 | 1416 |
1417 |
1418 | 1419 | 1420 | 1421 |
1422 |
1423 | 1424 |
1425 | 1426 | 1427 | 1428 | 1429 | Обреченный 1430 | 1431 | 1432 | 1433 | 1434 | 02/05/21 Вск 16:22:47 1435 | 1436 | 428085920 1440 | 1442 | 1443 | 1446 | 1447 | 1448 | 1449 | 1450 | 1451 |
1452 |
1453 | 1454 |
1455 |
1456 |
1457 | Суисей328.png 1459 | 1460 | 1461 | 1462 | 1463 | 473Кб, 493x657 1464 |
1465 | 1467 | 493x657 1472 | 1473 | 1474 |
1475 |
1476 | 1477 |
1478 | >>4280857
Чудесец, хорошо. Хорошо! Сави! Сави-сави! Дай 1480 | свои, чудесец. https://youtu.be/E-Xxo6utgNA[РАСКРЫТЬ] 1483 | 1484 |
1485 |
>>4280864
1488 | 1489 | 1490 |
1491 |
1492 | 1493 | 1494 | 1495 |
1496 |
1497 | 1498 |
1499 | 1500 | 1501 | 1502 | 1503 | Обреченный 1504 | 1505 | 1506 | 1507 | 1508 | 02/05/21 Вск 16:29:04 1509 | 1510 | 428086421 1514 | 1516 | 1517 | 1520 | 1521 | 1522 | 1523 | 1524 | 1525 |
1526 |
1527 | 1528 |
1529 |
1530 |
1531 | nazrin.jpg 1533 | 1534 | 1535 | 1536 | 1537 | 173Кб, 850x1006 1538 |
1539 | 1541 | 850x1006 1546 | 1547 | 1548 |
1549 |
1550 | 1551 |
1552 | >>4280859
Держи, чудесец..
Осторожно только трогай ушки 1554 | мои, чудесец!
И недолго! Стесняюсь же, чудесец 1555 | 1556 |
1557 |
>>4280866
1560 | 1561 | 1562 |
1563 |
1564 | 1565 | 1566 | 1567 |
1568 |
1569 | 1570 |
1571 | 1572 | 1573 | 1574 | 1575 | Обреченный 1576 | 1577 | 1578 | 1579 | 1580 | 02/05/21 Вск 16:33:13 1581 | 1582 | 428086622 1586 | 1588 | 1589 | 1592 | 1593 | 1594 | 1595 | 1596 | 1597 |
1598 |
1599 | 1600 |
1601 |
1602 |
1603 | Суисей330.png 1605 | 1606 | 1607 | 1608 | 1609 | 402Кб, 537x621 1610 |
1611 | 1613 | 537x621 1618 | 1619 | 1620 |
1621 |
1622 | 1623 |
1624 | >>4280864
Сааави, Сави, чудесец. Хорошо! Хорошо! Чудесные 1626 | уши, чудесец. Хорошо! В хиккемашине поместятся такие уши? 1627 | 1628 |
1629 |
>>4280870 >>4280871
1634 | 1635 | 1636 |
1637 |
1638 | 1639 | 1640 | 1641 |
1642 |
1643 | 1644 |
1645 | 1646 | 1647 | 1648 | 1649 | Обреченный 1650 | 1651 | 1652 | 1653 | 1654 | 02/05/21 Вск 16:35:13 1655 | 1656 | 428087023 1660 | 1662 | 1663 | 1666 | 1667 | 1668 | 1669 | 1670 | 1671 |
1672 |
1673 | 1674 |
1675 |
1676 |
1677 | 1559271949475.jpg 1679 | 1680 | 1681 | 1682 | 1683 | 32Кб, 640x479 1684 |
1685 | 1687 | 640x479 1692 | 1693 | 1694 |
1695 |
1696 | 1697 |
1698 | >>4280866
Там и ещё более большие ушки поместятся, чудесец. 1700 | 1701 |
1702 |
>>4280874
1705 | 1706 | 1707 |
1708 |
1709 | 1710 | 1711 | 1712 |
1713 |
1714 | 1715 |
1716 | 1717 | 1718 | 1719 | 1720 | Обреченный 1721 | 1722 | 1723 | 1724 | 1725 | 02/05/21 Вск 16:39:35 1726 | 1727 | 428087124 1731 | 1733 | 1734 | 1737 | 1738 | 1739 | 1740 | 1741 | 1742 |
1743 |
1744 | 1745 |
1746 |
1747 |
1748 | nazrin.png 1750 | 1751 | 1752 | 1753 | 1754 | 1498Кб, 1000x1387 1755 |
1756 | 1758 | 1000x1387 1763 | 1764 | 1765 |
1766 |
1767 | 1768 |
1769 | >>4280866
Поместятся, чудесец, конечно поместятся! Вот купим 1771 | хиккемашину, тогда и увидишь

Будем зелёный чай с лимоном пить, чудесец, роллтон 1772 | заваривать и живот больше никогда болеть не будет. Втуберов всяких смотреть.. Для счастья и не 1773 | нужно более. Верно, чудесец? 1774 | 1775 |
1776 |
>>4280879
1779 | 1780 | 1781 |
1782 |
1783 | 1784 | 1785 | 1786 |
1787 |
1788 | 1789 |
1790 | 1791 | 1792 | 1793 | 1794 | Обреченный 1795 | 1796 | 1797 | 1798 | 1799 | 02/05/21 Вск 16:40:36 1800 | 1801 | 428087425 1805 | 1807 | 1808 | 1811 | 1812 | 1813 | 1814 | 1815 | 1816 |
1817 |
1818 | 1819 |
1820 |
1821 |
1822 | Суисей328.png 1824 | 1825 | 1826 | 1827 | 1828 | 473Кб, 493x657 1829 |
1830 | 1832 | 493x657 1837 | 1838 | 1839 |
1840 |
1841 | 1842 |
1843 | >>4280870
Правда-правда, чудесец Сави, Правда? правда? 1845 | 1846 |
1847 |
>>4280890
1850 | 1851 | 1852 |
1853 |
1854 | 1855 | 1856 | 1857 |
1858 |
1859 | 1860 |
1861 | 1862 | 1863 | 1864 | 1865 | Обреченный 1866 | 1867 | 1868 | 1869 | 1870 | 02/05/21 Вск 16:41:43 1871 | 1872 | 428087926 1876 | 1878 | 1879 | 1882 | 1883 | 1884 | 1885 | 1886 | 1887 |
1888 |
1889 | 1890 |
1891 |
1892 |
1893 | Суисей329.png 1895 | 1896 | 1897 | 1898 | 1899 | 400Кб, 518x627 1900 |
1901 | 1903 | 518x627 1908 | 1909 | 1910 |
1911 |
1912 | 1913 |
1914 | >>4280871
Чудесец Сави, чудесец Сави! Сави! Верно, чудесец! 1916 | Сави! Сааави! Чудо, чудесец. Хиккемашину покупать и не нужно вовсе. 1917 | 1918 |
1919 | 1920 | 1921 | 1922 |
1923 |
1924 | 1925 | 1926 | 1927 |
1928 |
1929 | 1930 |
1931 | 1932 | 1933 | 1934 | 1935 | Обреченный 1936 | 1937 | 1938 | 1939 | 1940 | 02/05/21 Вск 16:59:24 1941 | 1942 | 428089027 1946 | 1948 | 1949 | 1952 | 1953 | 1954 | 1955 | 1956 | 1957 |
1958 |
1959 | 1960 |
1961 |
1962 |
1963 | 1569193462676.jpg 1965 | 1966 | 1967 | 1968 | 1969 | 124Кб, 640x479 1970 |
1971 | 1973 | 640x479 1978 | 1979 | 1980 |
1981 |
1982 | 1983 |
1984 | >>4280874
Правда-правда, чудесец. 1986 | 1987 |
1988 |
>>4280891
1991 | 1992 | 1993 |
1994 |
1995 | 1996 | 1997 | 1998 |
1999 |
2000 | 2001 |
2002 | 2003 | 2004 | 2005 | 2006 | Обреченный 2007 | 2008 | 2009 | 2010 | 2011 | 02/05/21 Вск 17:00:38 2012 | 2013 | 428089128 2017 | 2019 | 2020 | 2023 | 2024 | 2025 | 2026 | 2027 | 2028 |
2029 |
2030 | 2031 |
2032 |
2033 |
2034 | Суисей333.png 2036 | 2037 | 2038 | 2039 | 2040 | 348Кб, 532x525 2041 |
2042 | 2044 | 532x525 2049 | 2050 | 2051 |
2052 |
2053 | 2054 |
2055 | >>4280890
Спасибо, чудесец. Спасибо-спасибо, чудесец! 2057 | 2058 |
2059 | 2060 | 2061 | 2062 |
2063 |
2064 | 2065 | 2066 |
2067 | 2068 |
2069 | 2070 | 2103 | 2399 |
--------------------------------------------------------------------------------