├── .gitignore ├── README.md ├── async ├── async_article_parser.py ├── async_main.py └── new_asyncio_engine │ ├── main.py │ ├── qasnc_test.py │ └── type.py ├── build.json ├── deprecated └── no_gui.py ├── main.ico ├── main.py ├── main.ui ├── module ├── __pycache__ │ ├── article_parser.cpython-310.pyc │ ├── article_parser.cpython-39.pyc │ ├── headers.cpython-39.pyc │ ├── resource.cpython-39.pyc │ └── ui_loader.cpython-39.pyc ├── article_parser.py ├── headers.py ├── resource.py └── ui_loader.py ├── resource ├── arrow.png ├── img.png ├── main.png ├── search.ico └── search.png ├── test.py ├── type └── article_parser.py └── ui.py /.gitignore: -------------------------------------------------------------------------------- 1 | user_save.dat 2 | venv/ 3 | .idea/ 4 | .vscode/ 5 | output/ 6 | __pycache__/ 7 | *.pyc 8 | module/__pycache__/ 9 | *.pyc 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dcinside_Explorer 2 | ![image](https://user-images.githubusercontent.com/31213158/150639615-f28f4f02-80eb-4514-93dd-74c829c7d539.png) 3 | 4 | 디시인사이드 글 검색기 입니다. 5 | 기존 10000개씩 끊어서 검색하는 디시인사이드의 글 검색 시스템을 개선하는 프로그램 입니다. 6 | 7 | QThread를 제대로 활용하지 못해서 버그가 많습니다.. 8 | 추후에 일렉트론이나 C#을 이용해서 비동기 모델로 네트워크 I/O 처리를 전환할 여지가 있습니다. 9 | 10 | 사용방법 : https://pgh268400.tistory.com/380 11 | -------------------------------------------------------------------------------- /async/async_article_parser.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import aiohttp 4 | import async_timeout 5 | import requests 6 | from bs4 import BeautifulSoup 7 | 8 | from module.headers import headers, search_type 9 | 10 | 11 | # 비동기 http 요청 fetch 함수 구현 12 | async def fetch(session, url): 13 | async with async_timeout.timeout(10): 14 | async with session.get(url, headers=headers) as response: 15 | return await response.text() 16 | 17 | 18 | class DCArticleParser: 19 | # 생성자 20 | def __init__(self, dc_id): 21 | # 인스턴스 변수 초기화 (캡슐화를 위해 Private 선언 => 파이썬에선 private 변수 언더바 2개로 선언 가능) 22 | self.__g_type = "" # 갤러리 타입 23 | self.__all_link = {} # 링크 저장하는 딕셔너리 24 | self.__dc_id = dc_id 25 | 26 | self.__g_type = self.get_gallary_type(dc_id) # 갤러리 타입 얻어오기 27 | 28 | # 갤러리 타입 가져오기(마이너, 일반) - 생성자에서 사용하므로 동기적으로 처리 29 | def get_gallary_type(self, dc_id): 30 | # url로 requests를 보내서 redirect시키는지 체크한다. 31 | url = f'https://gall.dcinside.com/board/lists/?id={dc_id}' 32 | result = url 33 | 34 | res = requests.get(url, headers=headers) 35 | soup = BeautifulSoup(res.text, "lxml") 36 | if "location.replace" in str(soup): 37 | redirect_url = str(soup).split('"')[3] 38 | result = redirect_url 39 | if "mgallery" in result: 40 | result = "mgallery/board" 41 | else: 42 | result = "board" 43 | return result 44 | 45 | # 글 파싱 함수 46 | async def article_parse(self, keyword, s_type, page=1, search_pos=''): 47 | try: 48 | # Client Session 생성 49 | async with aiohttp.ClientSession() as session: 50 | # article 모아서 반환할 list 51 | result = [] 52 | 53 | # 이미 Class 안에서 알고 있는 변수들 54 | g_type = self.__g_type 55 | dc_id = self.__dc_id 56 | 57 | url = f"https://gall.dcinside.com/{g_type}/lists/?id={dc_id}&page={page}&search_pos={search_pos}&s_type={s_type}&s_keyword={keyword}" 58 | # print(url) 59 | 60 | res = await fetch(session, url) # 비동기 http 요청 61 | soup = BeautifulSoup(res, "lxml") 62 | 63 | article_list = soup.select(".us-post") # 글 박스 전부 select 64 | for element in article_list: 65 | # 글 박스를 하나씩 반복하면서 정보 추출 66 | link = "https://gall.dcinside.com/" + \ 67 | element.select("a")[0]['href'].strip() 68 | num = element.select(".gall_num")[0].text 69 | img = element.select(".ub-word > a > em.icon_pic") 70 | if img: 71 | img = True 72 | else: 73 | img = False 74 | 75 | title = element.select(".ub-word > a")[0].text 76 | reply = element.select( 77 | ".ub-word > a.reply_numbox > .reply_num") 78 | if reply: 79 | reply = reply[0].text.replace( 80 | "[", "").replace("]", "").split("/")[0] 81 | else: 82 | reply = 0 83 | nickname = element.select(".ub-writer")[0].text.strip() 84 | timestamp = element.select(".gall_date")[0].text 85 | refresh = element.select(".gall_count")[0].text 86 | recommend = element.select(".gall_recommend")[0].text 87 | # print(link, num, title, reply, nickname, timestamp, refresh, recommend) 88 | 89 | self.__all_link[num] = link # 링크 추가 90 | 91 | article_data = {'num': num, 'title': title, 'reply': reply, 'nickname': nickname, 92 | 'timestamp': timestamp, 93 | 'refresh': refresh, 'recommend': recommend} 94 | result.append(article_data) 95 | return result # 글 데이터 반환 96 | except Exception as e: 97 | raise Exception('글을 가져오는 중 오류가 발생했습니다.') # 예외 발생 98 | 99 | # 페이지 탐색용 함수 100 | async def page_explorer(self, keyword, s_type, search_pos=''): 101 | async with aiohttp.ClientSession() as session: 102 | g_type = self.__g_type 103 | dc_id = self.__dc_id 104 | page = {} 105 | url = f"https://gall.dcinside.com/{g_type}/lists/?id={dc_id}&page=1&search_pos={search_pos}&s_type" \ 106 | f"={s_type}&s_keyword={keyword} " 107 | res = await fetch(session, url) 108 | soup = BeautifulSoup(res, "lxml") 109 | 110 | article_list = soup.select(".us-post") # 글 박스 전부 select 111 | article_count = len(article_list) 112 | if article_count == 0: # 글이 없으면 113 | page['start'] = 0 114 | page['end'] = 0 # 페이지는 없음 115 | elif article_count < 20: # 20개 미만이면 116 | page['start'] = 1 117 | page['end'] = 1 # 1페이지 밖에 없음. 118 | else: 119 | # 끝 보기 버튼이 있나 검사 120 | page_end_btn = soup.select('a.page_end') 121 | 122 | if len(page_end_btn) == 2: 123 | page_end_btn = page_end_btn[0] 124 | final_page = int(page_end_btn['href'].split( 125 | '&page=')[1].split("&")[0]) + 1 126 | page['start'] = 1 127 | page['end'] = final_page 128 | else: 129 | page_box = soup.select( 130 | '#container > section.left_content.result article > div.bottom_paging_wrap > ' 131 | 'div.bottom_paging_box > a') 132 | 133 | page['start'] = 1 134 | if len(page_box) == 1: 135 | page['end'] = 1 136 | else: 137 | page['end'] = page_box[-2].text.strip() 138 | 139 | if page['end'] == '이전검색': 140 | page['end'] = 1 141 | page['end'] = int(page['end']) 142 | 143 | # next_pos 구하기 (다음 페이지 검색 위치) 144 | next_pos = soup.select('a.search_next') 145 | if next_pos: # 다음 찾기가 존재하면 146 | next_pos = soup.select('a.search_next')[0]['href'].split( 147 | '&search_pos=')[1].split("&")[0] 148 | else: # 미존재시 149 | next_pos = 'last' 150 | page['next_pos'] = next_pos 151 | 152 | # 글이 해당 페이지에 존재하는지 알려주는 값 153 | page['isArticle'] = False if page['start'] == 0 else True 154 | return page 155 | 156 | def get_link_list(self): 157 | return self.__all_link 158 | 159 | 160 | def run(): 161 | parser = DCArticleParser(dc_id="baseball_new11") # 객체 생성 162 | 163 | running = True 164 | 165 | loop_count = 20 166 | keyword = "이준석" 167 | 168 | idx = 0 169 | search_pos = '' 170 | while True: 171 | if not running: 172 | return 173 | 174 | if idx > loop_count or search_pos == 'last': 175 | print('상태 : 검색 완료') 176 | print('작업이 완료되었습니다.') 177 | running = False 178 | break 179 | 180 | page = parser.page_explorer(keyword, search_type["제목+내용"], search_pos) 181 | print(page) 182 | 183 | if page['isArticle']: # 해당 페이지에 글이 있으면 184 | 185 | for i in range(page['start'], page['end'] + 1): 186 | if not running: 187 | return 188 | 189 | print(f'상태 : {idx}/{loop_count} 탐색중...') 190 | page_article = parser.article_parse( 191 | keyword, search_type["제목+내용"], page=i, search_pos=search_pos) 192 | print(page_article) 193 | 194 | idx += 1 # 페이지 글들을 하나 탐색하면 + 1 195 | 196 | if idx > loop_count or search_pos == 'last': 197 | break 198 | 199 | time.sleep(0.1) # 디시 서버를 위한 딜레이 200 | 201 | print(f'상태 : {idx}/{loop_count} 탐색중...') 202 | idx += 1 # 글을 못찾고 넘어가도 + 1 203 | 204 | search_pos = page['next_pos'] 205 | 206 | # async def main(): 207 | # parser = DCArticleParser(dc_id="baseball_new11") # 객체 생성 208 | # keyword, stype = "ㅎㅇ", search_type["제목+내용"] 209 | # 210 | # first_page = await parser.page_explorer(keyword, stype) 211 | # first_next_pos = first_page["next_pos"] 212 | # 213 | # tmp_pos = first_next_pos 214 | # task_lst = [] 215 | # for i in range(1, 100): 216 | # future = asyncio.ensure_future( 217 | # parser.article_parse(keyword, stype, page=1, search_pos=tmp_pos)) # future = js의 promise와 유사한 것 218 | # task_lst.append(future) 219 | # tmp_pos = str(int(tmp_pos) + 10000) 220 | # 221 | # start = time.time() 222 | # completed, pending = await asyncio.wait(task_lst, return_when=ALL_COMPLETED) 223 | # print(completed) 224 | # end = time.time() 225 | # print(f'>>> 비동기 처리 총 소요 시간: {end - start}') 226 | # 227 | # 228 | # # 파이썬 3.7 이상 asyncio.run 으로 간단하게 사용 가능 229 | # asyncio.run(main()) 230 | -------------------------------------------------------------------------------- /async/async_main.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import functools 3 | import os 4 | import pickle 5 | import sys 6 | import webbrowser 7 | 8 | import qasync 9 | from PyQt5 import uic 10 | from PyQt5.QtCore import * 11 | from PyQt5.QtGui import QIntValidator, QIcon 12 | from PyQt5.QtWidgets import QMessageBox, QTableWidgetItem, QAbstractItemView, QMainWindow 13 | from async_article_parser import DCArticleParser 14 | from qasync import asyncSlot, QApplication 15 | 16 | from module.headers import search_type 17 | from module.resource import resource_path 18 | 19 | # Global -------------------------------------------- 20 | 21 | # 프로그램 검색기능 실행중일때 22 | running = False 23 | parser = None 24 | 25 | # 쓰레드 제대로 정상적으로 끝났는지 체크하기 위한 변수 26 | finished = False 27 | 28 | # ------------------------------------------- 29 | 30 | # Main UI Load 31 | main_ui = resource_path('../main.ui') 32 | Ui_MainWindow = uic.loadUiType(main_ui)[0] # ui 가져오기 33 | 34 | 35 | # 사용자 정의 Signal 36 | class ThreadMessageEvent(QObject): 37 | signal1 = pyqtSignal(str) 38 | 39 | def run(self, data): 40 | self.signal1.emit(data) 41 | 42 | 43 | class QTableWidgetUpdate(QObject): 44 | signal1 = pyqtSignal(list) 45 | 46 | def run(self, data): 47 | self.signal1.emit(data) 48 | 49 | 50 | class QLabelWidgetUpdate(QObject): 51 | signal1 = pyqtSignal(str) 52 | 53 | def run(self, data): 54 | self.signal1.emit(data) 55 | 56 | 57 | class Main(QMainWindow, Ui_MainWindow): 58 | def __init__(self): 59 | super().__init__() 60 | self.setupUi(self) 61 | self.initializer() 62 | 63 | window_ico = resource_path('../main.ico') 64 | self.setWindowIcon(QIcon(window_ico)) 65 | self.show() 66 | 67 | def initializer(self): 68 | self.setTableWidget() # Table Widget Column 폭 Fixed 69 | self.set_only_int() # 반복횟수는 숫자만 입력할 수 있도록 고정 70 | self.load_data('../user_save.dat') 71 | 72 | # 폼 종료 이벤트 73 | def closeEvent(self, QCloseEvent): 74 | repeat = self.txt_repeat.text() 75 | gallary_id = self.txt_id.text() 76 | keyword = self.txt_keyword.text() 77 | comboBox = self.comboBox.currentText() 78 | 79 | data = {'repeat': repeat, 'gallary_id': gallary_id, 80 | 'keyword': keyword, 'search_type': comboBox} 81 | self.save_data(data, '../user_save.dat') 82 | 83 | self.deleteLater() 84 | QCloseEvent.accept() 85 | 86 | def save_data(self, dict, filename): 87 | # 데이터 저장 88 | with open(filename, 'wb') as fw: 89 | pickle.dump(dict, fw) 90 | 91 | def load_data(self, filename): 92 | if os.path.isfile(filename): 93 | with open(filename, 'rb') as fr: 94 | data = pickle.load(fr) 95 | self.txt_repeat.setText(data['repeat']) 96 | self.txt_id.setText(data['gallary_id']) 97 | self.txt_keyword.setText(data['keyword']) 98 | self.comboBox.setCurrentText(data['search_type']) 99 | 100 | else: 101 | return 102 | 103 | def set_only_int(self): 104 | self.onlyInt = QIntValidator() 105 | self.txt_repeat.setValidator(self.onlyInt) 106 | 107 | def setTableWidget(self): 108 | self.articleView.setEditTriggers( 109 | QAbstractItemView.NoEditTriggers) # TableWidget 읽기 전용 설정 110 | self.articleView.setColumnWidth(0, 60) # 글 번호 111 | self.articleView.setColumnWidth(1, 430) # 제목 112 | self.articleView.setColumnWidth(2, 50) # 댓글수 113 | 114 | self.articleView.setColumnWidth(3, 100) # 글쓴이 115 | self.articleView.setColumnWidth(4, 60) # 작성일 116 | self.articleView.setColumnWidth(5, 40) # 조회 117 | self.articleView.setColumnWidth(6, 40) # 추천 118 | 119 | def setTableAutoSize(self): 120 | header = self.articleView.horizontalHeader() 121 | # 성능을 위해 이제 자동 컬럼조정은 사용하지 않는다. 122 | # header.setSectionResizeMode(0, QHeaderView.ResizeToContents) 123 | # header.setSectionResizeMode(1, QHeaderView.ResizeToContents) 124 | # header.setSectionResizeMode(2, QHeaderView.ResizeToContents) 125 | # header.setSectionResizeMode(3, QHeaderView.ResizeToContents) 126 | # header.setSectionResizeMode(4, QHeaderView.ResizeToContents) 127 | # header.setSectionResizeMode(5, QHeaderView.ResizeToContents) 128 | 129 | # GUI---------------------------------------------- 130 | 131 | @asyncSlot() 132 | async def search(self): # 글검색 버튼 133 | # global running 134 | # 135 | # if running: # 이미 실행중이면 136 | # dialog = QMessageBox.question(self, 'Message', 137 | # '검색이 진행중입니다. 새로 검색을 시작하시겠습니까?', 138 | # QMessageBox.Yes | QMessageBox.No, QMessageBox.No) 139 | # if dialog == QMessageBox.Yes: 140 | # running = False 141 | # self.thread.stop() # 쓰레드 종료 142 | 143 | if self.txt_id.text() != '' and self.txt_keyword.text() != '' and self.txt_repeat.text() != '': 144 | task = asyncio.create_task(self.run()) 145 | else: 146 | QMessageBox.information( 147 | self, '알림', '값을 전부 입력해주세요.', QMessageBox.Yes) 148 | 149 | async def run(self): 150 | global running, parser 151 | print("검색 작업 시작...") 152 | running = True 153 | 154 | id = self.txt_id.text() 155 | keyword = self.txt_keyword.text() 156 | loop_count = int(self.txt_repeat.text()) 157 | s_type = search_type[self.comboBox.currentText()] 158 | 159 | msg = ThreadMessageEvent() 160 | msg.signal1.connect(self.ThreadMessageEvent) 161 | 162 | table = QTableWidgetUpdate() 163 | table.signal1.connect(self.QTableWidgetUpdate) 164 | 165 | label = QLabelWidgetUpdate() 166 | label.signal1.connect(self.QLabelWidgetUpdate) 167 | 168 | parser = DCArticleParser(dc_id=id) # 객체 생성 169 | 170 | search_pos = '' 171 | idx = 0 172 | 173 | while True: 174 | if not running: 175 | return 176 | 177 | if idx > loop_count or search_pos == 'last': 178 | label.run('상태 : 검색 완료') 179 | msg.run('작업이 완료되었습니다.') 180 | running = False 181 | break 182 | 183 | page = await parser.page_explorer(keyword, s_type, search_pos) 184 | # print(page) 185 | 186 | if not page['start'] == 0: # 글이 있으면 187 | 188 | for i in range(page['start'], page['end'] + 1): 189 | if not running: 190 | return 191 | 192 | label.run(f'상태 : {idx}/{loop_count} 탐색중...') 193 | article = await parser.article_parse(keyword, s_type, page=i, search_pos=search_pos) 194 | table.run(article) 195 | 196 | idx += 1 # 글을 QTableWidgetUpdate하나 탐색하면 + 1 197 | 198 | if idx > loop_count or search_pos == 'last': 199 | break 200 | 201 | # 디시 서버를 위한 딜레이 (비동기 Non-Blocking 을 위해 동기 time.sleep 을 사용하지 않는다.) 202 | await asyncio.sleep(0.1) 203 | 204 | label.run(f'상태 : {idx}/{loop_count} 탐색중...') 205 | idx += 1 # 글을 못찾고 넘어가도 + 1 206 | 207 | search_pos = page['next_pos'] 208 | 209 | # 리스트뷰 아이템 더블클릭 210 | 211 | def item_dbl_click(self): 212 | global parser 213 | 214 | if parser: 215 | try: 216 | all_link = parser.get_link_list() 217 | row = self.articleView.currentIndex().row() 218 | column = self.articleView.currentIndex().column() 219 | 220 | # if (column == 0): 221 | # article_id = self.articleView.item(row, column).text() 222 | # webbrowser.open(all_link[article_id]) 223 | 224 | article_id = self.articleView.item(row, 0).text() 225 | webbrowser.open(all_link[article_id]) 226 | 227 | # 포커스 초기화 & 선택 초기화 228 | self.articleView.clearSelection() 229 | self.articleView.clearFocus() 230 | except Exception as e: 231 | pass 232 | 233 | # Slot Event 234 | @pyqtSlot(str) 235 | def ThreadMessageEvent(self, n): 236 | QMessageBox.information(self, '알림', n, QMessageBox.Yes) 237 | 238 | @pyqtSlot(list) 239 | def QTableWidgetUpdate(self, article): 240 | for data in article: 241 | row_position = self.articleView.rowCount() 242 | self.articleView.insertRow(row_position) 243 | 244 | item_num = QTableWidgetItem() 245 | item_num.setData(Qt.DisplayRole, int( 246 | data['num'])) # 숫자로 설정 (정렬을 위해) 247 | self.articleView.setItem(row_position, 0, item_num) 248 | 249 | self.articleView.setItem( 250 | row_position, 1, QTableWidgetItem(data['title'])) 251 | 252 | item_reply = QTableWidgetItem() 253 | item_reply.setData(Qt.DisplayRole, int( 254 | data['reply'])) # 숫자로 설정 (정렬을 위해) 255 | self.articleView.setItem(row_position, 2, item_reply) 256 | 257 | self.articleView.setItem( 258 | row_position, 3, QTableWidgetItem(data['nickname'])) 259 | self.articleView.setItem( 260 | row_position, 4, QTableWidgetItem(data['timestamp'])) 261 | 262 | item_refresh = QTableWidgetItem() 263 | item_refresh.setData(Qt.DisplayRole, int( 264 | data['refresh'])) # 숫자로 설정 (정렬을 위해) 265 | self.articleView.setItem(row_position, 5, item_refresh) 266 | 267 | item_recommend = QTableWidgetItem() 268 | item_recommend.setData(Qt.DisplayRole, int( 269 | data['recommend'])) # 숫자로 설정 (정렬을 위해) 270 | self.articleView.setItem(row_position, 6, item_recommend) 271 | 272 | @pyqtSlot(str) 273 | def QLabelWidgetUpdate(self, data): 274 | self.txt_status.setText(data) 275 | 276 | @pyqtSlot() 277 | def on_finished(self): 278 | global finished 279 | finished = True 280 | print("끝났슴다.") 281 | 282 | 283 | async def main(): 284 | def close_future(future, loop): 285 | loop.call_later(10, future.cancel) 286 | future.cancel() 287 | 288 | loop = asyncio.get_event_loop() 289 | future = asyncio.Future() 290 | 291 | app = QApplication.instance() 292 | if hasattr(app, "aboutToQuit"): 293 | getattr(app, "aboutToQuit").connect( 294 | functools.partial(close_future, future, loop) 295 | ) 296 | 297 | main = Main() 298 | main.show() 299 | 300 | await future 301 | return True 302 | 303 | 304 | if __name__ == "__main__": 305 | try: 306 | qasync.run(main()) 307 | except asyncio.exceptions.CancelledError: 308 | sys.exit(0) 309 | -------------------------------------------------------------------------------- /async/new_asyncio_engine/main.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from pprint import pprint 3 | import re 4 | import time 5 | from typing import Any 6 | import aiohttp 7 | import tqdm 8 | from aiolimiter import AsyncLimiter 9 | 10 | from type import * 11 | 12 | 13 | class DCAsyncParser: 14 | 15 | headers = { 16 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36', 17 | 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', 18 | 'Referer': 'https://gall.dcinside.com/board/lists/?id=baseball_new11', 19 | 'Sec-Fetch-Site': 'same-origin', 20 | 'Sec-Fetch-Mode': 'navigate', 21 | 'Sec-Fetch-User': '?1', 22 | 'Sec-Fetch-Dest': 'document' 23 | } 24 | requests_limit = 50 25 | 26 | 27 | 28 | # 생성자에는 async를 붙일 수 없으므로, static 메서드를 이용하여 생성자를 대체한다. 29 | # https://stackoverflow.com/questions/36363278/can-async-await-be-used-in-constructors 30 | # Factory Method 패턴? 31 | @staticmethod 32 | async def create(id: str) -> "DCAsyncParser": 33 | o = DCAsyncParser(id) 34 | await o.initialize() 35 | return o 36 | 37 | def __init__(self, id: str) -> None: 38 | # 생성자에선 정적인 변수만 처리함, 39 | # 그리고 값을 여기서 정하지 않는 것들은 그냥 타입만 적고 할당은 하지 않음 40 | self.gallary_type: Gallary 41 | self.search_type: Search 42 | self.keyword: str 43 | self.id: str = id 44 | 45 | # id만을 가지고 웹 요청을 보내서 갤러리를 파악한다. 46 | # 생성자에서 원래는 해야할 일이였지만 async를 붙일 수 없으므로, 47 | # initialize 메서드를 만들어서 대체한다. 48 | 49 | async def initialize(self) -> None: 50 | url = f'https://gall.dcinside.com/board/lists?id={self.id}' 51 | # 여기서 사용하는 session은 1회용. async with으로 묶어야 하기 때문에 이 session 은 아쉽게도 재활용 불가능하다. 52 | # 다만 search 함수에서는 session을 재활용할 수 있으므로, 그곳에선 재활용했으니 ㄱㅊ 53 | async with aiohttp.ClientSession(headers=DCAsyncParser.headers) as session: 54 | res = await self.fetch(session, url) 55 | 56 | # 리다이렉션 됐다는건 일반 갤러리가 아니라는 뜻 57 | if "location.replace" in res: 58 | if "mgallery" in res: 59 | self.gallary_type = Gallary.MINER 60 | else: 61 | self.gallary_type = Gallary.MINI 62 | else: 63 | self.gallary_type = Gallary.DEFAULT 64 | 65 | # For Debug 66 | output = "" 67 | if self.gallary_type == Gallary.DEFAULT: 68 | output = "일반" 69 | elif self.gallary_type == Gallary.MINER: 70 | output = "마이너" 71 | elif self.gallary_type == Gallary.MINI: 72 | output = "미니" 73 | print("갤러리 타입 :", output) 74 | 75 | # requests 모듈 대신 사용하는 aiohttp 모듈을 이용해서 비동기로 요청을 보낸다. 76 | # session은 매번 생성하는게 아닌 호출하는쪽에서 생성해서 재활용 하도록함. = 매번 세션을 생성하지 않으므로 성능 향상 77 | async def fetch(self, session, url) -> str: 78 | async with session.get(url) as response: 79 | return await response.text() 80 | 81 | # pos를 받아서 해당 검색 글 위치에서 시작 페이지와 끝 페이지가 몇 페이지인지 계산한다. 82 | async def get_page_structure(self, session, pos) -> Page: 83 | url = f'https://gall.dcinside.com/{self.gallary_type}board/lists/?id={self.id}&s_type={self.search_type}&s_keyword={self.keyword}&search_pos={pos}' 84 | async with session.get(url) as response: 85 | res: str = await response.text() 86 | res = res[:res.find('result_guide')] # result_guide 이전으로 짜르기 87 | 88 | pattern = re.compile(r'page=(\d+)') 89 | match = re.findall(pattern, res) 90 | 91 | start_page = 1 92 | 93 | if abs(pos) < 10000: 94 | # 10000개 이하의 글 검색 위치면 마지막 페이지에서 검색중이라는 뜻 95 | last_page = int(match[-1]) 96 | else: 97 | # 마지막 페이지가 아님 98 | last_page = int(match[-2]) 99 | 100 | return Page(pos, start_page, last_page) 101 | 102 | # pos, page 위치로부터 글 데이터를 가져온다. 103 | async def get_article_from_page(self, session, pos, page) -> list[Article]: 104 | url = f'https://gall.dcinside.com/{self.gallary_type}board/lists/?id={self.id}&s_type={self.search_type}&s_keyword={self.keyword}&page={page}&search_pos={pos}' 105 | async with session.get(url) as response: 106 | res: str = await response.text() 107 | # result_guide 이전으로 짜르기 (아래에 부수 검색 결과 거르기 위한 용도) 108 | res = res[:res.find('result_guide')] 109 | 110 | pattern = r'' 111 | trs = re.findall(pattern, res) 112 | 113 | result: list[Article] = [] 114 | for tr in trs: 115 | # tr 태그 안에 있는 문자열 가져오기 116 | gall_num: str = re.findall( 117 | r'(.*?)', tr)[0].strip() 118 | gall_tit: str = re.findall( 119 | r'', "") 122 | gall_tit = gall_tit.replace( 123 | '', "") 124 | gall_tit = gall_tit.split( 125 | 'view-msg ="">')[1].split('')[0].strip() 126 | gall_writer = re.findall(r'data-nick="([^"]*)"', tr)[0].strip() 127 | gall_date = re.findall( 128 | r'(.*?)', tr)[0].strip() 129 | gall_count = re.findall( 130 | r'(.*?)', tr)[0].strip() 131 | gall_recommend = re.findall( 132 | r'(.*?)', tr)[0].strip() 133 | 134 | result.append(Article(gall_num, gall_tit, gall_writer, 135 | gall_date, gall_count, gall_recommend)) 136 | 137 | # print(gall_num, gall_tit, gall_writer, gall_date, gall_count, gall_recommend) 138 | return result 139 | 140 | async def search(self, search_type : Search, keyword : str, repeat_cnt : int) -> Any: 141 | global limiter 142 | 143 | # 객체 변수에 검색에 관련 정보를 기억시킨다. 144 | # 매번 메서드 매개변수에 넘기는게 번거롭기 때문 145 | self.search_type = search_type 146 | self.keyword = keyword 147 | 148 | async with aiohttp.ClientSession(connector=aiohttp.TCPConnector(limit=DCAsyncParser.requests_limit), headers=DCAsyncParser.headers) as session: 149 | # async with limiter: 150 | # 제일 처음 검색 페이지에 요청을 던져서 글 갯수 (검색 위치) 를 파악한다. 151 | url = f'https://gall.dcinside.com/{self.gallary_type}board/lists/?id={self.id}&s_type={self.search_type}&s_keyword={self.keyword}' 152 | res = await self.fetch(session, url) 153 | 154 | # 다음 페이지 버튼에서 다음 10000개 글 검색 위치를 찾는다 155 | # 현재 글 위치는 next_pos - 10000 으로 계산 가능, DC 서버는 10000개씩 검색하기 때문 156 | next_pos = re.search(r'search_pos=(-?\d+)', res) 157 | if next_pos: 158 | next_pos = int(next_pos.group(1)) 159 | else: 160 | raise Exception('다음 검색 위치를 찾을 수 없습니다.') 161 | 162 | # 이 시점에서 next_pos 의 타입은 확실히 int 타입임을 보장 163 | current_pos: int = next_pos - 10000 164 | print(f"현재 글 위치 : {current_pos}", ) 165 | print(f"다음 검색 위치 : {next_pos}") 166 | print(f"전체 글 목록 {abs(next_pos)}개") 167 | 168 | print("먼저 10000개 단위로 검색하면서 각 목록의 페이지 수를 파악합니다.") 169 | start = time.time() # 시작 시간 저장 170 | 171 | # 최대 페이지 172 | max_pos = abs(current_pos) // 10000 + 1 173 | print(f"최대 탐색 가능 횟수 : {max_pos}개") 174 | 175 | # 다음 검색을 누를 횟수 (수정하는 부분) 176 | # next_search_cnt = max_pos 177 | next_search_cnt = repeat_cnt 178 | 179 | # url에 현재 pos 부터 10000씩 더해가면서 요청을 보낸다 180 | # 그때 사용할 임시 변수 : pos 181 | tmp_pos = current_pos 182 | 183 | # pos 범위 임시 출력용 리스트 184 | tmp_pos_list = [] 185 | 186 | # 코루틴 작업을 담을 리스트 187 | tasks: list = [] 188 | for _ in range(next_search_cnt): 189 | tmp_pos_list.append(tmp_pos) #지워도 됨 디버깅용 190 | tasks.append(self.get_page_structure(session, tmp_pos)) 191 | 192 | if abs(tmp_pos) < 10000: 193 | break 194 | tmp_pos += 10000 195 | 196 | print(tmp_pos_list) 197 | 198 | print("전체 페이지 구조 분석 시작...") 199 | 200 | # 한꺼번에 실행해서 결과를 받는다 (싱글쓰레드 - 이벤트 루프 ON) 201 | # res_list : list = await asyncio.gather(*tasks) 202 | 203 | # tdqm 활용하여 비동기 작업 진행상황 표시 204 | res_list: list[Page] = [] 205 | for f in tqdm.tqdm(asyncio.as_completed(tasks), total=len(tasks)): 206 | res_list.append(await f) 207 | print(len(res_list)) 208 | 209 | print("time :", time.time() - start) # 현재시각 - 시작시간 = 실행 시간 210 | 211 | res_list.sort(key=lambda x: int(x[0])) 212 | print(res_list) 213 | 214 | # await self.get_article_from_page(session, "-56999", 1) 215 | # ---------------------------------------------------------- 216 | 217 | # 코루틴 작업을 담을 리스트 218 | tasks: list = [] 219 | for item in res_list: 220 | for page in range(item.start_page, item.last_page + 1): 221 | tasks.append(self.get_article_from_page( 222 | session, item.pos, page)) 223 | 224 | # 한꺼번에 실행해서 결과를 받는다 (싱글쓰레드 - 이벤트 루프 ON) 225 | # tdqm 활용하여 비동기 작업 진행상황 표시 226 | 227 | print("페이지별 글 목록 수집 시작...") 228 | article_list: list = [] 229 | for f in tqdm.tqdm(asyncio.as_completed(tasks), total=len(tasks)): 230 | result = await f 231 | article_list.append(result) 232 | 233 | total_cnt = 0 234 | for item in article_list: 235 | total_cnt += len(item) 236 | pprint(article_list) 237 | pprint(total_cnt) 238 | 239 | return article_list 240 | 241 | # limiter = AsyncLimiter(1, 0.125) 242 | if __name__ == '__main__': 243 | asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) 244 | # parser = asyncio.run(DCAsyncParser.create(id='taiko')) 245 | # asyncio.run(parser.search(search_type=Search.TITLE_PLUS_CONTENT, keyword='타타콘', repeat_cnt=9999)) 246 | parser = asyncio.run(DCAsyncParser.create(id='baseball_new11')) 247 | asyncio.run(parser.search(search_type=Search.TITLE_PLUS_CONTENT, keyword='야붕이', repeat_cnt=9999)) 248 | 249 | -------------------------------------------------------------------------------- /async/new_asyncio_engine/qasnc_test.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import functools 3 | import sys 4 | 5 | import aiohttp 6 | 7 | from PyQt5.QtWidgets import ( 8 | # from PySide2.QtWidgets import ( 9 | QWidget, 10 | QLabel, 11 | QLineEdit, 12 | QTextEdit, 13 | QPushButton, 14 | QVBoxLayout, 15 | ) 16 | 17 | from PyQt5.QtWidgets import * 18 | from PyQt5.QtCore import * 19 | 20 | import qasync 21 | from qasync import asyncSlot, asyncClose, QApplication 22 | 23 | from main import DCAsyncParser 24 | from type import Search 25 | 26 | 27 | class MainWindow(QWidget): 28 | """Main window.""" 29 | 30 | _DEF_URL = "https://jsonplaceholder.typicode.com/todos/1" 31 | """str: Default URL.""" 32 | 33 | _SESSION_TIMEOUT = 1.0 34 | """float: Session timeout.""" 35 | 36 | def __init__(self): 37 | super().__init__() 38 | 39 | self.setLayout(QVBoxLayout()) 40 | 41 | self.lblStatus = QLabel("Idle", self) 42 | self.layout().addWidget(self.lblStatus) 43 | 44 | self.editUrl = QLineEdit(self._DEF_URL, self) 45 | self.layout().addWidget(self.editUrl) 46 | 47 | self.editResponse = QTextEdit("", self) 48 | self.layout().addWidget(self.editResponse) 49 | 50 | self.btnFetch = QPushButton("Fetch", self) 51 | self.btnFetch.clicked.connect(self.on_btnFetch_clicked) 52 | self.layout().addWidget(self.btnFetch) 53 | 54 | self.session = aiohttp.ClientSession( 55 | loop=asyncio.get_event_loop(), 56 | timeout=aiohttp.ClientTimeout(total=self._SESSION_TIMEOUT), 57 | ) 58 | 59 | @asyncClose 60 | async def closeEvent(self, event): 61 | await self.session.close() 62 | 63 | @asyncSlot() 64 | async def on_btnFetch_clicked(self): 65 | self.btnFetch.setEnabled(False) 66 | self.lblStatus.setText("Fetching...") 67 | 68 | try: 69 | lst = [] 70 | for i in range(1, 1000): 71 | lst.append(self.session.get(self.editUrl.text())) 72 | 73 | asyncio.gather(*lst) 74 | # self.editResponse.setText(await r.text()) 75 | 76 | # parser = await DCAsyncParser.create(id='taiko') 77 | # result = await parser.search(search_type=Search.TITLE_PLUS_CONTENT, keyword='타타콘', repeat_cnt=9999) 78 | # print(result) 79 | 80 | except Exception as exc: 81 | self.lblStatus.setText("Error: {}".format(exc)) 82 | else: 83 | self.lblStatus.setText("Finished!") 84 | finally: 85 | self.btnFetch.setEnabled(True) 86 | 87 | 88 | async def main(): 89 | def close_future(future, loop): 90 | loop.call_later(10, future.cancel) 91 | future.cancel() 92 | 93 | loop = asyncio.get_event_loop() 94 | future = asyncio.Future() 95 | 96 | app = QApplication.instance() 97 | if hasattr(app, "aboutToQuit"): 98 | getattr(app, "aboutToQuit").connect( 99 | functools.partial(close_future, future, loop) 100 | ) 101 | 102 | mainWindow = MainWindow() 103 | mainWindow.show() 104 | 105 | await future 106 | return True 107 | 108 | 109 | if __name__ == "__main__": 110 | try: 111 | qasync.run(main()) 112 | except asyncio.exceptions.CancelledError: 113 | sys.exit(0) 114 | -------------------------------------------------------------------------------- /async/new_asyncio_engine/type.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import NamedTuple 3 | 4 | 5 | class Search(Enum): 6 | TITLE_PLUS_CONTENT = 'search_subject_memo' 7 | TITLE = 'search_subject' 8 | CONTENT = 'search_memo' 9 | NICKNAME = 'search_name' 10 | COMMENT = 'search_comment' 11 | 12 | def __str__(self): 13 | return '%s' % self.value 14 | 15 | 16 | class Page(NamedTuple): 17 | pos: int 18 | start_page: int 19 | last_page: int 20 | 21 | 22 | class Gallary(Enum): 23 | DEFAULT = "" 24 | MINER = "mgallery/" 25 | MINI = "mini/" 26 | 27 | def __str__(self): 28 | return '%s' % self.value 29 | 30 | 31 | class Article(NamedTuple): 32 | gall_num: str 33 | gall_tit: str 34 | gall_writer: str 35 | gall_date: str 36 | gall_count: str 37 | gall_recommend: str 38 | -------------------------------------------------------------------------------- /build.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "auto-py-to-exe-configuration_v1", 3 | "pyinstallerOptions": [ 4 | { 5 | "optionDest": "noconfirm", 6 | "value": true 7 | }, 8 | { 9 | "optionDest": "filenames", 10 | "value": "C:/Users/pgh26/PycharmProjects/DCINSIDE_SEARCH/main.py" 11 | }, 12 | { 13 | "optionDest": "onefile", 14 | "value": true 15 | }, 16 | { 17 | "optionDest": "console", 18 | "value": false 19 | }, 20 | { 21 | "optionDest": "icon_file", 22 | "value": "C:/Users/pgh26/PycharmProjects/DCINSIDE_SEARCH/main.ico" 23 | }, 24 | { 25 | "optionDest": "ascii", 26 | "value": false 27 | }, 28 | { 29 | "optionDest": "clean_build", 30 | "value": false 31 | }, 32 | { 33 | "optionDest": "strip", 34 | "value": false 35 | }, 36 | { 37 | "optionDest": "noupx", 38 | "value": false 39 | }, 40 | { 41 | "optionDest": "disable_windowed_traceback", 42 | "value": false 43 | }, 44 | { 45 | "optionDest": "embed_manifest", 46 | "value": true 47 | }, 48 | { 49 | "optionDest": "uac_admin", 50 | "value": false 51 | }, 52 | { 53 | "optionDest": "uac_uiaccess", 54 | "value": false 55 | }, 56 | { 57 | "optionDest": "win_private_assemblies", 58 | "value": false 59 | }, 60 | { 61 | "optionDest": "win_no_prefer_redirects", 62 | "value": false 63 | }, 64 | { 65 | "optionDest": "bootloader_ignore_signals", 66 | "value": false 67 | }, 68 | { 69 | "optionDest": "datas", 70 | "value": "C:/Users/pgh26/PycharmProjects/DCINSIDE_SEARCH/main.ico;." 71 | } 72 | ], 73 | "nonPyinstallerOptions": { 74 | "increaseRecursionLimit": true, 75 | "manualArguments": "" 76 | } 77 | } -------------------------------------------------------------------------------- /deprecated/no_gui.py: -------------------------------------------------------------------------------- 1 | import time 2 | import requests, re 3 | from bs4 import BeautifulSoup 4 | import asyncio 5 | import timeit 6 | from aiohttp import ClientSession 7 | 8 | # 봇 차단을 위한 헤더 설정 9 | headers = { 10 | "Connection": "keep-alive", 11 | "Cache-Control": "max-age=0", 12 | "sec-ch-ua-mobile": "?0", 13 | "DNT": "1", 14 | "Upgrade-Insecure-Requests": "1", 15 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36", 16 | "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", 17 | "Sec-Fetch-Site": "none", 18 | "Sec-Fetch-Mode": "navigate", 19 | "Sec-Fetch-User": "?1", 20 | "Sec-Fetch-Dest": "document", 21 | "Accept-Encoding": "gzip, deflate, br", 22 | "Accept-Language": "ko-KR,ko;q=0.9" 23 | } 24 | 25 | 26 | # 갤러리 타입 가져오기(마이너, 일반) 27 | def get_gallary_type(dc_id): 28 | # url로 requests를 보내서 redirect시키는지 체크한다. 29 | url = f'https://gall.dcinside.com/board/lists/?id={dc_id}' 30 | result = url 31 | 32 | res = requests.get(url, headers=headers) 33 | soup = BeautifulSoup(res.text, "lxml") 34 | if "location.replace" in str(soup): 35 | redirect_url = str(soup).split('"')[3] 36 | result = redirect_url 37 | if "mgallery" in result: 38 | result = "mgallery/board" 39 | else: 40 | result = "board" 41 | 42 | return result 43 | 44 | 45 | # 글 파싱 함수 46 | async def article_parse(dc_id, keyword, page=1): 47 | url = f"https://gall.dcinside.com/{g_type}/lists/?id={dc_id}&page={page}&s_type=search_subject_memo&s_keyword={keyword}" 48 | async with ClientSession() as session: 49 | async with session.get(url, headers=headers) as response: 50 | r = await response.read() 51 | 52 | soup = BeautifulSoup(r, "lxml") 53 | article_list = soup.select(".us-post") # 글 박스 전부 select 54 | for element in article_list: 55 | # 글 박스를 하나씩 반복하면서 정보 추출 56 | link = "https://gall.dcinside.com/" + element.select("a")[0]['href'].strip() 57 | num = element.select(".gall_num")[0].text 58 | title = element.select(".ub-word > a")[0].text 59 | reply = element.select(".ub-word > a.reply_numbox > .reply_num") 60 | if reply: 61 | reply = reply[0].text 62 | else: 63 | reply = "" 64 | nickname = element.select(".ub-writer")[0].text.strip() 65 | timestamp = element.select(".gall_date")[0].text 66 | refresh = element.select(".gall_count")[0].text 67 | recommend = element.select(".gall_recommend")[0].text 68 | 69 | print(link, num, title, reply, nickname, timestamp, refresh, recommend) 70 | 71 | 72 | # 페이지 탐색용 함수 73 | def first_page_explorer(dc_id, keyword, search_pos=''): 74 | page = {} 75 | url = f"https://gall.dcinside.com/{g_type}/lists/?id={dc_id}&page=1&search_pos={search_pos}&s_type=search_subject_memo&s_keyword={keyword}" 76 | res = requests.get(url, headers=headers) 77 | soup = BeautifulSoup(res.text, "lxml") 78 | 79 | article_list = soup.select(".us-post") # 글 박스 전부 select 80 | article_count = len(article_list) 81 | if article_count == 0: # 글이 없으면 82 | page['start'] = 0; 83 | page['end'] = 0 # 페이지는 없음 84 | elif article_count < 20: # 20개 미만이면 85 | page['start'] = 1; 86 | page['end'] = 1 # 1페이지 밖에 없음. 87 | else: 88 | # 끝 보기 버튼이 있나 검사 89 | page_end_btn = soup.find('a', attrs={"class": "page_end"}) 90 | if page_end_btn: 91 | final_page = int(page_end_btn['href'].split('&page=')[1].split("&")[0]) + 1 92 | page['start'] = 1; 93 | page['end'] = final_page 94 | else: 95 | page_box = soup.select( 96 | '#container > section.left_content.result article > div.bottom_paging_wrap > div.bottom_paging_box > a') 97 | 98 | page['start'] = 1; 99 | page['end'] = page_box[-2].text.strip() 100 | 101 | next_pos = soup.select('a.search_next')[0]['href'].split('&search_pos=')[1].split("&")[0] 102 | page['next_pos'] = next_pos 103 | return page 104 | 105 | 106 | # 페이지 탐색용 함수 107 | async def page_explorer(dc_id, keyword, search_pos=''): 108 | global repeat_count 109 | 110 | page = {} 111 | url = f"https://gall.dcinside.com/{g_type}/lists/?id={dc_id}&page=1&search_pos={search_pos}&s_type=search_subject_memo&s_keyword={keyword}" 112 | async with ClientSession() as session: 113 | async with session.get(url, headers=headers) as response: 114 | r = await response.read() 115 | 116 | soup = BeautifulSoup(r, "lxml") 117 | article_list = soup.select(".us-post") # 글 박스 전부 select 118 | article_count = len(article_list) 119 | if article_count == 0: # 글이 없으면 120 | page['start'] = 0; 121 | page['end'] = 0 # 페이지는 없음 122 | elif article_count < 20: # 20개 미만이면 123 | page['start'] = 1; 124 | page['end'] = 1 # 1페이지 밖에 없음. 125 | else: 126 | # 끝 보기 버튼이 있나 검사 127 | page_end_btn = soup.find('a', attrs={"class": "page_end"}) 128 | if page_end_btn: 129 | final_page = int(page_end_btn['href'].split('&page=')[1].split("&")[0]) + 1 130 | page['start'] = 1; 131 | page['end'] = final_page 132 | else: 133 | page_box = soup.select( 134 | '#container > section.left_content.result article > div.bottom_paging_wrap > div.bottom_paging_box > a') 135 | 136 | page['start'] = 1; 137 | page['end'] = int(page_box[-2].text.strip()) 138 | if page['end'] == '이전검색': 139 | page['end'] = 1 140 | page['end'] = int(page['end']) 141 | 142 | page['current_pos'] = search_pos 143 | 144 | if page['start'] != 0: 145 | repeat_count = repeat_count + (page['end'] - page['start']) 146 | futures = [asyncio.ensure_future(article_parse(dc_id, keyword, i)) for i in range(page['start'], page['end'] + 1)] 147 | await asyncio.gather(*futures) 148 | 149 | 150 | # 반복횟수 저장 151 | repeat_count = 0 152 | 153 | # 검색할때 설정해줘야할 것들 154 | dc_id = "vr_games_xuq" 155 | keyword = "게임" 156 | search_pos = '' 157 | 158 | # 코루틴 돌리기전 작업 159 | g_type = get_gallary_type(dc_id) 160 | first = first_page_explorer(dc_id, keyword) 161 | idx = int(first['next_pos']) 162 | 163 | 164 | # 코루틴 작업 시작 165 | start = timeit.default_timer() 166 | loop = asyncio.get_event_loop() 167 | tasks = [] 168 | 169 | for i in range(1, 3): 170 | task = asyncio.ensure_future(page_explorer(dc_id, keyword, idx)) 171 | idx = idx + 10000 172 | if idx > 0: 173 | break 174 | tasks.append(task) 175 | loop.run_until_complete(asyncio.wait(tasks)) 176 | 177 | print(timeit.default_timer() - start) 178 | print("repeat count : ", repeat_count) -------------------------------------------------------------------------------- /main.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pgh268400/Dcinside_Explorer_Python/cbbf680c43b171d6d8d810f52c327fd099f86bcb/main.ico -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | # 이 아래 __future__ import 문을 쓰면 Class 내부에서 Class 변수 타입 힌트를 적거나 (순환 참조 문제 해결) , 나중에 Class 가 나와도 앞에서 타입 힌트를 적을 수 있다. 2 | from __future__ import annotations 3 | import os 4 | import pickle 5 | import sys 6 | import time 7 | from typing import Optional 8 | import webbrowser 9 | 10 | from PyQt5.QtCore import * 11 | from PyQt5.QtGui import QIntValidator, QIcon 12 | from PyQt5.QtWidgets import * 13 | from PyQt5 import QtCore 14 | 15 | from module.article_parser import DCArticleParser 16 | from module.headers import search_type 17 | from module.ui_loader import ui_auto_complete 18 | from type.article_parser import Article, Page, SaveData 19 | 20 | # from module.resource import resource_path 21 | 22 | # Global -------------------------------------------- 23 | 24 | # 프로그램 검색기능 실행중일때 25 | running = False 26 | thread_dead = False # 쓰레드 종료 여부 27 | parser: Optional[DCArticleParser] = None # 싱글톤 패턴으로 객체는 전역적으로 하나만 사용할 것임. 28 | 29 | # ------------------------------------------- 30 | 31 | # 글 검색 쓰레드 32 | 33 | 34 | class Worker(QThread): 35 | # PYQT의 쓰레드는 UI Update에 있어서 Unsafe하기 때문에 36 | # 무조건 시그널-슬롯으로 UI 업데이트를 진행해줘야 한다!! 37 | 38 | ThreadMessageEvent = pyqtSignal(str) # 사용자 정의 시그널 39 | QLabelWidgetUpdate = pyqtSignal(str) # 라벨 위젯 업데이트 40 | QTableWidgetUpdate = pyqtSignal(list) # 테이블 위젯 업데이트 41 | QTableWidgetSetSort = pyqtSignal(bool) # 테이블 위젯 컬럼 정렬 기능 ON/OFF 42 | 43 | # 메인폼에서 상속받기 44 | 45 | # parent는 WindowClass에서 전달하는 self이다.(WidnowClass의 인스턴스) 46 | def __init__(self, parent: Main) -> None: 47 | super().__init__(parent) # 부모 Class의 생성자 호출 - 코드 중복 방지 48 | # 클래스 변수 self.parent로 WindowClass 인스턴스를 접근할 수 있다. 49 | self.parent: Main = parent 50 | 51 | def run(self) -> None: 52 | global running, parser 53 | # self.parent를 사용하여 부모의 메서드나 데이터에 read 가능. 54 | # (단 Race Condition 방지를 위해 UI를 write 할땐 시그널 - 슬롯으로 접근해야 한다.) 55 | # Qt의 UI들은 thread safe 하지 않다. 56 | # 아무래도 더 구조적인 방법은 Read던 Write 던 시그널 - 슬롯으로 접근하는게 좋아보임 57 | # 상속으로 직접 접근하는건 그렇게 좋아보이진 않음. 58 | 59 | search_pos = '' 60 | id = self.parent.txt_id.text() 61 | keyword = self.parent.txt_keyword.text() 62 | loop_count = int(self.parent.txt_repeat.text()) 63 | s_type = search_type[self.parent.comboBox.currentText()] 64 | 65 | parser = DCArticleParser(dc_id=id) # 객체 생성 66 | 67 | idx = 0 68 | 69 | # 데이터 삽입 중엔 Column 정렬기능을 OFF 하자. (ON 할 경우 다운될 수도 있음.) 70 | self.QTableWidgetSetSort.emit(False) 71 | while running: 72 | if idx > loop_count or search_pos == 'last': 73 | self.QLabelWidgetUpdate.emit('상태 : 검색 완료') 74 | self.ThreadMessageEvent.emit('작업이 완료되었습니다.') 75 | running = False 76 | break 77 | 78 | page: Page = parser.page_explorer(keyword, s_type, search_pos) 79 | # print(page) 80 | 81 | if not page.start == 0: # 글이 있으면 82 | 83 | for i in range(page.start, page.end + 1): 84 | if not running: 85 | return 86 | 87 | self.QLabelWidgetUpdate.emit( 88 | f'상태 : {idx}/{loop_count} 탐색중...') 89 | article: list[Article] = parser.article_parse( 90 | keyword, s_type, page=i, search_pos=search_pos) 91 | self.QTableWidgetUpdate.emit(article) 92 | 93 | idx += 1 # 글을 하나 탐색하면 + 1 94 | 95 | if idx > loop_count or search_pos == 'last': 96 | break 97 | 98 | time.sleep(0.1) # 디시 서버를 위한 딜레이 99 | 100 | self.QLabelWidgetUpdate.emit(f'상태 : {idx}/{loop_count} 탐색중...') 101 | idx += 1 # 글을 못찾고 넘어가도 + 1 102 | 103 | search_pos = page.next_pos 104 | self.QTableWidgetSetSort.emit(True) 105 | 106 | # def stop(self): 107 | # self.quit() 108 | # self.wait(3000) 109 | 110 | # 모듈화에 문제가 생겨서 우선 하드 코딩 111 | 112 | 113 | def resource_path(relative_path: str) -> str: 114 | base_path = getattr(sys, '_MEIPASS', os.path.dirname( 115 | os.path.abspath(__file__))) 116 | return os.path.join(base_path, relative_path) 117 | 118 | 119 | # Main UI Compile & Load 120 | 121 | # fmt: off 122 | 123 | ui_auto_complete("main.ui", "ui.py") # ui 파일 컴파일 (main.ui -> ui.py) 124 | 125 | # ui 컴파일 이후 UI를 가져온다 126 | from ui import Ui_MainWindow 127 | 128 | # fmt: on 129 | 130 | # SearchWindow 131 | 132 | 133 | class SearchWindow(QMainWindow, Ui_MainWindow): 134 | filtering = pyqtSignal(str) # 필터링 시그널 135 | 136 | # 100,100 창으로 설정 137 | def __init__(self) -> None: 138 | super().__init__() 139 | self.initUI() 140 | 141 | def txt_id_enter(self) -> None: 142 | # 현재 검색값을 시그널로 Main윈도우에 넘기기 143 | self.filtering.emit(self.txt_keyword.text()) 144 | 145 | def initUI(self) -> None: 146 | self.setWindowTitle('Search Window') 147 | 148 | # 입력창 추가 149 | self.txt_keyword = QLineEdit(self) 150 | self.txt_keyword.move(0, 0) 151 | self.txt_keyword.resize(200, 20) 152 | 153 | # QlineEdit CSS 추가 154 | 155 | # 아이콘 main.ico 로 창 설정 156 | self.setWindowIcon(QIcon(resource_path('main.ico'))) 157 | 158 | # 종료 버튼만 남기고 숨기기 & Always on Top 159 | self.setWindowFlags(Qt.WindowCloseButtonHint | 160 | Qt.WindowStaysOnTopHint) # type: ignore 161 | 162 | # 창 크기 변경 못하게 변경 163 | self.setFixedSize(200, 20) 164 | 165 | # txt_id 엔터 시그널 연결 166 | self.txt_keyword.returnPressed.connect(self.txt_id_enter) 167 | 168 | # self.move(300, 300) 169 | # self.resize(200, 20) 170 | 171 | # 창 Always on top 설정 172 | # self.setWindowFlags(Qt.WindowStaysOnTopHint) 173 | self.show() 174 | 175 | # 엔터키 누르면 종료 176 | def keyPressEvent(self, e) -> None: 177 | # esc 누르면 종료 178 | if e.key() == QtCore.Qt.Key_Escape: 179 | self.close() 180 | 181 | 182 | class Main(QMainWindow, Ui_MainWindow): 183 | # 경로의 경우 모든 객체가 공유하도록 설계. 해당 폼 클래스 역시 싱글톤이기 때문에 사실 큰 의미는 없다. 184 | ICO_PATH = 'main.ico' 185 | ARROW_PATH = './resource/arrow.png' 186 | SAVE_FILE_PATH = 'user_save.dat' 187 | 188 | def __init__(self) -> None: 189 | super().__init__() 190 | self.setupUi(self) 191 | self.initializer() 192 | 193 | window_ico = resource_path(Main.ICO_PATH) 194 | self.setWindowIcon(QIcon(window_ico)) 195 | 196 | # 인스턴스 변수 (다른 언어에선 static 변수 , 객체가 공유), 사실 창 객체는 하나만 만들꺼라 별 의미 없음 197 | arrow_path = resource_path(Main.ARROW_PATH) 198 | 199 | # arrow_path 경로를 슬래시로 변경 (윈도우 역슬래시 경로 문자열을 슬래쉬로 바꿔줘야함. 아니면 인식을 못하네용.. ㅠ) 200 | arrow_path = arrow_path.replace('\\', '/') 201 | 202 | style = f"QComboBox::down-arrow {{image: url(%s);}}" % arrow_path 203 | self.comboBox.setStyleSheet(style) 204 | # print(style) 205 | 206 | # 이전 검색 기록 기억 207 | # 파이썬에선 멤버 변수 선언시 생성자에 적어야함. 208 | # self를 안적으면 C#이나 Java의 객체 static 선언하고 똑같다고 보면 됨. 209 | self.prev_item = "" 210 | self.prev_idx = 0 211 | 212 | self.show() 213 | 214 | def initializer(self) -> None: 215 | self.set_table_widget() # Table Widget Column 폭 Fixed 216 | self.set_only_int() # 반복횟수는 숫자만 입력할 수 있도록 고정 217 | self.load_data(Main.SAVE_FILE_PATH) 218 | 219 | # 폼 종료 이벤트 220 | def closeEvent(self, QCloseEvent) -> None: 221 | repeat = self.txt_repeat.text() 222 | gallary_id = self.txt_id.text() 223 | keyword = self.txt_keyword.text() 224 | comboBox = self.comboBox.currentText() 225 | 226 | data: SaveData = SaveData(repeat, gallary_id, keyword, comboBox) 227 | self.save_data(data, Main.SAVE_FILE_PATH) 228 | 229 | if hasattr(self, 'searchWindow'): 230 | self.searchWindow.close() 231 | 232 | self.deleteLater() 233 | QCloseEvent.accept() 234 | 235 | def save_data(self, data, filename) -> None: 236 | # 데이터 저장 237 | with open(filename, 'wb') as fw: 238 | pickle.dump(data, fw) 239 | 240 | def load_data(self, filename) -> None: 241 | if os.path.isfile(filename): 242 | with open(filename, 'rb') as fr: 243 | data_dict: dict[str, str] = pickle.load(fr) 244 | converted_data = SaveData(*data_dict) 245 | 246 | self.txt_repeat.setText(converted_data.repeat) 247 | self.txt_id.setText(converted_data.gallary_id) 248 | self.txt_keyword.setText(converted_data.keyword) 249 | self.comboBox.setCurrentText(converted_data.search_type) 250 | 251 | else: 252 | return 253 | 254 | def set_only_int(self) -> None: 255 | self.onlyInt = QIntValidator() 256 | self.txt_repeat.setValidator(self.onlyInt) 257 | 258 | def set_table_widget(self) -> None: 259 | self.articleView.setEditTriggers( 260 | QAbstractItemView.NoEditTriggers) # TableWidget 읽기 전용 설정 261 | self.articleView.setColumnWidth(0, 60) # 글 번호 262 | self.articleView.setColumnWidth(1, 430) # 제목 263 | self.articleView.setColumnWidth(2, 50) # 댓글수 264 | self.articleView.setColumnWidth(3, 100) # 글쓴이 265 | self.articleView.setColumnWidth(4, 60) # 작성일 266 | self.articleView.setColumnWidth(5, 40) # 조회 267 | self.articleView.setColumnWidth(6, 40) # 추천 268 | 269 | 270 | def set_table_autosize(self, isAutoResize: bool) -> None: 271 | # 성능을 위해 사이즈 정책은 검색 완료후 변경하도록 한다. 272 | # 해당 함수는 검색 완료후 2번 호출된다. (정책만 바꿔서 오토 리사이징 시키고 다시 정책을 원래대로 돌려놓기 위함) 273 | # isAutoResize : True - 자동 사이즈 조절, False - 고정 사이즈 274 | column_cnt = self.articleView.columnCount() 275 | header = self.articleView.horizontalHeader() 276 | if (isAutoResize): 277 | [header.setSectionResizeMode( 278 | i, QHeaderView.ResizeToContents) for i in range(column_cnt)] 279 | else: 280 | [header.setSectionResizeMode(i, QHeaderView.Fixed) 281 | for i in range(column_cnt)] 282 | 283 | # GUI---------------------------------------------- 284 | 285 | def search(self) -> None: # 글검색 286 | self.thread: Worker 287 | global running 288 | 289 | if self.txt_id.text() == '' or self.txt_keyword.text() == '' or self.txt_repeat.text() == '': 290 | QMessageBox.information( 291 | self, '알림', '값을 전부 입력해주세요.', QMessageBox.Yes) 292 | return 293 | 294 | if running: # 이미 실행중이면 295 | dialog = QMessageBox.question(self, 'Message', 296 | '검색이 진행중입니다. 새로 검색을 시작하시겠습니까?', 297 | QMessageBox.Yes | QMessageBox.No) 298 | if dialog == QMessageBox.Yes: 299 | running = False 300 | self.thread.terminate() # 작동 추가한 부분 (처리 상황에 따라 주석처리) -> 쓰레드 강제 종료 301 | # self.thread.quit() 302 | print("wait for thread to terminate") 303 | global thread_dead 304 | while not thread_dead: 305 | pass 306 | self.thread.wait() # 쓰레드 종료때까지 대기 (join 메서드 없음) 307 | 308 | print("thread terminated, continue run...") 309 | thread_dead = False 310 | else: # 취소 버튼 누른경우 걍 바로 함수 종료 311 | return 312 | 313 | # 검색 쓰레드 실행 부분 314 | 315 | running = True 316 | self.articleView.setRowCount(0) # 글 초기화 317 | self.thread = Worker(self) 318 | 319 | # 쓰레드 이벤트 연결 320 | self.thread.ThreadMessageEvent.connect( 321 | self.ThreadMessageEvent) 322 | self.thread.QTableWidgetUpdate.connect( 323 | self.QTableWidgetUpdate) 324 | self.thread.QLabelWidgetUpdate.connect( 325 | self.QLabelWidgetUpdate) 326 | self.thread.QTableWidgetSetSort.connect( 327 | self.QTableWidgetSetSort) 328 | 329 | self.thread.finished.connect(self.on_finished) 330 | 331 | # 쓰레드 작업 시작 332 | self.thread.start() 333 | 334 | # 리스트뷰 아이템 더블클릭 335 | def item_dbl_click(self) -> None: 336 | global parser 337 | 338 | if parser: 339 | try: 340 | all_link = parser.get_link_list() 341 | row = self.articleView.currentIndex().row() 342 | column = self.articleView.currentIndex().column() 343 | 344 | # if (column == 0): 345 | # article_id = self.articleView.item(row, column).text() 346 | # webbrowser.open(all_link[article_id]) 347 | 348 | article_id = self.articleView.item(row, 0).text() 349 | webbrowser.open(all_link[article_id]) 350 | 351 | # 포커스 초기화 & 선택 초기화 352 | self.articleView.clearSelection() 353 | self.articleView.clearFocus() 354 | except Exception as e: 355 | pass 356 | 357 | # Slot Event 358 | @ pyqtSlot(str) 359 | def ThreadMessageEvent(self, n: str) -> None: 360 | QMessageBox.information(self, '알림', n, QMessageBox.Yes) 361 | 362 | @ pyqtSlot(bool) 363 | def QTableWidgetSetSort(self, bool: bool) -> None: 364 | self.articleView.setSortingEnabled(bool) 365 | 366 | @ pyqtSlot(list) 367 | def QTableWidgetUpdate(self, article: list[Article]) -> None: 368 | 369 | for data in article: 370 | row_position = self.articleView.rowCount() 371 | self.articleView.insertRow(row_position) 372 | 373 | item_num = QTableWidgetItem() 374 | item_num.setData(Qt.DisplayRole, int( 375 | data.num)) # 숫자로 설정 (정렬을 위해) 376 | self.articleView.setItem(row_position, 0, item_num) 377 | 378 | self.articleView.setItem( 379 | row_position, 1, QTableWidgetItem(data.title)) 380 | 381 | item_reply = QTableWidgetItem() 382 | item_reply.setData(Qt.DisplayRole, int( 383 | data.reply)) # 숫자로 설정 (정렬을 위해) 384 | self.articleView.setItem(row_position, 2, item_reply) 385 | 386 | self.articleView.setItem( 387 | row_position, 3, QTableWidgetItem(data.nickname)) 388 | self.articleView.setItem( 389 | row_position, 4, QTableWidgetItem(data.timestamp)) 390 | 391 | item_refresh = QTableWidgetItem() 392 | item_refresh.setData(Qt.DisplayRole, int( 393 | data.refresh)) # 숫자로 설정 (정렬을 위해) 394 | self.articleView.setItem(row_position, 5, item_refresh) 395 | 396 | item_recommend = QTableWidgetItem() 397 | item_recommend.setData(Qt.DisplayRole, int( 398 | data.recommend)) # 숫자로 설정 (정렬을 위해) 399 | self.articleView.setItem(row_position, 6, item_recommend) 400 | 401 | @ pyqtSlot(str) 402 | def QLabelWidgetUpdate(self, data) -> None: 403 | self.txt_status.setText(data) 404 | 405 | @ pyqtSlot() 406 | def on_finished(self) -> None: 407 | global thread_dead 408 | thread_dead = True 409 | pass 410 | 411 | @ pyqtSlot(str) 412 | def filtering(self, keyword) -> None: 413 | # Clear current selection. 414 | self.articleView.setCurrentItem(None) 415 | 416 | if not keyword: 417 | # Empty string, don't search. 418 | return 419 | 420 | if self.prev_item == keyword: 421 | # 같은 키워드가 들어오면 다음 아이템으로 이동해서 확인 422 | self.prev_idx += 1 423 | else: 424 | # 키워드가 달라지면 처음부터 다시 검색 425 | self.prev_idx = 0 426 | 427 | matching_items = self.articleView.findItems(keyword, Qt.MatchContains) 428 | matching_item_cnt = len(matching_items) 429 | if matching_items: 430 | # We have found something. 431 | if self.prev_idx >= matching_item_cnt: 432 | # 처음부터 다시 검색 433 | self.prev_idx = 0 434 | item = matching_items[self.prev_idx] # Take the first. 435 | self.prev_item = keyword # 검색한 내용 기억 436 | self.articleView.setCurrentItem(item) 437 | 438 | # print(keyword) 439 | 440 | def keyPressEvent(self, event: PyQt5.QtGui.QKeyEvent) -> None: 441 | global parser 442 | 443 | # Ctrl + C 누른 경우 Table의 내용 복사 444 | # https://stackoverflow.com/questions/60715462/how-to-copy-and-paste-multiple-cells-in-qtablewidget-in-pyqt5 445 | if event.key() == Qt.Key.Key_C and (event.modifiers() & Qt.KeyboardModifier.ControlModifier): # type: ignore 446 | copied_cells = sorted( 447 | self.articleView.selectedIndexes()) # type: ignore 448 | 449 | copy_text = '' 450 | max_column = copied_cells[-1].column() 451 | for c in copied_cells: 452 | # 링크도 복사 453 | if c.column() == 0 and parser: 454 | all_link = parser.get_link_list() 455 | article_id = self.articleView.item(c.row(), 0).text() 456 | 457 | target_link = all_link[article_id] 458 | # print(all_link, article_id) 459 | # print(target_link) 460 | copy_text += (target_link + '\t') 461 | 462 | copy_text += self.articleView.item(c.row(), c.column()).text() 463 | if c.column() == max_column: 464 | copy_text += '\n' 465 | else: 466 | copy_text += '\t' 467 | 468 | QApplication.clipboard().setText(copy_text) 469 | 470 | # Ctrl + F 누른 경우 검색 창 (필터링 창) 열기 471 | elif event.key() == Qt.Key.Key_F and (event.modifiers() & Qt.KeyboardModifier.ControlModifier): # type: ignore 472 | if hasattr(self, 'searchWindow'): 473 | # 이미 열려있으면 포커스만 이동 (창 활성화) 474 | if self.searchWindow.isVisible(): 475 | self.searchWindow.activateWindow() 476 | return 477 | 478 | self.searchWindow = SearchWindow() 479 | self.searchWindow.filtering.connect(self.filtering) 480 | self.searchWindow.show() 481 | 482 | 483 | app = QApplication([]) 484 | main = Main() 485 | QApplication.processEvents() 486 | sys.exit(app.exec_()) 487 | -------------------------------------------------------------------------------- /main.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | MainWindow 4 | 5 | 6 | 7 | 0 8 | 0 9 | 812 10 | 802 11 | 12 | 13 | 14 | DCINSIDE ARTICLE FINDER v0.152 Beta 15 | 16 | 17 | 18 | main.icomain.ico 19 | 20 | 21 | QLineEdit { 22 | border: 4px solid #3B4890; 23 | padding: 4px; 24 | } 25 | 26 | QLineEdit:focus{ 27 | border: 4px solid rgb(0, 170, 255); 28 | } 29 | 30 | QPushButton { 31 | background-color: #3b4890; 32 | min-width: 5em; 33 | padding: 8px; 34 | color:white; 35 | 36 | border-style: outset; 37 | border-width: 2px; 38 | border-radius : 5px; 39 | 40 | border-color: beige; 41 | } 42 | 43 | QPushButton:pressed { 44 | background-color: #29367C; 45 | border-style: inset; 46 | } 47 | 48 | QComboBox { 49 | border: 4px solid #3B4890; 50 | padding: 4px; 51 | } 52 | 53 | QComboBox::drop-down 54 | { 55 | border: 0px; 56 | } 57 | 58 | QComboBox::down-arrow { 59 | image: url(git.png); 60 | width: 28px; 61 | height: 28px; 62 | } 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 2 72 | 73 | 74 | QLayout::SetDefaultConstraint 75 | 76 | 77 | 78 | 79 | 80 | 맑은 고딕 81 | 10 82 | 83 | 84 | 85 | 86 | 제목+내용 87 | 88 | 89 | 90 | 91 | 제목 92 | 93 | 94 | 95 | 96 | 내용 97 | 98 | 99 | 100 | 101 | 글쓴이 102 | 103 | 104 | 105 | 106 | 댓글 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 맑은 고딕 116 | 10 117 | 118 | 119 | 120 | 121 | 122 | 123 | #searchBox { 124 | border: 4px solid #3b4890; 125 | } 126 | 127 | 128 | 탐색 횟수 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 맑은 고딕 137 | 10 138 | 139 | 140 | 141 | 142 | 143 | 144 | #searchBox { 145 | border: 4px solid #3b4890; 146 | } 147 | 148 | 149 | 갤러리 ID 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 맑은 고딕 158 | 10 159 | 160 | 161 | 162 | 163 | 164 | 165 | 검색어 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 나눔고딕 174 | 50 175 | false 176 | 177 | 178 | 179 | 검색 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | QLabel#label{ 189 | color : rgb(85, 85, 255) 190 | } 191 | 192 | 193 | true 194 | 195 | 196 | Qt::SolidLine 197 | 198 | 199 | true 200 | 201 | 202 | 203 | 번호 204 | 205 | 206 | 207 | 208 | 제목 209 | 210 | 211 | 212 | 213 | 댓글수 214 | 215 | 216 | 217 | 218 | 글쓴이 219 | 220 | 221 | 222 | 223 | 작성일 224 | 225 | 226 | 227 | 228 | 조회 229 | 230 | 231 | 232 | 233 | 추천 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 나눔고딕 245 | 75 246 | true 247 | true 248 | 249 | 250 | 251 | QLabel#label{ 252 | color : rgb(85, 85, 255) 253 | } 254 | 255 | 256 | Copyright 2022. File(pgh268400@naver.com) all rights reserved. 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 나눔고딕 265 | 10 266 | 267 | 268 | 269 | 상태 : IDLE 270 | 271 | 272 | Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | btn_Search 288 | clicked() 289 | MainWindow 290 | search() 291 | 292 | 293 | 685 294 | 21 295 | 296 | 297 | 376 298 | 58 299 | 300 | 301 | 302 | 303 | articleView 304 | itemDoubleClicked(QTableWidgetItem*) 305 | MainWindow 306 | item_dbl_click() 307 | 308 | 309 | 174 310 | 201 311 | 312 | 313 | 604 314 | 734 315 | 316 | 317 | 318 | 319 | txt_keyword 320 | returnPressed() 321 | MainWindow 322 | search() 323 | 324 | 325 | 255 326 | 27 327 | 328 | 329 | 290 330 | 7 331 | 332 | 333 | 334 | 335 | txt_id 336 | returnPressed() 337 | MainWindow 338 | search() 339 | 340 | 341 | 157 342 | 19 343 | 344 | 345 | 143 346 | 4 347 | 348 | 349 | 350 | 351 | txt_repeat 352 | returnPressed() 353 | MainWindow 354 | search() 355 | 356 | 357 | 48 358 | 27 359 | 360 | 361 | 47 362 | 1 363 | 364 | 365 | 366 | 367 | 368 | search() 369 | click() 370 | item_dbl_click() 371 | 372 | 373 | -------------------------------------------------------------------------------- /module/__pycache__/article_parser.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pgh268400/Dcinside_Explorer_Python/cbbf680c43b171d6d8d810f52c327fd099f86bcb/module/__pycache__/article_parser.cpython-310.pyc -------------------------------------------------------------------------------- /module/__pycache__/article_parser.cpython-39.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pgh268400/Dcinside_Explorer_Python/cbbf680c43b171d6d8d810f52c327fd099f86bcb/module/__pycache__/article_parser.cpython-39.pyc -------------------------------------------------------------------------------- /module/__pycache__/headers.cpython-39.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pgh268400/Dcinside_Explorer_Python/cbbf680c43b171d6d8d810f52c327fd099f86bcb/module/__pycache__/headers.cpython-39.pyc -------------------------------------------------------------------------------- /module/__pycache__/resource.cpython-39.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pgh268400/Dcinside_Explorer_Python/cbbf680c43b171d6d8d810f52c327fd099f86bcb/module/__pycache__/resource.cpython-39.pyc -------------------------------------------------------------------------------- /module/__pycache__/ui_loader.cpython-39.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pgh268400/Dcinside_Explorer_Python/cbbf680c43b171d6d8d810f52c327fd099f86bcb/module/__pycache__/ui_loader.cpython-39.pyc -------------------------------------------------------------------------------- /module/article_parser.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | import requests 3 | from bs4 import BeautifulSoup 4 | from module.headers import headers 5 | from type.article_parser import Article, GallaryType, Page 6 | 7 | 8 | class DCArticleParser: 9 | # 생성자 10 | def __init__(self, dc_id: str) -> None: 11 | # 인스턴스 변수 초기화 (캡슐화를 위해 Private 선언 => 파이썬에선 private 변수 언더바 2개로 선언 가능) 12 | self.__g_type: GallaryType # 갤러리 타입 13 | self.__all_link = {} # 링크 저장하는 딕셔너리 14 | self.__dc_id: str = dc_id 15 | 16 | self.__g_type = self.get_gallary_type(dc_id) # 갤러리 타입 얻어오기 17 | 18 | # 갤러리 타입 가져오기(마이너, 일반) 19 | def get_gallary_type(self, dc_id: str) -> GallaryType: 20 | # url로 requests를 보내서 redirect시키는지 체크한다. 21 | url = f'https://gall.dcinside.com/board/lists/?id={dc_id}' 22 | result = url 23 | 24 | res = requests.get(url, headers=headers) 25 | soup = BeautifulSoup(res.text, "lxml") 26 | if "location.replace" in str(soup): 27 | redirect_url = str(soup).split('"')[3] 28 | result = redirect_url 29 | if "mgallery" in result: 30 | result = "mgallery/board" 31 | else: 32 | result = "board" 33 | return result 34 | 35 | # 글 파싱 함수 36 | 37 | def article_parse(self, keyword: str, s_type: str, page: int = 1, search_pos='') -> list[Article]: 38 | try: 39 | # article 데이터 모아서 반환할 list 40 | result: list[Article] = [] 41 | 42 | # 이미 Class 안에서 알고 있는 변수들 43 | g_type = self.__g_type 44 | dc_id = self.__dc_id 45 | 46 | url = f"https://gall.dcinside.com/{g_type}/lists/?id={dc_id}&page={page}&search_pos={search_pos}&s_type={s_type}&s_keyword={keyword}" 47 | # print(url) 48 | 49 | res = requests.get(url, headers=headers) 50 | soup = BeautifulSoup(res.text, "lxml") 51 | 52 | article_list = soup.select(".us-post") # 글 박스 전부 select 53 | for element in article_list: 54 | # 글 박스를 하나씩 반복하면서 정보 추출 55 | link = "https://gall.dcinside.com/" + \ 56 | element.select("a")[0]['href'].strip() 57 | num = element.select(".gall_num")[0].text 58 | img = element.select(".ub-word > a > em.icon_pic") 59 | if img: 60 | img = True 61 | else: 62 | img = False 63 | 64 | # 이유는 모르겠으나 디시 사이트가 바뀐건지 .text 로 가져오면 65 | # 아래와 같이 66 | # Article(num='번호', title='\n데이터', reply='1', nickname='ㅇㅇ(118.235)', timestamp='03.30', refresh='208', recommend='0') 67 | # 데이터 앞에 \n 이 붙어서 strip() 으로 썰어줬다. 68 | # strip()은 문자열의 시작과 끝에 있는 줄바꿈과 공백을 제거합니다. 69 | # 원래 strip 은 공백 제거 기능만 있는줄 알았더니 줄바꿈 기능도 있었다! 70 | # https://codechacha.com/ko/python-remove-newline-in-string/ 참고 71 | title = element.select(".ub-word > a")[0].text.strip() 72 | 73 | reply = element.select( 74 | ".ub-word > a.reply_numbox > .reply_num") 75 | if reply: 76 | reply = reply[0].text.replace( 77 | "[", "").replace("]", "").split("/")[0] 78 | else: 79 | reply = "0" 80 | nickname = element.select(".ub-writer")[0].text.strip() 81 | timestamp = element.select(".gall_date")[0].text 82 | refresh = element.select(".gall_count")[0].text 83 | recommend = element.select(".gall_recommend")[0].text 84 | # print(link, num, title, reply, nickname, timestamp, refresh, recommend) 85 | 86 | self.__all_link[num] = link # 링크 추가 87 | 88 | # article_data = {'num': num, 'title': title, 'reply': reply, 'nickname': nickname, 89 | # 'timestamp': timestamp, 90 | # 'refresh': refresh, 'recommend': recommend} 91 | article_data = Article( 92 | num, title, reply, nickname, timestamp, refresh, recommend) 93 | result.append(article_data) 94 | return result # 글 데이터 반환 95 | 96 | except Exception as e: 97 | raise Exception('글을 가져오는 중 오류가 발생했습니다.') # 예외 발생 98 | 99 | # 페이지 탐색용 함수 100 | def page_explorer(self, keyword: str, s_type: str, search_pos: str = '') -> Page: 101 | g_type = self.__g_type 102 | dc_id = self.__dc_id 103 | 104 | # 결과를 저장할 변수 105 | result: Page 106 | 107 | url = f"https://gall.dcinside.com/{g_type}/lists/?id={dc_id}&page=1&search_pos={search_pos}&s_type" \ 108 | f"={s_type}&s_keyword={keyword} " 109 | 110 | res = requests.get(url, headers=headers) 111 | soup = BeautifulSoup(res.text, "lxml") 112 | 113 | start_page: Optional[int] = None 114 | end_page: Optional[int] = None 115 | next_pos: Optional[str] = None 116 | isArticle: Optional[bool] = None 117 | 118 | article_list = soup.select(".us-post") # 글 박스 전부 select 119 | article_count = len(article_list) 120 | if article_count == 0: # 글이 없으면 121 | start_page = 0 122 | end_page = 0 # 페이지는 없음 123 | elif article_count < 20: # 20개 미만이면 124 | start_page = 1 125 | end_page = 1 # 1페이지 밖에 없음. 126 | else: 127 | # 끝 보기 버튼이 있나 검사 128 | page_end_btn = soup.select('a.page_end') 129 | 130 | if len(page_end_btn) == 2: 131 | page_end_btn = page_end_btn[0] 132 | final_page = int(page_end_btn['href'].split( 133 | '&page=')[1].split("&")[0]) + 1 134 | start_page = 1 135 | end_page = final_page 136 | else: 137 | page_box = soup.select( 138 | '#container > section.left_content.result article > div.bottom_paging_wrap > ' 139 | 'div.bottom_paging_box > a') 140 | 141 | start_page = 1 142 | if len(page_box) == 1: 143 | end_page = 1 144 | else: 145 | end_page = page_box[-2].text.strip() 146 | 147 | # 문자열일 경우 148 | if isinstance(end_page, str): 149 | if '이전' in end_page: 150 | end_page = 1 151 | else: 152 | end_page = int(end_page) 153 | 154 | # next_pos 구하기 (다음 페이지 검색 위치) 155 | next_pos = soup.select('a.search_next') 156 | if next_pos: # 다음 찾기가 존재하면 157 | next_pos = soup.select('a.search_next')[0]['href'].split( 158 | '&search_pos=')[1].split("&")[0] 159 | else: # 미존재시 160 | next_pos = 'last' 161 | 162 | # 글이 해당 페이지에 존재하는지 알려주는 값 163 | isArticle = False if start_page == 0 else True 164 | 165 | result = Page(start_page, end_page, next_pos, isArticle) 166 | return result 167 | 168 | def get_link_list(self): 169 | return self.__all_link 170 | 171 | # parser = DCArticleParser(dc_id="baseball_new11") # 객체 생성 172 | # keyword, type = "ㅎㅇ", search_type["제목+내용"] 173 | # 174 | # first_next_pos = parser.page_explorer(keyword, type)["next_pos"] 175 | # print(first_next_pos) 176 | # run() 177 | -------------------------------------------------------------------------------- /module/headers.py: -------------------------------------------------------------------------------- 1 | # DC 봇 차단 우회를 위한 글로벌 헤더 2 | headers = { 3 | "Connection": "keep-alive", 4 | "Cache-Control": "max-age=0", 5 | "sec-ch-ua-mobile": "?0", 6 | "DNT": "1", 7 | "Upgrade-Insecure-Requests": "1", 8 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36", 9 | "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", 10 | "Sec-Fetch-Site": "none", 11 | "Sec-Fetch-Mode": "navigate", 12 | "Sec-Fetch-User": "?1", 13 | "Sec-Fetch-Dest": "document", 14 | "Accept-Encoding": "gzip, deflate, br", 15 | "Accept-Language": "ko-KR,ko;q=0.9" 16 | } 17 | 18 | # 검색 옵션에 따른 쿼리 스트링 19 | search_type = { 20 | "제목+내용": "search_subject_memo", 21 | "제목": "search_subject", 22 | "내용": "search_memo", 23 | "글쓴이": "search_name", 24 | "댓글": "search_comment" 25 | } 26 | -------------------------------------------------------------------------------- /module/resource.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | 5 | def resource_path(relative_path: str): 6 | """ 7 | 8 | :param relative_path: 상대 경로 9 | 상대 경로를 절대 경로로 바꿔주는 함수 10 | 11 | pyinstaller 파일 포함시 UI 파일이나 리소스 파일을 절대 경로로 포함시켜야 exe 빌드후에도 파일을 잘 찾아낼 수 있다. 12 | 13 | """ 14 | 15 | base_path = getattr(sys, '_MEIPASS', os.path.dirname( 16 | os.path.abspath(__file__))) 17 | return os.path.join(base_path, relative_path) 18 | 19 | 20 | def resource_path2(relative_path): 21 | """ 22 | 23 | :param relative_path: 상대 경로 24 | 상대 경로를 절대 경로로 바꿔주는 함수 25 | 26 | pyinstaller 파일 포함시 UI 파일이나 리소스 파일을 절대 경로로 포함시켜야 exe 빌드후에도 파일을 잘 찾아낼 수 있다. 27 | 28 | """ 29 | 30 | return os.path.abspath(relative_path) 31 | -------------------------------------------------------------------------------- /module/ui_loader.py: -------------------------------------------------------------------------------- 1 | # ui_loader.py 2 | # UI 파일을 PY로 자동 변환한후 읽어온다 3 | # PY로 변환작업을 거치지 않으면 IDE의 자동 완성 기능이 활성화 되지 않는다 4 | # EX) uic.loadUiType(your_ui)[0] => 자동 완성이 제대로 작동하지 않음!! 5 | # 출처 : https://stackoverflow.com/questions/58770646/autocomplete-from-ui 6 | 7 | from distutils.dep_util import newer 8 | import os 9 | from PyQt5 import uic # type: ignore 10 | 11 | 12 | def ui_auto_complete(ui_dir : str, ui_to_py_dir : str) -> None: 13 | encoding = 'utf-8' 14 | 15 | # UI 파일이 존재하지 않으면 아무 작업도 수행하지 않는다. 16 | if not os.path.isfile(ui_dir): 17 | print("The required file does not exist.") 18 | return 19 | 20 | # UI 파일이 업데이트 됬는지 확인하고, 업데이트 되었으면 *.py로 변환한다 21 | if not newer(ui_dir, ui_to_py_dir): 22 | pass 23 | # print("UI has not changed!") 24 | else: 25 | # print("UI changed detected, compiling...") 26 | # ui 파일이 업데이트 되었다, py파일을 연다. 27 | fp = open(ui_to_py_dir, "w", encoding=encoding) 28 | # ui 파일을 py파일로 컴파일한다. 29 | uic.compileUi(ui_dir, fp, 30 | execute=True, indent=4, from_imports=True) 31 | fp.close() -------------------------------------------------------------------------------- /resource/arrow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pgh268400/Dcinside_Explorer_Python/cbbf680c43b171d6d8d810f52c327fd099f86bcb/resource/arrow.png -------------------------------------------------------------------------------- /resource/img.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pgh268400/Dcinside_Explorer_Python/cbbf680c43b171d6d8d810f52c327fd099f86bcb/resource/img.png -------------------------------------------------------------------------------- /resource/main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pgh268400/Dcinside_Explorer_Python/cbbf680c43b171d6d8d810f52c327fd099f86bcb/resource/main.png -------------------------------------------------------------------------------- /resource/search.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pgh268400/Dcinside_Explorer_Python/cbbf680c43b171d6d8d810f52c327fd099f86bcb/resource/search.ico -------------------------------------------------------------------------------- /resource/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pgh268400/Dcinside_Explorer_Python/cbbf680c43b171d6d8d810f52c327fd099f86bcb/resource/search.png -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtCore import * 2 | from PyQt5.QtWidgets import * 3 | 4 | 5 | class MyMainGUI(QDialog): 6 | def __init__(self, parent=None): 7 | super().__init__(parent) 8 | 9 | self.qtxt1 = QTextEdit(self) 10 | self.btn1 = QPushButton("Start", self) 11 | self.btn2 = QPushButton("Stop", self) 12 | self.btn3 = QPushButton("add 100", self) 13 | self.btn4 = QPushButton("send instance", self) 14 | 15 | vbox = QVBoxLayout() 16 | vbox.addWidget(self.qtxt1) 17 | vbox.addWidget(self.btn1) 18 | vbox.addWidget(self.btn2) 19 | vbox.addWidget(self.btn3) 20 | vbox.addWidget(self.btn4) 21 | self.setLayout(vbox) 22 | 23 | self.setGeometry(100, 50, 300, 300) 24 | 25 | 26 | class Test: 27 | def __init__(self): 28 | name = "" 29 | 30 | 31 | class MyMain(MyMainGUI): 32 | add_sec_signal = pyqtSignal() 33 | send_instance_singal = pyqtSignal("PyQt_PyObject") 34 | 35 | def __init__(self, parent=None): 36 | super().__init__(parent) 37 | 38 | self.btn1.clicked.connect(self.time_start) 39 | self.btn2.clicked.connect(self.time_stop) 40 | self.btn3.clicked.connect(self.add_sec) 41 | self.btn4.clicked.connect(self.send_instance) 42 | 43 | self.th = Worker(parent=self) 44 | # custom signal from worker thread to main thread 45 | self.th.sec_changed.connect(self.time_update) 46 | 47 | # custom signal from main thread to worker thread 48 | self.add_sec_signal.connect(self.th.add_sec) 49 | self.send_instance_singal.connect(self.th.recive_instance_singal) 50 | self.show() 51 | 52 | @pyqtSlot() 53 | def time_start(self): 54 | self.th.start() 55 | self.th.working = True 56 | 57 | @pyqtSlot() 58 | def time_stop(self): 59 | self.th.working = False 60 | self.th.quit() 61 | 62 | @pyqtSlot() 63 | def add_sec(self): 64 | print(".... add singal emit....") 65 | self.add_sec_signal.emit() 66 | 67 | @pyqtSlot(str) 68 | def time_update(self, msg): 69 | self.qtxt1.append(msg) 70 | 71 | @pyqtSlot() 72 | def send_instance(self): 73 | t1 = Test() 74 | t1.name = "SuperPower!!!" 75 | self.send_instance_singal.emit(t1) 76 | 77 | 78 | class Worker(QThread): 79 | sec_changed = pyqtSignal(str) 80 | 81 | def __init__(self, sec=0, parent=None): 82 | super().__init__() 83 | self.main = parent 84 | self.working = True 85 | self.sec = sec 86 | 87 | # self.main.add_sec_signal.connect(self.add_sec) # 이것도 작동함. # custom signal from main thread to worker thread 88 | 89 | def __del__(self): 90 | print(".... end thread.....") 91 | self.wait() 92 | 93 | def run(self): 94 | while self.working: 95 | self.sec_changed.emit('time (secs):{}'.format(self.sec)) 96 | self.sleep(1) 97 | self.sec += 1 98 | 99 | @pyqtSlot() 100 | def add_sec(self): 101 | print("add_sec....") 102 | self.sec += 100 103 | 104 | @pyqtSlot("PyQt_PyObject") # @pyqtSlot(object) 도 가능.. 105 | def recive_instance_singal(self, inst): 106 | print(inst.name) 107 | 108 | 109 | if __name__ == "__main__": 110 | import sys 111 | 112 | app = QApplication(sys.argv) 113 | form = MyMain() 114 | app.exec_() 115 | -------------------------------------------------------------------------------- /type/article_parser.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, NamedTuple 2 | 3 | # 갤러리 타입으로 사용할 타입 4 | GallaryType = Literal['mgallery/board', 'board'] 5 | 6 | # 글 파싱 함수 article_parse()에서 반환할 Article 타입 7 | 8 | 9 | class Article(NamedTuple): 10 | num: str 11 | title: str 12 | reply: str 13 | nickname: str 14 | timestamp: str 15 | refresh: str 16 | recommend: str 17 | 18 | 19 | class Page(NamedTuple): 20 | start: int 21 | end: int 22 | next_pos: str 23 | isArticle: bool 24 | 25 | 26 | class SaveData(NamedTuple): 27 | repeat: str 28 | gallary_id: str 29 | keyword: str 30 | search_type: str 31 | -------------------------------------------------------------------------------- /ui.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Form implementation generated from reading ui file 'main.ui' 4 | # 5 | # Created by: PyQt5 UI code generator 5.15.6 6 | # 7 | # WARNING: Any manual changes made to this file will be lost when pyuic5 is 8 | # run again. Do not edit this file unless you know what you are doing. 9 | 10 | 11 | from PyQt5 import QtCore, QtGui, QtWidgets 12 | 13 | 14 | class Ui_MainWindow(object): 15 | def setupUi(self, MainWindow): 16 | MainWindow.setObjectName("MainWindow") 17 | MainWindow.resize(812, 802) 18 | icon = QtGui.QIcon() 19 | icon.addPixmap(QtGui.QPixmap("main.ico"), 20 | QtGui.QIcon.Normal, QtGui.QIcon.Off) 21 | MainWindow.setWindowIcon(icon) 22 | MainWindow.setStyleSheet("QLineEdit {\n" 23 | " border: 4px solid #3B4890;\n" 24 | " padding: 4px;\n" 25 | "}\n" 26 | "\n" 27 | "QLineEdit:focus{\n" 28 | " border: 4px solid rgb(0, 170, 255);\n" 29 | "}\n" 30 | "\n" 31 | "QPushButton {\n" 32 | " background-color: #3b4890;\n" 33 | " min-width: 5em;\n" 34 | " padding: 8px;\n" 35 | " color:white;\n" 36 | "\n" 37 | " border-style: outset;\n" 38 | " border-width: 2px;\n" 39 | " border-radius : 5px;\n" 40 | "\n" 41 | " border-color: beige;\n" 42 | "}\n" 43 | "\n" 44 | "QPushButton:pressed {\n" 45 | " background-color: #29367C;\n" 46 | " border-style: inset;\n" 47 | "}\n" 48 | "\n" 49 | "QComboBox {\n" 50 | " border: 4px solid #3B4890;\n" 51 | " padding: 4px;\n" 52 | "}\n" 53 | "\n" 54 | "QComboBox::drop-down \n" 55 | "{\n" 56 | " border: 0px;\n" 57 | "}\n" 58 | "\n" 59 | "QComboBox::down-arrow {\n" 60 | " image: url(git.png);\n" 61 | " width: 28px;\n" 62 | " height: 28px;\n" 63 | "}") 64 | self.centralwidget = QtWidgets.QWidget(MainWindow) 65 | self.centralwidget.setObjectName("centralwidget") 66 | self.gridLayout = QtWidgets.QGridLayout(self.centralwidget) 67 | self.gridLayout.setObjectName("gridLayout") 68 | self.verticalLayout = QtWidgets.QVBoxLayout() 69 | self.verticalLayout.setObjectName("verticalLayout") 70 | self.horizontalLayout = QtWidgets.QHBoxLayout() 71 | self.horizontalLayout.setSizeConstraint( 72 | QtWidgets.QLayout.SetDefaultConstraint) 73 | self.horizontalLayout.setSpacing(2) 74 | self.horizontalLayout.setObjectName("horizontalLayout") 75 | self.comboBox = QtWidgets.QComboBox(self.centralwidget) 76 | font = QtGui.QFont() 77 | font.setFamily("맑은 고딕") 78 | font.setPointSize(10) 79 | self.comboBox.setFont(font) 80 | self.comboBox.setObjectName("comboBox") 81 | self.comboBox.addItem("") 82 | self.comboBox.addItem("") 83 | self.comboBox.addItem("") 84 | self.comboBox.addItem("") 85 | self.comboBox.addItem("") 86 | self.horizontalLayout.addWidget(self.comboBox) 87 | self.txt_repeat = QtWidgets.QLineEdit(self.centralwidget) 88 | font = QtGui.QFont() 89 | font.setFamily("맑은 고딕") 90 | font.setPointSize(10) 91 | self.txt_repeat.setFont(font) 92 | self.txt_repeat.setToolTip("") 93 | self.txt_repeat.setStyleSheet("#searchBox {\n" 94 | " border: 4px solid #3b4890;\n" 95 | "}") 96 | self.txt_repeat.setObjectName("txt_repeat") 97 | self.horizontalLayout.addWidget(self.txt_repeat) 98 | self.txt_id = QtWidgets.QLineEdit(self.centralwidget) 99 | font = QtGui.QFont() 100 | font.setFamily("맑은 고딕") 101 | font.setPointSize(10) 102 | self.txt_id.setFont(font) 103 | self.txt_id.setToolTip("") 104 | self.txt_id.setStyleSheet("#searchBox {\n" 105 | " border: 4px solid #3b4890;\n" 106 | "}") 107 | self.txt_id.setObjectName("txt_id") 108 | self.horizontalLayout.addWidget(self.txt_id) 109 | self.txt_keyword = QtWidgets.QLineEdit(self.centralwidget) 110 | font = QtGui.QFont() 111 | font.setFamily("맑은 고딕") 112 | font.setPointSize(10) 113 | self.txt_keyword.setFont(font) 114 | self.txt_keyword.setStyleSheet("") 115 | self.txt_keyword.setObjectName("txt_keyword") 116 | self.horizontalLayout.addWidget(self.txt_keyword) 117 | self.btn_Search = QtWidgets.QPushButton(self.centralwidget) 118 | font = QtGui.QFont() 119 | font.setFamily("나눔고딕") 120 | font.setBold(False) 121 | font.setWeight(50) 122 | self.btn_Search.setFont(font) 123 | self.btn_Search.setObjectName("btn_Search") 124 | self.horizontalLayout.addWidget(self.btn_Search) 125 | self.horizontalLayout.setStretch(0, 3) 126 | self.horizontalLayout.setStretch(1, 2) 127 | self.horizontalLayout.setStretch(2, 3) 128 | self.horizontalLayout.setStretch(3, 10) 129 | self.verticalLayout.addLayout(self.horizontalLayout) 130 | self.articleView = QtWidgets.QTableWidget(self.centralwidget) 131 | self.articleView.setStyleSheet("QLabel#label{\n" 132 | "color : rgb(85, 85, 255)\n" 133 | "}") 134 | self.articleView.setShowGrid(True) 135 | self.articleView.setGridStyle(QtCore.Qt.SolidLine) 136 | self.articleView.setObjectName("articleView") 137 | self.articleView.setColumnCount(7) 138 | self.articleView.setRowCount(0) 139 | item = QtWidgets.QTableWidgetItem() 140 | self.articleView.setHorizontalHeaderItem(0, item) 141 | item = QtWidgets.QTableWidgetItem() 142 | self.articleView.setHorizontalHeaderItem(1, item) 143 | item = QtWidgets.QTableWidgetItem() 144 | self.articleView.setHorizontalHeaderItem(2, item) 145 | item = QtWidgets.QTableWidgetItem() 146 | self.articleView.setHorizontalHeaderItem(3, item) 147 | item = QtWidgets.QTableWidgetItem() 148 | self.articleView.setHorizontalHeaderItem(4, item) 149 | item = QtWidgets.QTableWidgetItem() 150 | self.articleView.setHorizontalHeaderItem(5, item) 151 | item = QtWidgets.QTableWidgetItem() 152 | self.articleView.setHorizontalHeaderItem(6, item) 153 | self.verticalLayout.addWidget(self.articleView) 154 | self.horizontalLayout_2 = QtWidgets.QHBoxLayout() 155 | self.horizontalLayout_2.setObjectName("horizontalLayout_2") 156 | self.label = QtWidgets.QLabel(self.centralwidget) 157 | font = QtGui.QFont() 158 | font.setFamily("나눔고딕") 159 | font.setBold(True) 160 | font.setItalic(True) 161 | font.setWeight(75) 162 | self.label.setFont(font) 163 | self.label.setStyleSheet("QLabel#label{\n" 164 | "color : rgb(85, 85, 255)\n" 165 | "}") 166 | self.label.setObjectName("label") 167 | self.horizontalLayout_2.addWidget(self.label) 168 | self.txt_status = QtWidgets.QLabel(self.centralwidget) 169 | font = QtGui.QFont() 170 | font.setFamily("나눔고딕") 171 | font.setPointSize(10) 172 | self.txt_status.setFont(font) 173 | self.txt_status.setAlignment( 174 | QtCore.Qt.AlignRight | QtCore.Qt.AlignTrailing | QtCore.Qt.AlignVCenter) 175 | self.txt_status.setObjectName("txt_status") 176 | self.horizontalLayout_2.addWidget(self.txt_status) 177 | self.verticalLayout.addLayout(self.horizontalLayout_2) 178 | self.gridLayout.addLayout(self.verticalLayout, 0, 0, 1, 1) 179 | MainWindow.setCentralWidget(self.centralwidget) 180 | self.statusbar = QtWidgets.QStatusBar(MainWindow) 181 | self.statusbar.setObjectName("statusbar") 182 | MainWindow.setStatusBar(self.statusbar) 183 | 184 | self.retranslateUi(MainWindow) 185 | self.btn_Search.clicked.connect(MainWindow.search) # type: ignore 186 | self.articleView.itemDoubleClicked['QTableWidgetItem*'].connect( 187 | MainWindow.item_dbl_click) # type: ignore 188 | self.txt_keyword.returnPressed.connect( 189 | MainWindow.search) # type: ignore 190 | self.txt_id.returnPressed.connect(MainWindow.search) # type: ignore 191 | self.txt_repeat.returnPressed.connect( 192 | MainWindow.search) # type: ignore 193 | QtCore.QMetaObject.connectSlotsByName(MainWindow) 194 | 195 | def retranslateUi(self, MainWindow): 196 | _translate = QtCore.QCoreApplication.translate 197 | MainWindow.setWindowTitle(_translate( 198 | "MainWindow", "DCINSIDE ARTICLE FINDER v0.152 Beta")) 199 | self.comboBox.setItemText(0, _translate("MainWindow", "제목+내용")) 200 | self.comboBox.setItemText(1, _translate("MainWindow", "제목")) 201 | self.comboBox.setItemText(2, _translate("MainWindow", "내용")) 202 | self.comboBox.setItemText(3, _translate("MainWindow", "글쓴이")) 203 | self.comboBox.setItemText(4, _translate("MainWindow", "댓글")) 204 | self.txt_repeat.setPlaceholderText(_translate("MainWindow", "탐색 횟수")) 205 | self.txt_id.setPlaceholderText(_translate("MainWindow", "갤러리 ID")) 206 | self.txt_keyword.setPlaceholderText(_translate("MainWindow", "검색어")) 207 | self.btn_Search.setText(_translate("MainWindow", "검색")) 208 | self.articleView.setSortingEnabled(True) 209 | item = self.articleView.horizontalHeaderItem(0) 210 | item.setText(_translate("MainWindow", "번호")) 211 | item = self.articleView.horizontalHeaderItem(1) 212 | item.setText(_translate("MainWindow", "제목")) 213 | item = self.articleView.horizontalHeaderItem(2) 214 | item.setText(_translate("MainWindow", "댓글수")) 215 | item = self.articleView.horizontalHeaderItem(3) 216 | item.setText(_translate("MainWindow", "글쓴이")) 217 | item = self.articleView.horizontalHeaderItem(4) 218 | item.setText(_translate("MainWindow", "작성일")) 219 | item = self.articleView.horizontalHeaderItem(5) 220 | item.setText(_translate("MainWindow", "조회")) 221 | item = self.articleView.horizontalHeaderItem(6) 222 | item.setText(_translate("MainWindow", "추천")) 223 | self.label.setText(_translate( 224 | "MainWindow", "Copyright 2022. File(pgh268400@naver.com) all rights reserved.")) 225 | self.txt_status.setText(_translate("MainWindow", "상태 : IDLE")) 226 | 227 | 228 | if __name__ == "__main__": 229 | import sys 230 | app = QtWidgets.QApplication(sys.argv) 231 | MainWindow = QtWidgets.QMainWindow() 232 | ui = Ui_MainWindow() 233 | ui.setupUi(MainWindow) 234 | MainWindow.show() 235 | sys.exit(app.exec_()) 236 | --------------------------------------------------------------------------------