├── .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 | 
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 |
--------------------------------------------------------------------------------