.
675 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # CLAPI
2 | A basic API to scrape Craigslist.
3 |
4 | Most useful for viewing posts across a broad geographic area or for viewing posts within a specific timeframe.
5 |
6 |
7 |
8 | Requirements:
9 |
10 | - bs4 (BeautifulSoup)
11 | - shutil
12 | - requests
13 | - datetime
14 | - PyQt5
15 | - subprocess
16 |
17 | Note: All of these packages should be available from standard distributions, such as Anaconda.
18 |
19 |
20 |
21 | Typical Use Case:
22 |
23 |
24 | from CLAPI import CraigsList
25 | cities = []
26 | for state in ['AL', 'AK', 'AZ', 'AR']:
27 | cities += CraigsList.GetCitiesByState(state)
28 | hours = float(input('Posts in the last x hours >> '))/24
29 | query = input('Query >> ')
30 | for city in cities:
31 | print('Parsing %s...' % city)
32 | cl = CraigsList(city, query, CraigsList.SORT_RELEVANT, lookback=hours)
33 | posts += cl.posts
34 | CraigsList.OpenViewer(posts, maxImgs=3)
35 |
36 |
37 | The above example scrapes the posts during the lookback period for every city with a Craiglist in the specified states. These posts are presented to the user in a simple PyQt5 GUI for rapid browsing. The user can quickly open the associated post webpage or post location via buttons on the GUI.
38 |
39 | Note: if you use a browser other than chrome, you will want to modify the subprocess call in the MainWindowHandlers.py file such that you call the appropriate browser.
40 |
41 |
42 |
43 | Be aware, this program will create a temporary directory within your current working directory, called 'tmp' in which the Craigslist thumbnail images are downloaded. When the program exits without errors, this temporary directory will be deleted.
44 |
--------------------------------------------------------------------------------
/__init__.py:
--------------------------------------------------------------------------------
1 | from .craigslist import CraigsList
2 |
3 |
4 | __version__ = (1, 0)
--------------------------------------------------------------------------------
/craigslist.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | """
3 | Created on Sat May 1 12:13:59 2021
4 |
5 | @author: The Absolute Tinkerer
6 | """
7 |
8 | import os
9 | import sys
10 | import math
11 | import shutil
12 | import requests
13 |
14 | from bs4 import BeautifulSoup
15 | from datetime import datetime, timedelta
16 |
17 | from .viewer import MainWindowHandlers
18 |
19 | from PyQt5.QtWidgets import QApplication
20 |
21 |
22 | class Post:
23 | GPS = 'https://www.google.com/maps/search/%s,%s' # lat, long
24 |
25 | def __init__(self, city, recordSoup):
26 | """
27 | Constructor
28 |
29 | Parameters:
30 | -----------
31 | city : str
32 | The post city (the city from the craigslist url)
33 | recordSoup : BeautifulSoup
34 | The html loaded into a BeautifulSoup instance for a particular
35 | post. The post section must have originated from the Craigslist
36 | search results page
37 | """
38 | # Collect the post id, datetime, post url, post title, price, "hood",
39 | # image urls, and google maps url of this post
40 | pid = recordSoup['data-pid']
41 | dt = recordSoup.findAll('time', {'class': 'result-date'})[0]
42 | dt = datetime.strptime(dt['datetime'], '%Y-%m-%d %H:%M')
43 | url = recordSoup.findAll('a', {'class': 'result-title hdrlnk'})[0]
44 | text = url.text
45 | url = url['href']
46 |
47 | price = recordSoup.findAll('span', {'class': 'result-meta'})[0]
48 | hood = price.findAll('span', {'class': 'result-hood'})
49 | price = price.findAll('span', {'class': 'result-price'})
50 | # sometimes there is no price
51 | if len(price) == 0:
52 | price = 0
53 | else:
54 | price = int(price[0].text.replace('$', '').replace(',', ''))
55 | # sometimes there is no hood
56 | if len(hood) == 0:
57 | hood = city
58 | else:
59 | hood = hood[0].text.strip()[1:-1]
60 |
61 | # Sometimes there are no pictures
62 | imgs = recordSoup.findAll('a', {'class': 'result-image gallery'})
63 | if len(imgs) == 0:
64 | self._img_urls = []
65 | else:
66 | base_url = CraigsList.IMG_URL
67 | items = imgs[0]['data-ids'].split(',')
68 | self._img_urls = [base_url % item.split(':')[1] for item in items]
69 |
70 | # Bind to class variables
71 | self._pid = pid
72 | self._dt = dt
73 | self._url = url
74 | self._text = text
75 | self._price = price
76 | self._city = city
77 | self._hood = hood
78 | # Since this requires a separate request, only get if the user calls
79 | # the property
80 | self._gps = None
81 |
82 | """
83 | ##########################################################################
84 | Public Functions
85 | ##########################################################################
86 | """
87 | def downloadImages(self, folder, fmt='jpg', maxImgs=-1):
88 | """
89 | Public function used to download the images for this post. All images
90 | are 300x300 pixels (CL's thumbnail size).
91 |
92 | Parameters:
93 | -----------
94 | folder : str
95 | The output folder
96 | fmt : str
97 | The output format
98 | maxImgs : int
99 | -1 if you want all images, otherwise specify the maximum allowed
100 | images to download
101 | """
102 | for i, url in enumerate(self.image_urls):
103 | if maxImgs != -1 and i >= maxImgs:
104 | break
105 | r = requests.get(url, stream=True)
106 | if r.status_code == 200:
107 | fname = '%s/%s_%s.%s' % (folder, self.pid, i, fmt)
108 | with open(fname, 'wb') as f:
109 | r.raw.decode_content = True
110 | shutil.copyfileobj(r.raw, f)
111 |
112 | """
113 | ##########################################################################
114 | Properties
115 | ##########################################################################
116 | """
117 | @property
118 | def pid(self):
119 | return self._pid
120 |
121 | @property
122 | def dt(self):
123 | return self._dt
124 |
125 | @property
126 | def url(self):
127 | return self._url
128 |
129 | @property
130 | def image_urls(self):
131 | return self._img_urls
132 |
133 | @property
134 | def text(self):
135 | return self._text
136 |
137 | @property
138 | def price(self):
139 | return self._price
140 |
141 | @property
142 | def city(self):
143 | return self._city
144 |
145 | @property
146 | def hood(self):
147 | return self._hood
148 |
149 | @property
150 | def gps(self):
151 | if self._gps is None:
152 | self._gps = self._getGPSLocation()
153 | return self._gps
154 |
155 | """
156 | ##########################################################################
157 | Private Functions
158 | ##########################################################################
159 | """
160 | def _getGPSLocation(self):
161 | """
162 | Private function used to retrieve the google maps url to this post
163 | """
164 | # Make our request
165 | params = {}
166 | headers = {'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64'}
167 | response = requests.get(url=self.url,
168 | headers=headers,
169 | params=params)
170 |
171 | # If there was an error, a non-200 status code is thrown
172 | if response.status_code != 200:
173 | s = 'Failed to fetch requested data with status code: %s'
174 | raise Exception(s % response.status_code)
175 |
176 | # Parse with BeautifulSoup
177 | soup = BeautifulSoup(response.content, 'html.parser')
178 |
179 | # Get lat & long
180 | ll = soup.findAll('div', {'class': 'viewposting'})[0]
181 |
182 | return self.GPS % (ll['data-latitude'], ll['data-longitude'])
183 |
184 | """
185 | ##########################################################################
186 | Built-In Functions
187 | ##########################################################################
188 | """
189 | def __str__(self):
190 | return '%s:%s: %s | $%s' % (self.city, self.hood,
191 | self.text, self.price)
192 |
193 |
194 | class CraigsList:
195 | BASE_URL = 'https://%s.craigslist.org'
196 | GEO_URL = 'https://geo.craigslist.org/iso/us/%s'
197 | URL = 'https://%s.craigslist.org/d/for-sale/search/sss?query=%s&sort=%s'
198 | IMG_URL = 'https://images.craigslist.org/%s_300x300.jpg'
199 | SORT_RELEVANT = 'rel'
200 | SORT_ASCENDING = 'priceasc'
201 | SORT_DECENDING = 'pricedsc'
202 | NUM_PAGE_RECORDS = 120
203 |
204 | def __init__(self, city, query, sortby, lookback=-1):
205 | """
206 | Constructor
207 |
208 | Parameters:
209 | -----------
210 | city : str
211 | The city you're getting posts from. Must be a valid token for a
212 | CraigsList url
213 | query : str
214 | The query string you want to search
215 | sortby : str
216 | Either of SORT_RELEVANT, SORT_ASCENDING, or SORT_DECENDING
217 | lookback : float
218 | The number of days in the past you want to get posts for. -1 if
219 | you want all posts
220 | """
221 | query = '+'.join([requests.utils.quote(q) for q in query.split(' ')])
222 |
223 | # Bind to class variables
224 | self._city = city.lower().replace(' ', '')
225 | self._query = query
226 | self._sortby = sortby
227 | self._lookback = lookback
228 | self._posts = []
229 | self._start = datetime.now()
230 |
231 | # Make our query
232 | self._makeQuery()
233 |
234 | """
235 | ##########################################################################
236 | Properties
237 | ##########################################################################
238 | """
239 | @property
240 | def city(self):
241 | return self._city
242 |
243 | @property
244 | def query(self):
245 | return self._query
246 |
247 | @property
248 | def sortby(self):
249 | return self._sortby
250 |
251 | @property
252 | def lookback(self):
253 | return self._lookback
254 |
255 | @property
256 | def posts(self):
257 | return self._posts
258 |
259 | """
260 | ##########################################################################
261 | Private Functions
262 | ##########################################################################
263 | """
264 | @staticmethod
265 | def _getSoup(url, params, headers):
266 | """
267 | Private static function used to get the soup from a specific request
268 |
269 | Parameters:
270 | -----------
271 | url : str
272 | The url the request is being made to
273 | params : dict
274 | The parameters you want to pass in
275 | headers : dict
276 | The associated headers for the request
277 |
278 | Returns:
279 | --------
280 | : BeautfulSoup
281 | The soup from the reponse's html
282 | """
283 | # Make our request
284 | response = requests.get(url=url,
285 | headers=headers,
286 | params=params)
287 |
288 | # If there was an error, a non-200 status code is thrown
289 | if response.status_code != 200:
290 | s = 'Failed to fetch requested data with status code: %s'
291 | raise Exception(s % response.status_code)
292 |
293 | # Parse with BeautifulSoup
294 | return BeautifulSoup(response.content, 'html.parser')
295 |
296 | def _makeQuery(self):
297 | """
298 | Private function used to make the specific query to CraigsList and
299 | put all posts into the list of Post objects (self._posts)
300 | """
301 | # Build the url, parameters, and headers for the initial request
302 | url = self.URL % (self.city, self.query, self.sortby)
303 | params = {'sort': self.sortby,
304 | 'query': self.query}
305 | headers = {'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64'}
306 |
307 | # Get the total record count
308 | soup = self._getSoup(url, params, headers)
309 | n_recs = soup.findAll('span', {'class', 'totalcount'})
310 |
311 | # Not always going to have a response - this means there are zero
312 | # posts
313 | if len(n_recs) > 0:
314 | n_recs = int(n_recs[0].text)
315 | else:
316 | return
317 |
318 | # Initialize the data object with the starting page
319 | for item in soup.findAll('li', {'class', 'result-row'}):
320 | post = Post(self.city, item)
321 | if(self._lookback != -1 and
322 | post.dt < self._start - timedelta(days=self._lookback)):
323 | return
324 | self._posts.append(post)
325 |
326 | # Get records after the first page
327 | for i in range(math.ceil(n_recs/self.NUM_PAGE_RECORDS)-1):
328 | params['s'] = str((i+1)*self.NUM_PAGE_RECORDS)
329 | soup = self._getSoup(url, params, headers)
330 | for item in soup.findAll('li', {'class': 'result-row'}):
331 | post = Post(self.city, item)
332 | if(self._lookback != -1 and
333 | post.dt < self._start - timedelta(days=self._lookback)):
334 | return
335 | self._posts.append(post)
336 |
337 | """
338 | ##########################################################################
339 | Static Functions
340 | ##########################################################################
341 | """
342 | @staticmethod
343 | def OpenViewer(posts, maxImgs=-1):
344 | """
345 | Static function to open a local GUI to view annotated versions of
346 | posts. The post images will be downloaded to a local, temporary
347 | directory and discarded when the viewer is closed
348 |
349 | Parameters:
350 | -----------
351 | posts : list
352 | List of Post objects
353 | maxImgs : int
354 | The maximum number of images per post you want to download. -1 if
355 | you want all images.
356 | """
357 | folder = 'tmp'
358 | def saveImages():
359 | if os.path.exists(folder):
360 | shutil.rmtree(folder)
361 | os.mkdir(folder)
362 |
363 | for j, post in enumerate(posts):
364 | print('Post (%s/%s) images downloading.' % (
365 | j+1, len(posts)))
366 | post.downloadImages(folder, maxImgs=maxImgs)
367 | def rmImages():
368 | if os.path.exists(folder):
369 | shutil.rmtree(folder)
370 |
371 | # Download all images
372 | saveImages()
373 |
374 | # Initialize the application
375 | app = QApplication(sys.argv)
376 | window = MainWindowHandlers()
377 |
378 | # Now call the initialize function on this window
379 | window.initialize(posts)
380 | window.show()
381 | app.exec_()
382 |
383 | # Remove all images
384 | rmImages()
385 |
386 | @staticmethod
387 | def GetNearbyCities(city):
388 | """
389 | Static function used to retrieve a list of nearby cities to the
390 | provided city
391 |
392 | Parameters:
393 | -----------
394 | city : str
395 | The city name in the CraigsList url format
396 |
397 | Returns:
398 | --------
399 | cities : list
400 | A list of city names in string form, in the Craigslist url format
401 | """
402 | # Build the url, parameters, and headers
403 | url = CraigsList.BASE_URL % city
404 | params = {}
405 | headers = {'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64'}
406 |
407 | # Get the soup
408 | soup = CraigsList._getSoup(url, params, headers)
409 |
410 | # Get all nearby URLs
411 | cities = []
412 | group = soup.findAll('ul', {'class': 'acitem'})[0]
413 | for sub_item in group.findAll('li', {'class': 's'}):
414 | link = sub_item.findAll('a')[0]['href']
415 | cities.append(link.replace('/', '').split('.')[0])
416 |
417 | return cities
418 |
419 | @staticmethod
420 | def GetCitiesByState(state):
421 | """
422 | Get the cities with CraigsList from the provided state, in two-letter
423 | postal-code form
424 |
425 | Parameters:
426 | -----------
427 | state : str
428 | The postal-code format of the state of interest
429 |
430 | Returns:
431 | --------
432 | cities : list
433 | A list of city names in string form, in the Craigslist url format
434 | """
435 | # Build the url, parameters, and headers
436 | url = CraigsList.GEO_URL % state.lower()
437 | params = {}
438 | headers = {'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64'}
439 |
440 | # Get the soup
441 | soup = CraigsList._getSoup(url, params, headers)
442 |
443 | # Get all cities
444 | cities = []
445 | group = soup.findAll('ul', {'class': 'geo-site-list'})[0]
446 | for sub_item in group.findAll('a'):
447 | link = sub_item['href']
448 | cities.append(link.replace('https://', '').split('.')[0])
449 |
450 | return cities
451 |
--------------------------------------------------------------------------------
/sample.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Absolute-Tinkerer/CLAPI/f4ca7c58bc643baca01335cbbae65c0943b4cbaf/sample.png
--------------------------------------------------------------------------------
/viewer/MainWindowUI.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | # Form implementation generated from reading ui file 'mainwindow.ui'
4 | #
5 | # Created by: PyQt5 UI code generator 5.9.2
6 | #
7 | # WARNING! All changes made in this file will be lost!
8 |
9 | from PyQt5 import QtCore, QtGui, QtWidgets
10 |
11 | class Ui_MainWindow(object):
12 | def setupUi(self, MainWindow):
13 | MainWindow.setObjectName("MainWindow")
14 | MainWindow.resize(681, 519)
15 | self.centralwidget = QtWidgets.QWidget(MainWindow)
16 | self.centralwidget.setObjectName("centralwidget")
17 | self.horizontalLayout = QtWidgets.QHBoxLayout(self.centralwidget)
18 | self.horizontalLayout.setObjectName("horizontalLayout")
19 | self.widget = QtWidgets.QWidget(self.centralwidget)
20 | self.widget.setObjectName("widget")
21 | self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.widget)
22 | self.verticalLayout_2.setObjectName("verticalLayout_2")
23 | self.widget_5 = QtWidgets.QWidget(self.widget)
24 | self.widget_5.setObjectName("widget_5")
25 | self.horizontalLayout_4 = QtWidgets.QHBoxLayout(self.widget_5)
26 | self.horizontalLayout_4.setContentsMargins(0, 0, 0, 0)
27 | self.horizontalLayout_4.setObjectName("horizontalLayout_4")
28 | spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum)
29 | self.horizontalLayout_4.addItem(spacerItem)
30 | self.imgLabel = QtWidgets.QLabel(self.widget_5)
31 | self.imgLabel.setMinimumSize(QtCore.QSize(300, 300))
32 | self.imgLabel.setMaximumSize(QtCore.QSize(300, 300))
33 | self.imgLabel.setText("")
34 | self.imgLabel.setObjectName("imgLabel")
35 | self.horizontalLayout_4.addWidget(self.imgLabel)
36 | spacerItem1 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum)
37 | self.horizontalLayout_4.addItem(spacerItem1)
38 | self.verticalLayout_2.addWidget(self.widget_5)
39 | self.imgnumLabel = QtWidgets.QLabel(self.widget)
40 | self.imgnumLabel.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter)
41 | self.imgnumLabel.setObjectName("imgnumLabel")
42 | self.verticalLayout_2.addWidget(self.imgnumLabel)
43 | self.widget_4 = QtWidgets.QWidget(self.widget)
44 | self.widget_4.setObjectName("widget_4")
45 | self.horizontalLayout_3 = QtWidgets.QHBoxLayout(self.widget_4)
46 | self.horizontalLayout_3.setObjectName("horizontalLayout_3")
47 | spacerItem2 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum)
48 | self.horizontalLayout_3.addItem(spacerItem2)
49 | self.prevImgBtn = QtWidgets.QPushButton(self.widget_4)
50 | self.prevImgBtn.setMinimumSize(QtCore.QSize(20, 0))
51 | self.prevImgBtn.setMaximumSize(QtCore.QSize(20, 16777215))
52 | self.prevImgBtn.setObjectName("prevImgBtn")
53 | self.horizontalLayout_3.addWidget(self.prevImgBtn)
54 | self.nextImgBtn = QtWidgets.QPushButton(self.widget_4)
55 | self.nextImgBtn.setMinimumSize(QtCore.QSize(20, 0))
56 | self.nextImgBtn.setMaximumSize(QtCore.QSize(20, 16777215))
57 | self.nextImgBtn.setObjectName("nextImgBtn")
58 | self.horizontalLayout_3.addWidget(self.nextImgBtn)
59 | spacerItem3 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum)
60 | self.horizontalLayout_3.addItem(spacerItem3)
61 | self.verticalLayout_2.addWidget(self.widget_4)
62 | spacerItem4 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding)
63 | self.verticalLayout_2.addItem(spacerItem4)
64 | self.horizontalLayout.addWidget(self.widget)
65 | self.line = QtWidgets.QFrame(self.centralwidget)
66 | self.line.setFrameShape(QtWidgets.QFrame.VLine)
67 | self.line.setFrameShadow(QtWidgets.QFrame.Sunken)
68 | self.line.setObjectName("line")
69 | self.horizontalLayout.addWidget(self.line)
70 | self.widget_2 = QtWidgets.QWidget(self.centralwidget)
71 | self.widget_2.setObjectName("widget_2")
72 | self.verticalLayout = QtWidgets.QVBoxLayout(self.widget_2)
73 | self.verticalLayout.setObjectName("verticalLayout")
74 | self.postTextEdit = QtWidgets.QTextEdit(self.widget_2)
75 | self.postTextEdit.setMinimumSize(QtCore.QSize(300, 400))
76 | self.postTextEdit.setMaximumSize(QtCore.QSize(300, 16777215))
77 | self.postTextEdit.setObjectName("postTextEdit")
78 | self.verticalLayout.addWidget(self.postTextEdit)
79 | self.widget_6 = QtWidgets.QWidget(self.widget_2)
80 | self.widget_6.setObjectName("widget_6")
81 | self.horizontalLayout_5 = QtWidgets.QHBoxLayout(self.widget_6)
82 | self.horizontalLayout_5.setContentsMargins(0, 0, 0, 0)
83 | self.horizontalLayout_5.setObjectName("horizontalLayout_5")
84 | spacerItem5 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum)
85 | self.horizontalLayout_5.addItem(spacerItem5)
86 | self.postBtn = QtWidgets.QPushButton(self.widget_6)
87 | self.postBtn.setObjectName("postBtn")
88 | self.horizontalLayout_5.addWidget(self.postBtn)
89 | self.mapBtn = QtWidgets.QPushButton(self.widget_6)
90 | self.mapBtn.setObjectName("mapBtn")
91 | self.horizontalLayout_5.addWidget(self.mapBtn)
92 | self.verticalLayout.addWidget(self.widget_6)
93 | spacerItem6 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding)
94 | self.verticalLayout.addItem(spacerItem6)
95 | self.recnumLabel = QtWidgets.QLabel(self.widget_2)
96 | self.recnumLabel.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter)
97 | self.recnumLabel.setObjectName("recnumLabel")
98 | self.verticalLayout.addWidget(self.recnumLabel)
99 | self.widget_3 = QtWidgets.QWidget(self.widget_2)
100 | self.widget_3.setObjectName("widget_3")
101 | self.horizontalLayout_2 = QtWidgets.QHBoxLayout(self.widget_3)
102 | self.horizontalLayout_2.setContentsMargins(0, 0, 0, 0)
103 | self.horizontalLayout_2.setObjectName("horizontalLayout_2")
104 | spacerItem7 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum)
105 | self.horizontalLayout_2.addItem(spacerItem7)
106 | self.previousButton = QtWidgets.QPushButton(self.widget_3)
107 | self.previousButton.setObjectName("previousButton")
108 | self.horizontalLayout_2.addWidget(self.previousButton)
109 | self.nextButton = QtWidgets.QPushButton(self.widget_3)
110 | self.nextButton.setObjectName("nextButton")
111 | self.horizontalLayout_2.addWidget(self.nextButton)
112 | spacerItem8 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum)
113 | self.horizontalLayout_2.addItem(spacerItem8)
114 | self.verticalLayout.addWidget(self.widget_3)
115 | self.horizontalLayout.addWidget(self.widget_2)
116 | MainWindow.setCentralWidget(self.centralwidget)
117 |
118 | self.retranslateUi(MainWindow)
119 | self.previousButton.clicked.connect(MainWindow.previousPost)
120 | self.nextButton.clicked.connect(MainWindow.nextPost)
121 | self.prevImgBtn.clicked.connect(MainWindow.previousImage)
122 | self.nextImgBtn.clicked.connect(MainWindow.nextImage)
123 | self.postBtn.clicked.connect(MainWindow.openPost)
124 | self.mapBtn.clicked.connect(MainWindow.openMap)
125 | QtCore.QMetaObject.connectSlotsByName(MainWindow)
126 |
127 | def retranslateUi(self, MainWindow):
128 | _translate = QtCore.QCoreApplication.translate
129 | MainWindow.setWindowTitle(_translate("MainWindow", "Post Viewer"))
130 | self.imgnumLabel.setText(_translate("MainWindow", "(xx/xx)"))
131 | self.prevImgBtn.setText(_translate("MainWindow", "<"))
132 | self.nextImgBtn.setText(_translate("MainWindow", ">"))
133 | self.postBtn.setText(_translate("MainWindow", "Open Post"))
134 | self.mapBtn.setText(_translate("MainWindow", "Open Map"))
135 | self.recnumLabel.setText(_translate("MainWindow", "(xx/xx)"))
136 | self.previousButton.setText(_translate("MainWindow", "<< Previous"))
137 | self.nextButton.setText(_translate("MainWindow", "Next >>"))
138 |
139 |
--------------------------------------------------------------------------------
/viewer/__init__.py:
--------------------------------------------------------------------------------
1 | from .mainwindowhandlers import MainWindowHandlers
--------------------------------------------------------------------------------
/viewer/createPYfromUI.bat:
--------------------------------------------------------------------------------
1 | @ECHO on
2 | pyuic5 mainwindow.ui -o MainWindowUI.py
3 | PAUSE
--------------------------------------------------------------------------------
/viewer/mainwindow.ui:
--------------------------------------------------------------------------------
1 |
2 |
3 | MainWindow
4 |
5 |
6 |
7 | 0
8 | 0
9 | 681
10 | 519
11 |
12 |
13 |
14 | Post Viewer
15 |
16 |
17 |
18 | -
19 |
20 |
21 |
-
22 |
23 |
24 |
25 | 0
26 |
27 |
28 | 0
29 |
30 |
31 | 0
32 |
33 |
34 | 0
35 |
36 |
-
37 |
38 |
39 | Qt::Horizontal
40 |
41 |
42 |
43 | 40
44 | 20
45 |
46 |
47 |
48 |
49 | -
50 |
51 |
52 |
53 | 300
54 | 300
55 |
56 |
57 |
58 |
59 | 300
60 | 300
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 | -
69 |
70 |
71 | Qt::Horizontal
72 |
73 |
74 |
75 | 40
76 | 20
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 | -
85 |
86 |
87 | (xx/xx)
88 |
89 |
90 | Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
91 |
92 |
93 |
94 | -
95 |
96 |
97 |
-
98 |
99 |
100 | Qt::Horizontal
101 |
102 |
103 |
104 | 40
105 | 20
106 |
107 |
108 |
109 |
110 | -
111 |
112 |
113 |
114 | 20
115 | 0
116 |
117 |
118 |
119 |
120 | 20
121 | 16777215
122 |
123 |
124 |
125 | <
126 |
127 |
128 |
129 | -
130 |
131 |
132 |
133 | 20
134 | 0
135 |
136 |
137 |
138 |
139 | 20
140 | 16777215
141 |
142 |
143 |
144 | >
145 |
146 |
147 |
148 | -
149 |
150 |
151 | Qt::Horizontal
152 |
153 |
154 |
155 | 40
156 | 20
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 | -
165 |
166 |
167 | Qt::Vertical
168 |
169 |
170 |
171 | 20
172 | 40
173 |
174 |
175 |
176 |
177 |
178 |
179 |
180 | -
181 |
182 |
183 | Qt::Vertical
184 |
185 |
186 |
187 | -
188 |
189 |
190 |
-
191 |
192 |
193 |
194 | 300
195 | 400
196 |
197 |
198 |
199 |
200 | 300
201 | 16777215
202 |
203 |
204 |
205 |
206 | -
207 |
208 |
209 |
210 | 0
211 |
212 |
213 | 0
214 |
215 |
216 | 0
217 |
218 |
219 | 0
220 |
221 |
-
222 |
223 |
224 | Qt::Horizontal
225 |
226 |
227 |
228 | 40
229 | 20
230 |
231 |
232 |
233 |
234 | -
235 |
236 |
237 | Open Post
238 |
239 |
240 |
241 | -
242 |
243 |
244 | Open Map
245 |
246 |
247 |
248 |
249 |
250 |
251 | -
252 |
253 |
254 | Qt::Vertical
255 |
256 |
257 |
258 | 20
259 | 40
260 |
261 |
262 |
263 |
264 | -
265 |
266 |
267 | (xx/xx)
268 |
269 |
270 | Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
271 |
272 |
273 |
274 | -
275 |
276 |
277 |
278 | 0
279 |
280 |
281 | 0
282 |
283 |
284 | 0
285 |
286 |
287 | 0
288 |
289 |
-
290 |
291 |
292 | Qt::Horizontal
293 |
294 |
295 |
296 | 40
297 | 20
298 |
299 |
300 |
301 |
302 | -
303 |
304 |
305 | << Previous
306 |
307 |
308 |
309 | -
310 |
311 |
312 | Next >>
313 |
314 |
315 |
316 | -
317 |
318 |
319 | Qt::Horizontal
320 |
321 |
322 |
323 | 40
324 | 20
325 |
326 |
327 |
328 |
329 |
330 |
331 |
332 |
333 |
334 |
335 |
336 |
337 |
338 |
339 |
340 |
341 | previousButton
342 | clicked()
343 | MainWindow
344 | previousPost()
345 |
346 |
347 | 452
348 | 488
349 |
350 |
351 | 674
352 | 301
353 |
354 |
355 |
356 |
357 | nextButton
358 | clicked()
359 | MainWindow
360 | nextPost()
361 |
362 |
363 | 541
364 | 484
365 |
366 |
367 | 677
368 | 336
369 |
370 |
371 |
372 |
373 | prevImgBtn
374 | clicked()
375 | MainWindow
376 | previousImage()
377 |
378 |
379 | 160
380 | 365
381 |
382 |
383 | 673
384 | 150
385 |
386 |
387 |
388 |
389 | nextImgBtn
390 | clicked()
391 | MainWindow
392 | nextImage()
393 |
394 |
395 | 186
396 | 364
397 |
398 |
399 | 675
400 | 191
401 |
402 |
403 |
404 |
405 | postBtn
406 | clicked()
407 | MainWindow
408 | openPost()
409 |
410 |
411 | 554
412 | 433
413 |
414 |
415 | 675
416 | 234
417 |
418 |
419 |
420 |
421 | mapBtn
422 | clicked()
423 | MainWindow
424 | openMap()
425 |
426 |
427 | 617
428 | 437
429 |
430 |
431 | 673
432 | 276
433 |
434 |
435 |
436 |
437 |
438 | nextImage()
439 | previousImage()
440 | nextPost()
441 | previousPost()
442 | openPost()
443 | openMap()
444 |
445 |
446 |
--------------------------------------------------------------------------------
/viewer/mainwindowhandlers.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | """
3 | Created on Sat May 1 18:36:09 2021
4 |
5 | @author: The Absolute Tinkerer
6 | """
7 |
8 | import os
9 | import sys
10 | import subprocess
11 |
12 | from PyQt5.QtWidgets import QMainWindow, QLabel, QTextEdit, QPushButton
13 | from PyQt5.QtGui import QPixmap
14 |
15 | from .MainWindowUI import Ui_MainWindow
16 |
17 |
18 | class MainWindowHandlers(QMainWindow):
19 | def __init__(self, *args, **kwargs):
20 | super(MainWindowHandlers, self).__init__(*args, **kwargs)
21 |
22 | ui = Ui_MainWindow()
23 | ui.setupUi(self)
24 |
25 | def initialize(self, posts):
26 | self._posts = posts
27 |
28 | self._textfield = self.findChild(QTextEdit, 'postTextEdit')
29 | self._recnumLabel = self.findChild(QLabel, 'recnumLabel')
30 | self._imgnumLabel = self.findChild(QLabel, 'imgnumLabel')
31 | self._imgLabel = self.findChild(QLabel, 'imgLabel')
32 | self._pimgBtn = self.findChild(QPushButton, 'prevImgBtn')
33 | self._nimgBtn = self.findChild(QPushButton, 'nextImgBtn')
34 |
35 | # Post and image index and count
36 | self._pidx = 0
37 | self._iidx = 0
38 |
39 | # Load the first post
40 | if len(self._posts) > 0:
41 | self._loadPost(self._posts[self._pidx])
42 | else:
43 | raise Exception('No posts are available for this query')
44 |
45 | def nextImage(self):
46 | post = self._posts[self._pidx]
47 | self._iidx = (self._iidx + 1) % len(post.image_urls)
48 | self._setImage('tmp/%s_%s.jpg' % (post.pid, self._iidx))
49 |
50 | def nextPost(self):
51 | self._pidx = (self._pidx + 1) % len(self._posts)
52 | self._iidx = 0
53 | self._loadPost(self._posts[self._pidx])
54 |
55 | def previousImage(self):
56 | post = self._posts[self._pidx]
57 | self._iidx = (self._iidx - 1) % len(post.image_urls)
58 | self._setImage('tmp/%s_%s.jpg' % (post.pid, self._iidx))
59 |
60 | def previousPost(self):
61 | self._pidx = (self._pidx - 1) % len(self._posts)
62 | self._iidx = 0
63 | self._loadPost(self._posts[self._pidx])
64 |
65 | def openPost(self):
66 | post = self._posts[self._pidx]
67 | subprocess.call('chrome.exe %s -incognito --new-window' % post.url)
68 |
69 | def openMap(self):
70 | post = self._posts[self._pidx]
71 | subprocess.call('chrome.exe %s -incognito --new-window' % post.gps)
72 |
73 | def _setImage(self, path=None):
74 | if path is None:
75 | path = os.path.abspath(
76 | sys.modules[MainWindowHandlers.__module__].__file__)
77 | path = os.path.abspath(os.path.join(path, '..', 'placeholder.jpg'))
78 | else:
79 | if not os.path.exists(path):
80 | path = os.path.abspath(
81 | sys.modules[MainWindowHandlers.__module__].__file__)
82 | path = os.path.abspath(os.path.join(path, '..', 'online.jpg'))
83 | self._imgnumLabel.setText('(%+3s/%+3s)' % (
84 | self._iidx+1, len(self._posts[self._pidx].image_urls)))
85 |
86 | pixmap = QPixmap(path)
87 | self._imgLabel.setPixmap(pixmap)
88 |
89 | def _loadPost(self, post):
90 | # Load the image
91 | if len(post.image_urls) > 0:
92 | self._setImage('tmp/%s_0.jpg' % post.pid)
93 | self._pimgBtn.setEnabled(True)
94 | self._nimgBtn.setEnabled(True)
95 | else:
96 | self._setImage()
97 | self._pimgBtn.setEnabled(False)
98 | self._nimgBtn.setEnabled(False)
99 |
100 | # Set the text label values
101 | self._recnumLabel.setText('(%+3s/%+3s)' % (
102 | self._pidx+1, len(self._posts)))
103 |
104 | # Set the text field contents
105 | s = '%s\n' % post.hood
106 | s += '%s\n' % post.text
107 | s += '$%s\n' % post.price
108 | s += '\nReference URL: %s' % post.url
109 | self._textfield.setPlainText(s)
110 |
--------------------------------------------------------------------------------
/viewer/online.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Absolute-Tinkerer/CLAPI/f4ca7c58bc643baca01335cbbae65c0943b4cbaf/viewer/online.jpg
--------------------------------------------------------------------------------
/viewer/placeholder.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Absolute-Tinkerer/CLAPI/f4ca7c58bc643baca01335cbbae65c0943b4cbaf/viewer/placeholder.jpg
--------------------------------------------------------------------------------