├── .gitignore ├── LICENSE ├── MANIFEST.in ├── NOTICE ├── README.rst ├── pyhn ├── __init__.py ├── cachemanager.py ├── config.py ├── gui.py ├── hnapi.py ├── poller.py └── popup.py ├── requirements.txt ├── screenshot.png ├── scripts └── pyhn ├── setup.py └── tests ├── comments.data ├── get_comments.py └── treesample.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-info 3 | build 4 | dist 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 toxinu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE README.rst NOTICE pyhn/lib/requests/cacert.pem -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Beautifulsoup4 License 2 | ====================== 3 | 4 | Beautiful Soup is made available under the MIT license: 5 | 6 | Copyright (c) 2004-2012 Leonard Richardson 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining 9 | a copy of this software and associated documentation files (the 10 | "Software"), to deal in the Software without restriction, including 11 | without limitation the rights to use, copy, modify, merge, publish, 12 | distribute, sublicense, and/or sell copies of the Software, and to 13 | permit persons to whom the Software is furnished to do so, subject to 14 | the following conditions: 15 | 16 | The above copyright notice and this permission notice shall be 17 | included in all copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 20 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 21 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 22 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS 23 | BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 24 | ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 25 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | SOFTWARE, DAMMIT. 27 | 28 | Beautiful Soup incorporates code from the html5lib library, which is 29 | also made available under the MIT license. 30 | 31 | Requests License 32 | ================ 33 | 34 | Copyright 2015 Kenneth Reitz 35 | 36 | Licensed under the Apache License, Version 2.0 (the "License"); 37 | you may not use this file except in compliance with the License. 38 | You may obtain a copy of the License at 39 | 40 | http://www.apache.org/licenses/LICENSE-2.0 41 | 42 | Unless required by applicable law or agreed to in writing, software 43 | distributed under the License is distributed on an "AS IS" BASIS, 44 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 45 | See the License for the specific language governing permissions and 46 | limitations under the License. 47 | 48 | Urllib3 License 49 | =============== 50 | 51 | This is the MIT license: http://www.opensource.org/licenses/mit-license.php 52 | 53 | Copyright 2008-2011 Andrey Petrov and contributors (see CONTRIBUTORS.txt), 54 | Modifications copyright 2012 Kenneth Reitz. 55 | 56 | Permission is hereby granted, free of charge, to any person obtaining 57 | a copy of this software and associated documentation files (the 58 | "Software"), to deal in the Software without restriction, including 59 | without limitation the rights to use, copy, modify, merge, publish, 60 | distribute, sublicense, and/or sell copies of the Software, and to 61 | permit persons to whom the Software is furnished to do so, subject to 62 | the following conditions: 63 | 64 | The above copyright notice and this permission notice shall be 65 | included in all copies or substantial portions of the Software. 66 | 67 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 68 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 69 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 70 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 71 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 72 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 73 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 74 | 75 | Chardet License 76 | =============== 77 | 78 | This library is free software; you can redistribute it and/or 79 | modify it under the terms of the GNU Lesser General Public 80 | License as published by the Free Software Foundation; either 81 | version 2.1 of the License, or (at your option) any later version. 82 | 83 | This library is distributed in the hope that it will be useful, 84 | but WITHOUT ANY WARRANTY; without even the implied warranty of 85 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 86 | Lesser General Public License for more details. 87 | 88 | You should have received a copy of the GNU Lesser General Public 89 | License along with this library; if not, write to the Free Software 90 | Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 91 | 02110-1301 USA 92 | 93 | 94 | CA Bundle License 95 | ================= 96 | 97 | This Source Code Form is subject to the terms of the Mozilla Public 98 | License, v. 2.0. If a copy of the MPL was not distributed with this 99 | file, You can obtain one at http://mozilla.org/MPL/2.0/. -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Pyhn 2 | ==== 3 | 4 | Hacker news in your terminal. 5 | 6 | .. image:: https://raw.github.com/socketubs/pyhn/master/screenshot.png 7 | 8 | Don't be worry about your IP. Pyhn is not aggressive, it uses cache. 9 | 10 | * ``Cache manager`` 11 | * ``Customize all the colors`` 12 | * ``Customize all the keybinds`` 13 | * ``Default vim-like keybindings`` 14 | * ``Compatible with Top, Ask, Show and Job stories`` 15 | * ``Auto refresh support`` 16 | * ``Play nice with tmux and screen (over ssh too!)`` 17 | * ``Open storiers in your commandline web browser`` 18 | * ``Mouse support`` 19 | * ``Easily installable`` 20 | * ``Easily hackable`` 21 | * ``Ultra fast`` 22 | * ``Python 2 and 3`` 23 | * ``MIT license`` 24 | 25 | Installation 26 | ------------ 27 | 28 | Using pip: :: 29 | 30 | pip install pyhn 31 | 32 | Run it: :: 33 | 34 | pyhn 35 | 36 | Arch Linux 37 | ~~~~~~~~~~ 38 | 39 | ``pyhn`` is available in the AUR_. 40 | 41 | Usage 42 | ----- 43 | 44 | Use help for all key bindings: 45 | 46 | * **h**, **?**: Print help popup 47 | 48 | Configuration 49 | ------------- 50 | 51 | By default, configuration file is in your ``$HOME/.pyhn/config``. 52 | You can set key bindings, colors and more. 53 | 54 | This is an example file: :: 55 | 56 | [keybindings] 57 | open_story_link = S,enter 58 | show_story_link = s 59 | open_comments_link = C 60 | show_comments_link = c 61 | open_user_link = U 62 | show_user_link = u 63 | up = j 64 | down = k 65 | page_up = ctrl d 66 | page_down = ctrl u 67 | first_story = g 68 | last_story = G 69 | refresh = r,R 70 | reload_config = ctrl r,ctrl R 71 | 72 | newest_stories = n 73 | top_stories = t 74 | best_stories = b 75 | show_stories = d 76 | show_newest_stories = D 77 | ask_stories = a 78 | jobs_stories = J 79 | 80 | [settings] 81 | cache = /home/socketubs/.pyhn/cache 82 | cache_age = 5 83 | # Refresh interval in minutes (default: 5. minimum: 1) 84 | refresh_interval = 5 85 | browser_cmd = __default__ 86 | 87 | [colors] 88 | body = default| 89 | focus = white,bold|dark cyan 90 | footer = black|light gray 91 | footer-error = dark red,bold|light gray 92 | header = black,bold|light gray 93 | title = dark red,bold|light gray 94 | help = black,standout|dark cyan 95 | 96 | 97 | Settings 98 | ~~~~~~~~ 99 | 100 | * ``cache_age`` is a minute indicator which say to ``CacheManager`` when cache is outdated 101 | * ``browser_cmd`` is a bash command which will be use to open links 102 | 103 | Examples: :: 104 | 105 | browser_cmd = lynx __url__ 106 | browser_cmd = __default__ 107 | browser_cmd = w3m __url__ 108 | browser_cmd = echo "[INFO] Open with w3m: __url__" >> /tmp/pyhn.log && w3m __url__ 109 | 110 | Key bindings 111 | ~~~~~~~~~~~~ 112 | 113 | You can set different key bindings for same action with a comma separator. 114 | Take a look at ``urwid`` `input`_ manual. 115 | 116 | Colors 117 | ~~~~~~ 118 | 119 | Colors options are designed like that: ``foreground|background|monochrome``. 120 | 121 | **foreground** 122 | 123 | * *colors*: ‘default’ (use the terminal’s default foreground), ‘black’, ‘dark red’, ‘dark green’, ‘brown’, ‘dark blue’, ‘dark magenta’, ‘dark cyan’, ‘light gray’, ‘dark gray’, ‘light red’, ‘light green’, ‘yellow’, ‘light blue’, ‘light magenta’, ‘light cyan’, ‘white’ 124 | * *settings*: ‘bold’, ‘underline’, ‘blink’, ‘standout’ 125 | 126 | **background** 127 | 128 | * *colors*: ‘default’ (use the terminal’s default background), ‘black’, ‘dark red’, ‘dark green’, ‘brown’, ‘dark blue’, ‘dark magenta’, ‘dark cyan’, ‘light gray’ 129 | 130 | **monochrome** 131 | 132 | * *settings* : ‘bold’, ‘underline’, ‘blink’, ‘standout’ 133 | 134 | For more informations you can take a look at ``urwid`` `manual`_. 135 | 136 | License 137 | ------- 138 | 139 | License is `MIT`_. See `LICENSE`_. 140 | 141 | .. _AUR: https://aur.archlinux.org/packages/pyhn/ 142 | .. _input: http://urwid.org/manual/userinput.html#keyboard-input 143 | .. _manual: http://urwid.org/manual/displayattributes.html#foreground-and-background-settings 144 | .. _MIT: http://opensource.org/licenses/MIT 145 | .. _LICENSE: https://raw.github.com/socketubs/pyhn/master/LICENSE 146 | -------------------------------------------------------------------------------- /pyhn/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | __title__ = 'pyhn' 4 | __version__ = '0.3.12' 5 | __author__ = 'toxinu' 6 | __license__ = 'MIT' 7 | __copyright__ = 'Copyright 2020 toxinu' 8 | -------------------------------------------------------------------------------- /pyhn/cachemanager.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import pickle 4 | import datetime 5 | 6 | from pyhn.config import Config 7 | from pyhn.hnapi import HackerNewsAPI 8 | 9 | 10 | class CacheManager(object): 11 | def __init__(self, cache_path=None): 12 | self.cache_path = cache_path 13 | if cache_path is None: 14 | self.config = Config() 15 | self.cache_path = self.config.parser.get('settings', 'cache') 16 | 17 | self.cache_age = int(self.config.parser.get('settings', 'cache_age')) 18 | self.extra_page = int(self.config.parser.get('settings', 'extra_page')) 19 | self.api = HackerNewsAPI() 20 | 21 | if not os.path.exists(self.cache_path): 22 | self.refresh() 23 | 24 | def is_outdated(self, which="top"): 25 | if not os.path.exists(self.cache_path): 26 | return True 27 | 28 | try: 29 | cache = pickle.load(open(self.cache_path, 'rb')) 30 | except: 31 | cache = {} 32 | if not cache.get(which, False): 33 | return True 34 | 35 | cache_age = datetime.datetime.today() - cache[which]['date'] 36 | if cache_age.seconds > self.cache_age * 60: 37 | return True 38 | else: 39 | return False 40 | 41 | def refresh(self, which="top"): 42 | if which == "top": 43 | stories = self.api.get_top_stories(extra_page=self.extra_page) 44 | elif which == "newest": 45 | stories = self.api.get_newest_stories(extra_page=self.extra_page) 46 | elif which == "best": 47 | stories = self.api.get_best_stories(extra_page=self.extra_page) 48 | elif which == "show": 49 | stories = self.api.get_show_stories(extra_page=self.extra_page) 50 | elif which == "show_newest": 51 | stories = self.api.get_show_newest_stories( 52 | extra_page=self.extra_page) 53 | elif which == "ask": 54 | stories = self.api.get_ask_stories(extra_page=self.extra_page) 55 | elif which == "jobs": 56 | stories = self.api.get_jobs_stories(extra_page=self.extra_page) 57 | else: 58 | raise Exception( 59 | 'Bad value: top, newest, ask, jobs,' 60 | 'show, shownewest and best stories') 61 | 62 | cache = {} 63 | if os.path.exists(self.cache_path): 64 | try: 65 | cache = pickle.load(open(self.cache_path, 'rb')) 66 | except: 67 | pass 68 | 69 | cache[which] = {'stories': stories, 'date': datetime.datetime.today()} 70 | pickle.dump(cache, open(self.cache_path, 'wb')) 71 | 72 | def get_stories(self, which="top"): 73 | cache = [] 74 | if os.path.exists(self.cache_path): 75 | try: 76 | cache = pickle.load(open(self.cache_path, 'rb')) 77 | except: 78 | cache = {} 79 | 80 | if not cache.get(which, False): 81 | return [] 82 | else: 83 | return cache[which]['stories'] 84 | -------------------------------------------------------------------------------- /pyhn/config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | 4 | try: 5 | from configparser import SafeConfigParser 6 | except ImportError: 7 | from ConfigParser import SafeConfigParser 8 | 9 | 10 | TRUE_WORDS = ['true', 'True', 'yes', '1'] 11 | FALSE_WORDS = ['false', 'False', 'no', 0] 12 | 13 | 14 | class Config(object): 15 | def __init__(self, config_dir=None, config_file=None): 16 | self.config_dir = config_dir 17 | self.config_file = config_file 18 | 19 | if config_dir is None: 20 | self.config_dir = os.path.join( 21 | os.environ.get('HOME', './'), 22 | '.pyhn') 23 | if config_file is None: 24 | self.config_file = "config" 25 | 26 | if not os.path.exists(self.config_dir): 27 | os.makedirs(self.config_dir) 28 | 29 | self.config_path = os.path.join(self.config_dir, self.config_file) 30 | 31 | self.parser = SafeConfigParser() 32 | self.read() 33 | 34 | def read(self): 35 | self.parser.read(self.config_path) 36 | 37 | # Keybindings 38 | if not self.parser.has_section('keybindings'): 39 | self.parser.add_section('keybindings') 40 | 41 | if not self.parser.has_option('keybindings', 'page_up'): 42 | self.parser.set('keybindings', 'page_up', 'ctrl u') 43 | if not self.parser.has_option('keybindings', 'page_down'): 44 | self.parser.set('keybindings', 'page_down', 'ctrl d') 45 | if not self.parser.has_option('keybindings', 'first_story'): 46 | self.parser.set('keybindings', 'first_story', 'g') 47 | if not self.parser.has_option('keybindings', 'last_story'): 48 | self.parser.set('keybindings', 'last_story', 'G') 49 | if not self.parser.has_option('keybindings', 'up'): 50 | self.parser.set('keybindings', 'up', 'j') 51 | if not self.parser.has_option('keybindings', 'down'): 52 | self.parser.set('keybindings', 'down', 'k') 53 | if not self.parser.has_option('keybindings', 'refresh'): 54 | self.parser.set('keybindings', 'refresh', 'r') 55 | 56 | if not self.parser.has_option('keybindings', 'show_comments_link'): 57 | self.parser.set('keybindings', 'show_comments_link', 'c') 58 | if not self.parser.has_option('keybindings', 'open_comments_link'): 59 | self.parser.set('keybindings', 'open_comments_link', 'C') 60 | if not self.parser.has_option('keybindings', 'show_story_link'): 61 | self.parser.set('keybindings', 'show_story_link', 's') 62 | if not self.parser.has_option('keybindings', 'open_story_link'): 63 | self.parser.set('keybindings', 'open_story_link', 'S,enter') 64 | if not self.parser.has_option('keybindings', 'show_submitter_link'): 65 | self.parser.set('keybindings', 'show_submitter_link', 'u') 66 | if not self.parser.has_option('keybindings', 'open_submitter_link'): 67 | self.parser.set('keybindings', 'open_submitter_link', 'U') 68 | 69 | if not self.parser.has_option('keybindings', 'reload_config'): 70 | self.parser.set('keybindings', 'reload_config', 'ctrl R') 71 | 72 | if not self.parser.has_option('keybindings', 'newest_stories'): 73 | self.parser.set('keybindings', 'newest_stories', 'n') 74 | if not self.parser.has_option('keybindings', 'top_stories'): 75 | self.parser.set('keybindings', 'top_stories', 't') 76 | if not self.parser.has_option('keybindings', 'best_stories'): 77 | self.parser.set('keybindings', 'best_stories', 'b') 78 | if not self.parser.has_option('keybindings', 'show_stories'): 79 | self.parser.set('keybindings', 'show_stories', 'd') 80 | if not self.parser.has_option('keybindings', 'show_newest_stories'): 81 | self.parser.set('keybindings', 'show_newest_stories', 'D') 82 | if not self.parser.has_option('keybindings', 'ask_stories'): 83 | self.parser.set('keybindings', 'ask_stories', 'a') 84 | if not self.parser.has_option('keybindings', 'jobs_stories'): 85 | self.parser.set('keybindings', 'jobs_stories', 'J') 86 | # Interface 87 | if not self.parser.has_section('interface'): 88 | self.parser.add_section('interface') 89 | if not self.parser.has_option('interface', 'show_score'): 90 | self.parser.set('interface', 'show_score', 'true') 91 | if not self.parser.has_option('interface', 'show_comments'): 92 | self.parser.set('interface', 'show_comments', 'true') 93 | if not self.parser.has_option('interface', 'show_published_time'): 94 | self.parser.set('interface', 'show_published_time', 'false') 95 | # Paths 96 | if not self.parser.has_section('settings'): 97 | self.parser.add_section('settings') 98 | 99 | if not self.parser.has_option('settings', 'extra_page'): 100 | self.parser.set('settings', 'extra_page', '2') 101 | 102 | if not self.parser.has_option('settings', 'cache'): 103 | self.parser.set( 104 | 'settings', 105 | 'cache', 106 | os.path.join(os.environ.get('HOME', './'), '.pyhn', 'cache')) 107 | if not self.parser.has_option('settings', 'cache_age'): 108 | self.parser.set('settings', 'cache_age', "5") 109 | if not self.parser.has_option('settings', 'browser_cmd'): 110 | self.parser.set('settings', 'browser_cmd', '__default__') 111 | 112 | if not self.parser.has_option('settings', 'refresh_interval'): 113 | self.parser.set('settings', 'refresh_interval', '5') 114 | 115 | # Colors 116 | if not self.parser.has_section('colors'): 117 | self.parser.add_section('colors') 118 | 119 | if not self.parser.has_option('colors', 'body'): 120 | self.parser.set('colors', 'body', 'default||standout') 121 | if not self.parser.has_option('colors', 'focus'): 122 | self.parser.set('colors', 'focus', 'yellow,bold||underline') 123 | if not self.parser.has_option('colors', 'footer'): 124 | self.parser.set('colors', 'footer', 'black|light gray') 125 | if not self.parser.has_option('colors', 'footer-error'): 126 | self.parser.set( 127 | 'colors', 'footer-error', 'dark red,bold|light gray') 128 | if not self.parser.has_option('colors', 'header'): 129 | self.parser.set('colors', 'header', 'dark gray,bold|white|') 130 | if not self.parser.has_option('colors', 'title'): 131 | self.parser.set('colors', 'title', 'dark red,bold|light gray') 132 | if not self.parser.has_option('colors', 'help'): 133 | self.parser.set('colors', 'help', 'black|dark cyan|standout') 134 | 135 | if not os.path.exists(self.config_path): 136 | self.parser.write(open(self.config_path, 'w')) 137 | 138 | def get_palette(self): 139 | palette = [] 140 | for item in self.parser.items('colors'): 141 | name = item[0] 142 | settings = item[1] 143 | foreground = "" 144 | background = "" 145 | monochrome = "" 146 | if len(settings.split('|')) == 3: 147 | foreground = settings.split('|')[0] 148 | background = settings.split('|')[1] 149 | monochrome = settings.split('|')[2] 150 | elif len(settings.split('|')) == 2: 151 | foreground = settings.split('|')[0] 152 | background = settings.split('|')[1] 153 | elif len(settings.split('|')) == 1: 154 | foreground = settings.split('|')[0] 155 | 156 | palette.append((name, foreground, background, monochrome)) 157 | return palette 158 | -------------------------------------------------------------------------------- /pyhn/gui.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import sys 3 | import urwid 4 | import subprocess 5 | import threading 6 | 7 | from pyhn.popup import Popup 8 | from pyhn.poller import Poller 9 | from pyhn.config import Config, FALSE_WORDS, TRUE_WORDS 10 | from pyhn import __version__ 11 | 12 | PY3 = False 13 | if sys.version_info.major == 3: 14 | PY3 = True 15 | 16 | if PY3: 17 | from urllib.parse import urlparse 18 | else: 19 | from urlparse import urlparse 20 | 21 | 22 | class ItemWidget(urwid.WidgetWrap): 23 | """ Widget of listbox, represent each story """ 24 | def __init__(self, story, show_published_time, show_score, show_comments): 25 | self.story = story 26 | self.number = story.number 27 | self.title = story.title.encode('utf-8') 28 | self.url = story.url 29 | self.domain = urlparse(story.domain).netloc 30 | self.submitter = story.submitter 31 | self.submitter_url = story.submitter_url 32 | self.comment_count = story.comment_count 33 | self.comments_url = story.comments_url 34 | self.score = story.score 35 | self.published_time = story.published_time 36 | self.show_published_time = show_published_time 37 | self.show_score = show_score 38 | self.show_comments = show_comments 39 | 40 | if self.number is None: 41 | number_text = '-' 42 | number_align = 'center' 43 | self.number = '-' 44 | else: 45 | number_align = 'right' 46 | number_text = '%s:' % self.number 47 | 48 | if self.submitter is None: 49 | self.submitter = None 50 | self.submitter_url = None 51 | 52 | if self.score is None: 53 | self.score = "-" 54 | 55 | if self.comment_count is None: 56 | comment_text = '-' 57 | self.comment_count = None 58 | self.comments_url = None 59 | else: 60 | comment_text = '%s' % self.comment_count 61 | 62 | title = self.title 63 | try: 64 | title = title.encode('latin') 65 | except: 66 | pass 67 | 68 | self.item = [ 69 | ('fixed', 4, urwid.Padding(urwid.AttrWrap( 70 | urwid.Text(number_text, align=number_align), 71 | 'body', 'focus'))), 72 | urwid.AttrWrap( 73 | urwid.Text(title), 'body', 'focus'), 74 | ] 75 | if self.show_published_time: 76 | self.item.append( 77 | ('fixed', 15, urwid.Padding(urwid.AttrWrap( 78 | urwid.Text(str(self.published_time), align="right"), 'body', 'focus'))), 79 | ) 80 | if self.show_score: 81 | self.item.append( 82 | ('fixed', 5, urwid.Padding(urwid.AttrWrap( 83 | urwid.Text(str(self.score), align="right"), 'body', 'focus'))), 84 | ) 85 | if self.show_comments: 86 | self.item.append( 87 | ('fixed', 8, urwid.Padding(urwid.AttrWrap( 88 | urwid.Text(comment_text, align="right"), 89 | 'body', 'focus'))) 90 | ) 91 | w = urwid.Columns(self.item, focus_column=1, dividechars=1) 92 | self.__super.__init__(w) 93 | 94 | def selectable(self): 95 | return True 96 | 97 | def keypress(self, size, key): 98 | return key 99 | 100 | 101 | class HNGui(object): 102 | """ The Pyhn Gui object """ 103 | def __init__(self, cache_manager): 104 | self.cache_manager = cache_manager 105 | self.already_build = False 106 | self.on_comments = False 107 | self.which = "top" 108 | 109 | self.config = Config() 110 | self.poller = Poller( 111 | self, delay=int( 112 | self.config.parser.get('settings', 'refresh_interval'))) 113 | self.palette = self.config.get_palette() 114 | self.show_comments = self.config.parser.get('interface', 'show_comments') in TRUE_WORDS 115 | self.show_score = self.config.parser.get('interface', 'show_score') in TRUE_WORDS 116 | self.show_published_time = self.config.parser.get( 117 | 'interface', 'show_published_time') in TRUE_WORDS 118 | 119 | def main(self): 120 | """ 121 | Main Gui function which create Ui object, 122 | build interface and run the loop 123 | """ 124 | self.ui = urwid.raw_display.Screen() 125 | self.ui.register_palette(self.palette) 126 | self.build_interface() 127 | self.ui.run_wrapper(self.run) 128 | 129 | def build_help(self): 130 | """ Fetch all key bindings and build help message """ 131 | self.bindings = {} 132 | self.help_msg = [] 133 | self.help_msg.append( 134 | urwid.AttrWrap(urwid.Text('\n Key bindings \n'), 'title')) 135 | self.help_msg.append(urwid.AttrWrap(urwid.Text(''), 'help')) 136 | for binding in self.config.parser.items('keybindings'): 137 | self.bindings[binding[0]] = binding[1] 138 | line = urwid.AttrWrap( 139 | urwid.Text( 140 | ' %s: %s ' % (binding[1], binding[0].replace('_', ' '))), 141 | 'help') 142 | self.help_msg.append(line) 143 | self.help_msg.append(urwid.AttrWrap( 144 | urwid.Text(' ctrl mouse-left: open story link'), 'help')) 145 | self.help_msg.append(urwid.AttrWrap(urwid.Text(''), 'help')) 146 | self.help_msg.append(urwid.AttrWrap( 147 | urwid.Text( 148 | ' Thanks for using Pyhn %s! ' % __version__, align='center'), 149 | 'title')) 150 | self.help_msg.append(urwid.AttrWrap(urwid.Text(''), 'help')) 151 | self.help_msg.append( 152 | urwid.AttrWrap(urwid.Text( 153 | ' Author : toxinu'), 'help')) 154 | self.help_msg.append(urwid.AttrWrap( 155 | urwid.Text(' Code : https://github.com/toxinu/pyhn '), 156 | 'help')) 157 | self.help_msg.append(urwid.AttrWrap( 158 | urwid.Text(' Website: http://toxinu.github.io '), 159 | 'help')) 160 | self.help_msg.append(urwid.AttrWrap(urwid.Text(''), 'help')) 161 | self.help_msg.append(urwid.AttrWrap(urwid.Text(''), 'help')) 162 | self.help_msg.append(urwid.AttrWrap(urwid.Text(''), 'help')) 163 | 164 | self.help = Popup(self.help_msg, ('help', 'help'), (0, 1), self.view) 165 | 166 | def build_interface(self): 167 | """ 168 | Build interface, refresh cache if needed, update stories listbox, 169 | create header, footer, view and the loop. 170 | """ 171 | if self.cache_manager.is_outdated(): 172 | self.cache_manager.refresh() 173 | 174 | self.stories = self.cache_manager.get_stories() 175 | self.update_stories(self.stories) 176 | self.header_content = [ 177 | ('fixed', 4, urwid.Padding( 178 | urwid.AttrWrap(urwid.Text(' N°'), 'header'))), 179 | urwid.AttrWrap(urwid.Text('TOP STORIES', align="center"), 'title'), 180 | ] 181 | if self.show_published_time: 182 | self.header_content.append( 183 | ('fixed', 15, urwid.Padding( 184 | urwid.AttrWrap(urwid.Text('PUBLISHED TIME'), 'header'))), 185 | ) 186 | if self.show_score: 187 | self.header_content.append( 188 | ('fixed', 5, urwid.Padding( 189 | urwid.AttrWrap(urwid.Text('SCORE'), 'header'))), 190 | ) 191 | if self.show_comments: 192 | self.header_content.append( 193 | ('fixed', 8, urwid.Padding( 194 | urwid.AttrWrap(urwid.Text('COMMENTS'), 'header'))) 195 | ) 196 | self.header = urwid.Columns(self.header_content, dividechars=1) 197 | self.footer = urwid.AttrMap( 198 | urwid.Text( 199 | 'Welcome in pyhn by toxinu ' 200 | '(https://github.com/toxinu/pyhn)', align='center'), 201 | 'footer') 202 | 203 | self.view = urwid.Frame( 204 | urwid.AttrWrap( 205 | self.listbox, 'body'), header=self.header, footer=self.footer) 206 | self.loop = urwid.MainLoop( 207 | self.view, 208 | self.palette, 209 | screen=self.ui, 210 | handle_mouse=True, 211 | unhandled_input=self.keystroke) 212 | 213 | self.build_help() 214 | self.already_build = True 215 | 216 | def set_help(self): 217 | """ Set help msg in footer """ 218 | self.view.set_footer( 219 | urwid.AttrWrap(urwid.Text(self.help, align="center"), 'help')) 220 | 221 | def set_footer(self, msg, style="normal"): 222 | """ Set centered footer message """ 223 | if style == "normal": 224 | self.footer = urwid.AttrWrap(urwid.Text(msg), 'footer') 225 | self.view.set_footer(self.footer) 226 | elif style == "error": 227 | self.footer = urwid.AttrWrap(urwid.Text(msg), 'footer-error') 228 | self.view.set_footer(self.footer) 229 | 230 | def set_header(self, msg): 231 | """ Set header story message """ 232 | self.header_content[1] = urwid.AttrWrap( 233 | urwid.Text(msg, align="center"), 'title') 234 | self.view.set_header(urwid.Columns(self.header_content, dividechars=1)) 235 | 236 | def keystroke(self, input): 237 | """ All key bindings are computed here """ 238 | # QUIT 239 | if input in ('q', 'Q'): 240 | self.exit(must_raise=True) 241 | # LINKS 242 | if input in self.bindings['open_comments_link'].split(','): 243 | if not self.listbox.get_focus()[0].comments_url: 244 | self.set_footer('No comments') 245 | else: 246 | if not self.on_comments: 247 | self.show_comments(self.listbox.get_focus()[0]) 248 | self.on_comments = True 249 | else: 250 | self.update_stories( 251 | self.cache_manager.get_stories(self.which)) 252 | self.on_comments = False 253 | self.open_webbrowser(self.listbox.get_focus()[0].comments_url) 254 | if input in self.bindings['show_comments_link'].split(','): 255 | if not self.listbox.get_focus()[0].comments_url: 256 | self.set_footer('No comments') 257 | else: 258 | self.set_footer(self.listbox.get_focus()[0].comments_url) 259 | if input in self.bindings['open_story_link'].split(','): 260 | self.open_webbrowser(self.listbox.get_focus()[0].url) 261 | if input in self.bindings['show_story_link'].split(','): 262 | self.set_footer(self.listbox.get_focus()[0].url) 263 | if input in self.bindings['open_submitter_link'].split(','): 264 | if not self.listbox.get_focus()[0].submitter_url: 265 | self.set_footer('No submitter') 266 | else: 267 | self.open_webbrowser(self.listbox.get_focus()[0].submitter_url) 268 | if input in self.bindings['show_submitter_link'].split(','): 269 | if not self.listbox.get_focus()[0].submitter_url: 270 | self.set_footer('No submitter') 271 | else: 272 | self.set_footer(self.listbox.get_focus()[0].submitter_url) 273 | # MOVEMENTS 274 | if input in self.bindings['down'].split(','): 275 | if self.listbox.focus_position - 1 in self.walker.positions(): 276 | self.listbox.set_focus( 277 | self.walker.prev_position(self.listbox.focus_position)) 278 | if input in self.bindings['up'].split(','): 279 | if self.listbox.focus_position + 1 in self.walker.positions(): 280 | self.listbox.set_focus( 281 | self.walker.next_position(self.listbox.focus_position)) 282 | if input in self.bindings['page_up'].split(','): 283 | self.listbox._keypress_page_up(self.ui.get_cols_rows()) 284 | if input in self.bindings['page_down'].split(','): 285 | self.listbox._keypress_page_down(self.ui.get_cols_rows()) 286 | if input in self.bindings['first_story'].split(','): 287 | self.listbox.set_focus(self.walker.positions()[0]) 288 | if input in self.bindings['last_story'].split(','): 289 | self.listbox.set_focus(self.walker.positions()[-1]) 290 | # STORIES 291 | if input in self.bindings['newest_stories'].split(','): 292 | self.set_footer('Syncing newest stories...') 293 | threading.Thread( 294 | None, 295 | self.async_refresher, 296 | None, 297 | ('newest', 'NEWEST STORIES'), 298 | {}).start() 299 | if input in self.bindings['top_stories'].split(','): 300 | self.set_footer('Syncing top stories...') 301 | threading.Thread( 302 | None, self.async_refresher, 303 | None, ('top', 'TOP STORIES'), {}).start() 304 | if input in self.bindings['best_stories'].split(','): 305 | self.set_footer('Syncing best stories...') 306 | threading.Thread( 307 | None, self.async_refresher, 308 | None, ('best', 'BEST STORIES'), {}).start() 309 | if input in self.bindings['show_stories'].split(','): 310 | self.set_footer('Syncing show stories...') 311 | threading.Thread( 312 | None, self.async_refresher, 313 | None, ('show', 'SHOW STORIES'), {}).start() 314 | if input in self.bindings['show_newest_stories'].split(','): 315 | self.set_footer('Syncing show newest stories...') 316 | threading.Thread( 317 | None, 318 | self.async_refresher, 319 | None, 320 | ('show_newest', 'SHOW NEWEST STORIES'), 321 | {}).start() 322 | if input in self.bindings['ask_stories'].split(','): 323 | self.set_footer('Syncing ask stories...') 324 | threading.Thread( 325 | None, self.async_refresher, 326 | None, ('ask', 'ASK STORIES'), {}).start() 327 | if input in self.bindings['jobs_stories'].split(','): 328 | self.set_footer('Syncing jobs stories...') 329 | threading.Thread( 330 | None, self.async_refresher, 331 | None, ('jobs', 'JOBS STORIES'), {}).start() 332 | # OTHERS 333 | if input in self.bindings['refresh'].split(','): 334 | self.set_footer('Refreshing new stories...') 335 | threading.Thread( 336 | None, self.async_refresher, None, (), {'force': True}).start() 337 | if input in self.bindings['reload_config'].split(','): 338 | self.reload_config() 339 | if input in ('h', 'H', '?'): 340 | keys = True 341 | while True: 342 | if keys: 343 | self.ui.draw_screen( 344 | self.ui.get_cols_rows(), 345 | self.help.render(self.ui.get_cols_rows(), True)) 346 | keys = self.ui.get_input() 347 | if 'h' or 'H' or '?' or 'escape' in keys: 348 | break 349 | # MOUSE 350 | if len(input) > 1 and input[0] == 'ctrl mouse release': 351 | self.open_webbrowser(self.listbox.get_focus()[0].url) 352 | 353 | def async_refresher(self, which=None, header=None, force=False): 354 | if which is None: 355 | which = self.which 356 | if self.cache_manager.is_outdated(which) or force: 357 | self.cache_manager.refresh(which) 358 | stories = self.cache_manager.get_stories(which) 359 | self.update_stories(stories) 360 | if header is not None: 361 | self.set_header(header) 362 | self.which = which 363 | self.loop.draw_screen() 364 | 365 | def update_stories(self, stories): 366 | """ Reload listbox and walker with new stories """ 367 | items = [] 368 | item_ids = [] 369 | for story in stories: 370 | if story.id is not None and story.id in item_ids: 371 | story.title = "- %s" % story.title 372 | items.append(ItemWidget( 373 | story, 374 | self.show_published_time, 375 | self.show_score, 376 | self.show_comments)) 377 | else: 378 | items.append(ItemWidget( 379 | story, 380 | self.show_published_time, 381 | self.show_score, 382 | self.show_comments)) 383 | item_ids.append(story.id) 384 | 385 | if self.already_build: 386 | self.walker[:] = items 387 | self.update() 388 | else: 389 | self.walker = urwid.SimpleListWalker(items) 390 | self.listbox = urwid.ListBox(self.walker) 391 | 392 | def show_comments(self, story): 393 | pass 394 | 395 | def open_webbrowser(self, url): 396 | """ Handle url and open sub process with web browser """ 397 | if self.config.parser.get('settings', 'browser_cmd') == "__default__": 398 | python_bin = sys.executable 399 | subprocess.Popen( 400 | [python_bin, '-m', 'webbrowser', '-t', url], 401 | stdout=subprocess.PIPE, 402 | stderr=subprocess.PIPE) 403 | else: 404 | cmd = self.config.parser.get('settings', 'browser_cmd') 405 | try: 406 | p = subprocess.Popen( 407 | cmd.replace('__url__', url), 408 | shell=True, 409 | close_fds=True, 410 | stderr=subprocess.PIPE) 411 | 412 | returncode = p.wait() 413 | except KeyboardInterrupt: 414 | stderr = "User keyboard interrupt detected!" 415 | self.set_footer(stderr, style="error") 416 | return 417 | if returncode > 0: 418 | stderr = p.communicate()[1] 419 | self.set_footer("%s" % stderr, style="error") 420 | 421 | def update(self): 422 | """ Update footer about focus story """ 423 | focus = self.listbox.get_focus()[0] 424 | if not focus.submitter: 425 | msg = "submitted %s" % focus.published_time 426 | else: 427 | msg = "submitted %s by %s" % ( 428 | focus.published_time, focus.submitter) 429 | 430 | self.set_footer(msg) 431 | 432 | def reload_config(self): 433 | """ 434 | Create new Config object, reload colors, refresh cache 435 | if needed and redraw screen. 436 | """ 437 | self.set_footer('Reloading configuration') 438 | self.config = Config() 439 | self.build_help() 440 | self.palette = self.config.get_palette() 441 | self.build_interface() 442 | self.loop.draw_screen() 443 | self.set_footer('Configuration file reloaded!') 444 | 445 | if self.config.parser.get( 446 | 'settings', 'cache') != self.cache_manager.cache_path: 447 | self.cache_manager.cache_path = self.config.parser.get( 448 | 'settings', 'cache') 449 | 450 | def exit(self, must_raise=False): 451 | self.poller.is_running = False 452 | self.poller.join() 453 | if must_raise: 454 | raise urwid.ExitMainLoop() 455 | urwid.ExitMainLoop() 456 | 457 | def run(self): 458 | urwid.connect_signal(self.walker, 'modified', self.update) 459 | 460 | try: 461 | self.poller.start() 462 | self.loop.run() 463 | except KeyboardInterrupt: 464 | self.exit() 465 | print('Exiting... Bye!') 466 | -------------------------------------------------------------------------------- /pyhn/hnapi.py: -------------------------------------------------------------------------------- 1 | """ 2 | hn-api is a simple, ad-hoc Python API for Hacker News. 3 | ====================================================== 4 | 5 | hn-api is released under the Simplified BSD License: 6 | 7 | Copyright (c) 2010, Scott Jackson 8 | All rights reserved. 9 | 10 | Redistribution and use in source and binary forms, with or without 11 | modification, are permitted provided that the following conditions are met: 12 | 13 | 1. Redistributions of source code must retain the above copyright notice, 14 | this list of conditions and the following disclaimer. 15 | 16 | 2. Redistributions in binary form must reproduce the above copyright notice, 17 | this list of conditions and the following disclaimer in the 18 | documentation and/or other materials provided with the distribution. 19 | 20 | THIS SOFTWARE IS PROVIDED BY SCOTT JACKSON ``AS IS'' AND ANY EXPRESS OR IMPLIED 21 | WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 22 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 23 | IN NO EVENT SHALL SCOTT JACKSON OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, 24 | INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 25 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 26 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 27 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 28 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 29 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | 31 | The views and conclusions contained in the software and documentation are 32 | those of the authors and should not be interpreted as representing official 33 | policies, either expressed or implied, of Scott Jackson. 34 | 35 | 36 | """ 37 | import re 38 | import sys 39 | 40 | import requests 41 | from bs4 import BeautifulSoup 42 | 43 | PY3 = False 44 | if sys.version_info.major == 3: 45 | PY3 = True 46 | 47 | if PY3: 48 | from urllib.parse import urljoin 49 | from urllib.parse import urlparse 50 | else: 51 | from urlparse import urljoin 52 | from urlparse import urlparse 53 | 54 | HEADERS = { 55 | 'User-Agent': ( 56 | "Pyhn (Hacker news command line client) - " 57 | "https://github.com/toxinu/pyhn")} 58 | 59 | 60 | class HNException(Exception): 61 | """ 62 | HNException is exactly the same as a plain Python Exception. 63 | 64 | The HNException class exists solely so that you can identify 65 | errors that come from HN as opposed to from your application. 66 | """ 67 | pass 68 | 69 | 70 | class HackerNewsAPI: 71 | """ 72 | The class for slicing and dicing the HTML and turning 73 | it into HackerNewsStory objects. 74 | """ 75 | number_of_stories_on_front_page = 0 76 | 77 | def get_source(self, url): 78 | """ 79 | Returns the HTML source code for a URL. 80 | """ 81 | try: 82 | r = requests.get(url, headers=HEADERS) 83 | if r: 84 | return r.text 85 | except Exception: 86 | raise HNException( 87 | "Error getting source from " + url + 88 | ". Your internet connection may have something " 89 | "funny going on, or you could be behind a proxy.") 90 | 91 | def get_story_number(self, source): 92 | """ 93 | Parses HTML and returns the number of a story. 94 | """ 95 | bs = BeautifulSoup(source, "html.parser") 96 | span = bs.find('span', attrs={'class': 'rank'}) 97 | if span.string: 98 | number = span.string.replace('.', '') 99 | return int(number) 100 | 101 | def get_story_url(self, source): 102 | """ 103 | Gets the URL of a story. 104 | """ 105 | url_start = source.find('href="') + 6 106 | url_end = source.find('">', url_start) 107 | url = source[url_start:url_end] 108 | # Check for "Ask HN" links. 109 | if url[0:4] == "item": # "Ask HN" links start with "item". 110 | url = "https://news.ycombinator.com/" + url 111 | 112 | # Change "&" to "&" 113 | url = url.replace("&", "&") 114 | 115 | # Remove 'rel="nofollow' from the end of links, 116 | # since they were causing some bugs. 117 | if url[len(url) - 13:] == "rel=\"nofollow": 118 | url = url[:len(url) - 13] 119 | 120 | # Weird hack for URLs that end in '" '. 121 | # Consider removing later if it causes any problems. 122 | if url[len(url) - 2:] == "\" ": 123 | url = url[:len(url) - 2] 124 | return url 125 | 126 | def get_story_domain(self, source): 127 | """ 128 | Gets the domain of a story. 129 | """ 130 | bs = BeautifulSoup(source, "html.parser") 131 | url = bs.find('a').get('href') 132 | url_parsed = urlparse(url) 133 | if url_parsed.netloc: 134 | return url 135 | return urljoin('https://news.ycombinator.com', url) 136 | 137 | def get_story_title(self, source): 138 | """ 139 | Gets the title of a story. 140 | """ 141 | bs = BeautifulSoup(source, "html.parser") 142 | title = bs.find('td', attrs={'class': 'title'}).text 143 | title = title.strip() 144 | return title 145 | 146 | def get_story_score(self, source): 147 | """ 148 | Gets the score of a story. 149 | """ 150 | bs = BeautifulSoup(source, "html.parser") 151 | tags = bs.find_all('span', {'class': 'score'}) 152 | if tags: 153 | score = tags[0].text.split(u'\xa0') 154 | if not score or not score[0].isdigit(): 155 | score = tags[0].text.split(' ') 156 | if score and score[0].isdigit(): 157 | return int(score[0]) 158 | 159 | def get_submitter(self, source): 160 | """ 161 | Gets the HN username of the person that submitted a story. 162 | """ 163 | bs = BeautifulSoup(source, "html.parser") 164 | tags = bs.find_all('a', {'class': 'hnuser'}) 165 | if tags: 166 | return tags[0].text 167 | 168 | def get_comment_count(self, source): 169 | """ 170 | Gets the comment count of a story. 171 | """ 172 | bs = BeautifulSoup(source, "html.parser") 173 | comments = bs.find_all('a', text=re.compile('comment')) 174 | if comments: 175 | comments = comments[0].text 176 | separator = u'\xc2\xa0' 177 | if separator in comments: 178 | comments = comments.split(separator)[0] 179 | else: 180 | comments = comments.split(u'\xa0')[0] 181 | try: 182 | return int(comments) 183 | except ValueError: 184 | return None 185 | 186 | comments = bs.find_all('a', text=re.compile('discuss')) 187 | if comments: 188 | return 0 189 | 190 | def get_published_time(self, source): 191 | """ 192 | Gets the published time ago 193 | """ 194 | p = re.compile( 195 | r'\d{1,}\s(minutes|minute|hours|hour|day|days)\sago', flags=re.U) 196 | 197 | if not PY3: 198 | source = source.decode('utf-8') 199 | 200 | results = p.search(source) 201 | if results: 202 | return results.group() 203 | 204 | def get_hn_id(self, source): 205 | """ 206 | Gets the Hacker News ID of a story. 207 | """ 208 | bs = BeautifulSoup(source, "html.parser") 209 | hn_id = bs.find_all('a', {'href': re.compile('item\?id=')}) 210 | if hn_id: 211 | hn_id = hn_id[0].get('href') 212 | if hn_id: 213 | hn_id = hn_id.split('item?id=')[-1] 214 | if hn_id.isdigit(): 215 | return int(hn_id) 216 | 217 | def get_comments_url(self, source): 218 | """ 219 | Gets the comment URL of a story. 220 | """ 221 | return "https://news.ycombinator.com/item?id=" + str( 222 | self.get_hn_id(source)) 223 | 224 | def get_stories(self, source): 225 | """ 226 | Looks at source, makes stories from it, returns the stories. 227 | """ 228 | """ 31. """ 229 | self.number_of_stories_on_front_page = source.count( 230 | 'span class="rank"') 231 | 232 | # Create the empty stories. 233 | news_stories = [] 234 | for i in range(0, self.number_of_stories_on_front_page): 235 | story = HackerNewsStory() 236 | news_stories.append(story) 237 | 238 | soup = BeautifulSoup(source, "html.parser") 239 | # Gives URLs, Domains and titles. 240 | story_details = soup.findAll("td", {"class": "title"}) 241 | # Gives score, submitter, comment count and comment URL. 242 | story_other_details = soup.findAll("td", {"class": "subtext"}) 243 | # Get story numbers. 244 | story_numbers = [] 245 | for i in range(0, len(story_details) - 1, 2): 246 | # Otherwise, story_details[i] is a BeautifulSoup-defined object. 247 | story = str(story_details[i]) 248 | story_number = self.get_story_number(story) 249 | story_numbers.append(story_number) 250 | 251 | story_urls = [] 252 | story_domains = [] 253 | story_titles = [] 254 | story_scores = [] 255 | story_submitters = [] 256 | story_comment_counts = [] 257 | story_comment_urls = [] 258 | story_published_time = [] 259 | story_ids = [] 260 | 261 | # Every second cell contains a story. 262 | for i in range(1, len(story_details), 2): 263 | story = str(story_details[i]) 264 | story_urls.append(self.get_story_url(story)) 265 | story_domains.append(self.get_story_domain(story)) 266 | story_titles.append(self.get_story_title(story)) 267 | 268 | for s in story_other_details: 269 | story = str(s) 270 | story_scores.append(self.get_story_score(story)) 271 | story_submitters.append(self.get_submitter(story)) 272 | story_comment_counts.append(self.get_comment_count(story)) 273 | story_comment_urls.append(self.get_comments_url(story)) 274 | story_published_time.append(self.get_published_time(story)) 275 | story_ids.append(self.get_hn_id(story)) 276 | 277 | # Associate the values with our newsStories. 278 | for i in range(0, self.number_of_stories_on_front_page): 279 | news_stories[i].number = story_numbers[i] 280 | news_stories[i].url = story_urls[i] 281 | news_stories[i].domain = story_domains[i] 282 | news_stories[i].title = story_titles[i] 283 | news_stories[i].score = story_scores[i] 284 | news_stories[i].submitter = story_submitters[i] 285 | if news_stories[i].submitter: 286 | news_stories[i].submitter_url = ( 287 | "https://news.ycombinator.com/user?id={}".format( 288 | story_submitters[i])) 289 | else: 290 | news_stories[i].submitter_url = None 291 | news_stories[i].comment_count = story_comment_counts[i] 292 | news_stories[i].comments_url = story_comment_urls[i] 293 | news_stories[i].published_time = story_published_time[i] 294 | news_stories[i].id = story_ids[i] 295 | 296 | if news_stories[i].id < 0: 297 | news_stories[i].url.find('item?id=') + 8 298 | news_stories[i].comments_url = '' 299 | news_stories[i].submitter = None 300 | news_stories[i].submitter_url = None 301 | 302 | return news_stories 303 | 304 | def get_more_link(self, source): 305 | soup = BeautifulSoup(source, "html.parser") 306 | more_a = soup.findAll("a", {"rel": "nofollow"}, text="More") 307 | if more_a: 308 | return urljoin('https://news.ycombinator.com/', more_a[0]['href']) 309 | return None 310 | 311 | # #### End of internal methods. ##### 312 | 313 | # The following methods could be turned into one method with 314 | # an argument that switches which page to get stories from, 315 | # but I thought it would be simplest if I kept the methods 316 | # separate. 317 | 318 | def get_jobs_stories(self, extra_page=1): 319 | stories = [] 320 | source_latest = self.get_source("https://news.ycombinator.com/jobs") 321 | stories += self.get_stories(source_latest) 322 | for i in range(1, extra_page + 2): 323 | get_more_link = self.get_more_link(source_latest) 324 | if not get_more_link: 325 | break 326 | source_latest = self.get_source(get_more_link) 327 | stories += self.get_stories(source_latest) 328 | 329 | return stories 330 | 331 | def get_ask_stories(self, extra_page=1): 332 | stories = [] 333 | for i in range(1, extra_page + 2): 334 | source = self.number_of_stories_on_front_page( 335 | "https://news.ycombinator.com/ask?p=%s" % i) 336 | stories += self.get_stories(source) 337 | return stories 338 | 339 | def get_show_newest_stories(self, extra_page=1): 340 | stories = [] 341 | source_latest = self.get_source("https://news.ycombinator.com/shownew") 342 | stories += self.get_stories(source_latest) 343 | for i in range(1, extra_page + 2): 344 | get_more_link = self.get_more_link(source_latest) 345 | if not get_more_link: 346 | break 347 | source_latest = self.get_source(get_more_link) 348 | stories += self.get_stories(source_latest) 349 | return stories 350 | 351 | def get_show_stories(self, extra_page=1): 352 | stories = [] 353 | source_latest = self.get_source("https://news.ycombinator.com/show") 354 | stories += self.get_stories(source_latest) 355 | for i in range(1, extra_page + 2): 356 | get_more_link = self.get_more_link(source_latest) 357 | if not get_more_link: 358 | break 359 | source_latest = self.get_source(get_more_link) 360 | stories += self.get_stories(source_latest) 361 | return stories 362 | 363 | def get_top_stories(self, extra_page=1): 364 | """ 365 | Gets the top stories from Hacker News. 366 | """ 367 | stories = [] 368 | for i in range(1, extra_page + 2): 369 | source = self.get_source( 370 | "https://news.ycombinator.com/news?p=%s" % i) 371 | stories += self.get_stories(source) 372 | return stories 373 | 374 | def get_newest_stories(self, extra_page=1): 375 | """ 376 | Gets the newest stories from Hacker News. 377 | """ 378 | stories = [] 379 | source_latest = self.get_source("https://news.ycombinator.com/newest") 380 | stories += self.get_stories(source_latest) 381 | for i in range(1, extra_page + 2): 382 | get_more_link = self.get_more_link(source_latest) 383 | if not get_more_link: 384 | break 385 | source_latest = self.get_source(get_more_link) 386 | stories += self.get_stories(source_latest) 387 | return stories 388 | 389 | def get_best_stories(self, extra_page=1): 390 | """ 391 | Gets the "best" stories from Hacker News. 392 | """ 393 | stories = [] 394 | for i in range(1, extra_page + 2): 395 | source_latest = self.get_source( 396 | "https://news.ycombinator.com/best?p=%s" % i) 397 | stories += self.get_stories(source_latest) 398 | return stories 399 | 400 | def get_page_stories(self, pageId): 401 | """ 402 | Gets the pageId stories from Hacker News. 403 | """ 404 | source = self.get_source( 405 | "https://news.ycombinator.com/x?fnid=%s" % pageId) 406 | stories = self.get_stories(source) 407 | return stories 408 | 409 | 410 | class HackerNewsStory: 411 | """ 412 | A class representing a story on Hacker News. 413 | """ 414 | id = 0 # The Hacker News ID of a story. 415 | number = None # What rank the story is on HN. 416 | title = "" # The title of the story. 417 | domain = "" # The website the story is from. 418 | url = "" # The URL of the story. 419 | score = None # Current score of the story. 420 | submitter = "" # The person that submitted the story. 421 | comment_count = None # How many comments the story has. 422 | comments_url = "" # The HN link for commenting (and upmodding). 423 | published_time = "" # The time sinc story was published 424 | 425 | def get_comments(self): 426 | url = ( 427 | 'http://hndroidapi.appspot.com/' 428 | 'nestedcomments/format/json/id/%s' % self.id) 429 | try: 430 | r = requests.get(url, headers=HEADERS) 431 | self.comments = r.json()['items'] 432 | return self.comments 433 | except Exception: 434 | raise HNException( 435 | "Error getting source from " + url + 436 | ". Your internet connection may have something funny " 437 | "going on, or you could be behind a proxy.") 438 | 439 | def print_details(self): 440 | """ 441 | Prints details of the story. 442 | """ 443 | print(str(self.number) + ": " + self.title) 444 | print("URL: %s" % self.url) 445 | print("domain: %s" % self.domain) 446 | print("score: " + str(self.score) + " points") 447 | print("submitted by: " + self.submitter) 448 | print("sinc %s" + self.published_time) 449 | print("of comments: " + str(self.comment_count)) 450 | print("'discuss' URL: " + self.comments_url) 451 | print("HN ID: " + str(self.id)) 452 | print(" ") 453 | 454 | 455 | class HackerNewsUser: 456 | """ 457 | A class representing a user on Hacker News. 458 | """ 459 | # Default value. I don't think anyone really has -10000 karma. 460 | karma = -10000 461 | name = "" # The user's HN username. 462 | user_page_url = "" # The URL of the user's 'user' page. 463 | threads_page_url = "" # The URL of the user's 'threads' page. 464 | 465 | def __init__(self, username): 466 | """ 467 | Constructor for the user class. 468 | """ 469 | self.name = username 470 | self.user_page_url = ( 471 | "https://news.ycombinator.com/user?id=" + self.name) 472 | self.threads_page_url = ( 473 | "https://news.ycombinator.com/threads?id=%s" % self.name) 474 | self.refresh_karma() 475 | 476 | def refresh_karma(self): 477 | """ 478 | Gets the karma count of a user from the source of their 'user' page. 479 | """ 480 | hn = HackerNewsAPI() 481 | source = hn.get_source(self.user_page_url) 482 | karma_start = source.find('karma:') + 30 483 | karma_end = source.find('', karma_start) 484 | karma = source[karma_start:karma_end] 485 | if karma is not '': 486 | self.karma = int(karma) 487 | else: 488 | raise HNException("Error getting karma for user " + self.name) 489 | -------------------------------------------------------------------------------- /pyhn/poller.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from time import sleep 3 | from threading import Thread 4 | 5 | 6 | class Poller(Thread): 7 | def __init__(self, gui, delay=5): 8 | if delay < 1: 9 | delay = 1 10 | 11 | self.gui = gui 12 | self.delay = delay 13 | self.is_running = True 14 | self.counter = 0 15 | super(Poller, self).__init__() 16 | 17 | def run(self): 18 | while self.is_running: 19 | sleep(0.1) 20 | self.counter += 0.1 21 | if self.counter >= self.delay * 60: 22 | self.gui.async_refresher(force=True) 23 | self.counter = 0 24 | -------------------------------------------------------------------------------- /pyhn/popup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import urwid 3 | 4 | 5 | class Popup(urwid.WidgetWrap): 6 | """ 7 | Creates a popup menu on top of another BoxWidget. 8 | 9 | Attributes: 10 | 11 | selected -- Contains the item the user has selected by pressing , 12 | or None if nothing has been selected. 13 | """ 14 | 15 | selected = None 16 | 17 | def __init__(self, menu_list, attr, pos, body): 18 | """ 19 | menu_list -- a list of strings with the menu entries 20 | attr -- a tuple (background, active_item) of attributes 21 | pos -- a tuple (x, y), position of the menu widget 22 | body -- widget displayed beneath the message widget 23 | """ 24 | 25 | content = [w for w in menu_list] 26 | 27 | # Calculate width and height of the menu widget: 28 | height = len(menu_list) 29 | width = 0 30 | for entry in menu_list: 31 | if len(entry.original_widget.text) > width: 32 | width = len(entry.original_widget.text) 33 | 34 | # Create the ListBox widget and put it on top of body: 35 | self._listbox = urwid.AttrWrap(urwid.ListBox(content), attr[0]) 36 | overlay = urwid.Overlay(self._listbox, body, 'center', 37 | width + 2, 'middle', height) 38 | 39 | urwid.WidgetWrap.__init__(self, overlay) 40 | 41 | def keypress(self, size, key): 42 | """ 43 | key selects an item, other keys will be passed to 44 | the ListBox widget. 45 | """ 46 | 47 | if key == "enter": 48 | (widget, foo) = self._listbox.get_focus() 49 | (text, foo) = widget.get_text() 50 | self.selected = text[1:] # Get rid of the leading space... 51 | else: 52 | return self._listbox.keypress(size, key) 53 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | beautifulsoup4==4.9.3 2 | requests==2.25.1 3 | urwid==2.1.2 4 | # wheel 5 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toxinu/pyhn/b5a3c05f3d22da38a523d64c4d7531892571e836/screenshot.png -------------------------------------------------------------------------------- /scripts/pyhn: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from pyhn import gui 4 | from pyhn.cachemanager import CacheManager 5 | 6 | print('Loading stories...') 7 | cachemanager = CacheManager() 8 | hn_gui = gui.HNGui(cachemanager) 9 | hn_gui.main() 10 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | import os 4 | import re 5 | import sys 6 | 7 | from codecs import open 8 | 9 | try: 10 | from setuptools import setup 11 | except ImportError: 12 | from distutils.core import setup 13 | 14 | if sys.argv[-1] == 'publish': 15 | os.system('python setup.py sdist upload') 16 | sys.exit() 17 | 18 | requires = [ 19 | 'requests==2.25.1', 20 | 'beautifulsoup4==4.9.3', 21 | 'urwid==2.1.2'] 22 | 23 | version = '' 24 | with open('pyhn/__init__.py', 'r') as fd: 25 | version = re.search( 26 | r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', 27 | fd.read(), re.MULTILINE).group(1) 28 | 29 | if not version: 30 | raise RuntimeError('Cannot find version information') 31 | 32 | with open('README.rst', 'r', 'utf-8') as f: 33 | readme = f.read() 34 | 35 | setup( 36 | name='pyhn', 37 | version=version, 38 | description='Hacker News in your terminal', 39 | long_description=readme, 40 | license=open("LICENSE").read(), 41 | author="toxinu", 42 | author_email="toxinu@gmail.com", 43 | url='https://github.com/toxinu/pyhn/', 44 | keywords="python hackernews hn terminal commandline", 45 | packages=['pyhn'], 46 | scripts=['scripts/pyhn'], 47 | install_requires=requires, 48 | package_data={'': ['LICENSE', 'NOTICE']}, 49 | package_dir={'pyhn': 'pyhn'}, 50 | include_package_data=True, 51 | classifiers=[ 52 | 'Development Status :: 5 - Production/Stable', 53 | 'Natural Language :: English', 54 | 'License :: OSI Approved :: MIT License', 55 | 'Programming Language :: Python', 56 | 'Programming Language :: Python :: 2', 57 | 'Programming Language :: Python :: 2.7', 58 | 'Programming Language :: Python :: 3', 59 | 'Programming Language :: Python :: 3.4', 60 | 'Programming Language :: Python :: 3.5', 61 | 'Programming Language :: Python :: 3.6', 62 | 'Programming Language :: Python :: 3.7'] 63 | ) 64 | -------------------------------------------------------------------------------- /tests/comments.data: -------------------------------------------------------------------------------- 1 | [{"username": "bendmorris", "comment": "In my field (ecology and evolutionary biology) there's a small but concerted push to get people to publish \\\"executable papers\\\" where all code and data is available via GitHub, on an iPython notebook, etc. so that all figures and test cases can easily be reproduced by reviewers and researchers. If you're publishing research, it shouldn't be the reader's job to parse your paper and reimplement the research you describe; it should be on you to make your results replicable, and I think this is a standard we need to insist upon. I can't count how many papers I've read lately that were missing either crucial methods or underlying data so that replication was impossible.__BR__edit: Here's an example:__BR__https://github.com/weecology/white-etal-2012-ecology__BR__ Run portions of the analysis pipeline: Empirical analyses: python mete_sads.py ./data/ empir Simulation analyses: python mete_sads.py ./data/ sims Figures: python mete_sads.py ./data/ figs", "children": [{"username": "jgrahamc", "comment": "This is what I (with co-authors) argued for in Nature last year: http://blog.jgc.org/2012/02/case-for-open-computer-programs....", "children": [], "grayedOutPercent": 0, "reply_id": "5043005&whence=%69%74%65%6d%3f%69%64%3d%35%30%34%32%37%33%35", "time": "1 minute ago", "id": "5043005"}, {"username": "tellarin", "comment": "The Reproducible Research movement and the like have been trying to gain ground in many different areas since at least 2009 [1].__BR__Too bad its adoption hasn't grown faster. Especially with all the recent focus on \\\"big data\\\", applied machine learning, and computational science papers.It's hard to actually measure the quality and contributions of most of them.__BR__Not having an accepted method to cite and share datasets is also part of the problem.__BR__1- http://www.computer.org/csdl/mags/cs/2009/01/mcs2009010005.p... [PDF]", "children": [], "grayedOutPercent": 0, "reply_id": "5043001&whence=%69%74%65%6d%3f%69%64%3d%35%30%34%32%37%33%35", "time": "2 minutes ago", "id": "5043001"}, {"username": "fusiongyro", "comment": "I agree completely, but as the resident Dijkstra-head I am compelled to furnish the following quote:__BR__\\\"In the good old days physicists repeated each other's experiments, just to be sure. Today they stick to FORTRAN, so that they can share each other's programs, bugs included.\\\" -- EWD 498 (\\\"How do we tell truths that might hurt?\\\")", "children": [{"username": "stingraycharles", "comment": "And what's wrong with that? That only makes it easier to spot the errors in their research.", "children": [], "grayedOutPercent": 0, "reply_id": "5043002&whence=%69%74%65%6d%3f%69%64%3d%35%30%34%32%37%33%35", "time": "2 minutes ago", "id": "5043002"}], "grayedOutPercent": 0, "reply_id": "5042990&whence=%69%74%65%6d%3f%69%64%3d%35%30%34%32%37%33%35", "time": "4 minutes ago", "id": "5042990"}, {"username": "vidarh", "comment": "That's great... When I did my MSc (my thesis was on reducing error rates in OCR), of the dozens of computer science papers I surveyed, there were perhaps 2-3 where I didn't have to do extensive \\\"reverse engineering\\\" of the results of the papers to figure out a whole lot of unstated assumptions when I wanted to test their algorithms. It was a tremendous waste of time...__BR__Especially when pretty much all of these papers described results that meant the authors had already implemented their algorithms in executable form. But almost none of them made the code available in any form whatsoever.", "children": [], "grayedOutPercent": 0, "reply_id": "5042961&whence=%69%74%65%6d%3f%69%64%3d%35%30%34%32%37%33%35", "time": "10 minutes ago", "id": "5042961"}, {"username": "mej10", "comment": "This should be the default for all code and data produced by publicly funded research.", "children": [{"username": "46Bit", "comment": "Yes, although I don't think this is just a question of open access. It's a question of research verification - and expected of all published research.", "children": [], "grayedOutPercent": 0, "reply_id": "5042985&whence=%69%74%65%6d%3f%69%64%3d%35%30%34%32%37%33%35", "time": "5 minutes ago", "id": "5042985"}], "grayedOutPercent": 0, "reply_id": "5042948&whence=%69%74%65%6d%3f%69%64%3d%35%30%34%32%37%33%35", "time": "12 minutes ago", "id": "5042948"}], "grayedOutPercent": 0, "reply_id": "5042889&whence=%69%74%65%6d%3f%69%64%3d%35%30%34%32%37%33%35", "time": "26 minutes ago", "id": "5042889"}, {"username": "nullc", "comment": "The site is down now, so I haven't seen the article&euro;&rdquo; but I hope it just says \\\"(1) give up and beg the author for their implementation, which undoubtedly contains 2 megabytes of opaque unmentioned magic constants.\\\"", "children": [], "grayedOutPercent": 0, "reply_id": "5042989&whence=%69%74%65%6d%3f%69%64%3d%35%30%34%32%37%33%35", "time": "4 minutes ago", "id": "5042989"}, {"username": "dmlorenzetti", "comment": "It's kind of funny that the article lists \\\"authors citing their own work\\\" as an identifying feature of groundbreaking research.__BR__I don't know about CS, but in most scientific fields, this may be a bad sign. It can mean they're just trying to pump up their own reference counts, or it can mean they don't really know what other people are doing.__BR__The only way to be sure that neither of these is the case is to know, a priori, that they're truly doing groundbreaking work.", "children": [{"username": "arethuza", "comment": "I think the warning sign should really be if an author only cites their own work, from what I recall having a small number of references to your own previous work that you are building on is pretty standard and desirable as most research is incremental.", "children": [], "grayedOutPercent": 0, "reply_id": "5042881&whence=%69%74%65%6d%3f%69%64%3d%35%30%34%32%37%33%35", "time": "27 minutes ago", "id": "5042881"}, {"username": "lake99", "comment": "Not funny at all. Referencing your own work, by itself, is not a bad sign. In many cases, the groundbreaking work take several years to accomplish. The years of work generate several new ideas on their own, resulting in several related papers.", "children": [], "grayedOutPercent": 0, "reply_id": "5042998&whence=%69%74%65%6d%3f%69%64%3d%35%30%34%32%37%33%35", "time": "2 minutes ago", "id": "5042998"}], "grayedOutPercent": 0, "reply_id": "5042856&whence=%69%74%65%6d%3f%69%64%3d%35%30%34%32%37%33%35", "time": "33 minutes ago", "id": "5042856"}, {"username": "bjoernbu", "comment": "While peer review is flawed in so many ways and neither all papers accepted at top conferences are good, nor all good papers get accepted, it has still some meaning.__BR__When deciding which paper to read, it can be a great hit where it was published. The link merely claims groundbreaking work was published in the best \\\"journals\\\". Especially in computer science, conferece papers are where recent, groundbreaking work is published and good conference are the ones that are hard to get into.However, I agree that groundbreaking work ofter gets a longer follow-up journal aritcle. But those usually appear years later and for those algorithms it is likely that there are existing implementations available by that time.", "children": [], "grayedOutPercent": 0, "reply_id": "5042899&whence=%69%74%65%6d%3f%69%64%3d%35%30%34%32%37%33%35", "time": "22 minutes ago", "id": "5042899"}, {"username": "mistercow", "comment": "&#62;If you are in the U.S., beware of software patents. Some papers are patented and you could get into trouble for using them in commercial applications.__BR__Is it only commercial applications that you have to worry about? I was under the impression that even a free implementation would be infringing the patent and make you liable for damages.", "children": [], "grayedOutPercent": 0, "reply_id": "5042947&whence=%69%74%65%6d%3f%69%64%3d%35%30%34%32%37%33%35", "time": "13 minutes ago", "id": "5042947"}, {"username": "Gravityloss", "comment": "It's interesting to note how Matlab is still so fast to prototype stuff in. I've done it myself countless times.__BR__Data generation, input, manipulation, output and result checking are all very good.__BR__Maybe things like Go can change that, or then some optionally typed language. There is no fundamental reason not to get massive improvements.", "children": [], "grayedOutPercent": 0, "reply_id": "5042968&whence=%69%74%65%6d%3f%69%64%3d%35%30%34%32%37%33%35", "time": "8 minutes ago", "id": "5042968"}, {"username": "omaranto", "comment": "6.3 and 6.4 read funny one after the other: put references to the paper in the comments, but change all the notation from the paper.", "children": [], "grayedOutPercent": 0, "reply_id": "5042936&whence=%69%74%65%6d%3f%69%64%3d%35%30%34%32%37%33%35", "time": "14 minutes ago", "id": "5042936"}] -------------------------------------------------------------------------------- /tests/get_comments.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import os 4 | import json 5 | from hnapi import HackerNewsAPI 6 | 7 | hn = HackerNewsAPI() 8 | stories = hn.get_top_stories() 9 | 10 | story = stories[0] 11 | if not os.path.exists('comments.data'): 12 | comments = story.get_comments() 13 | open('comments.data', 'w').write(json.dumps(comments)) 14 | else: 15 | comments = json.load(open('comments.data')) 16 | -------------------------------------------------------------------------------- /tests/treesample.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # 3 | # Trivial data browser 4 | # This version: 5 | # Copyright (C) 2010 Rob Lanphier 6 | # Derived from browse.py in urwid distribution 7 | # Copyright (C) 2004-2007 Ian Ward 8 | # 9 | # This library is free software; you can redistribute it and/or 10 | # modify it under the terms of the GNU Lesser General Public 11 | # License as published by the Free Software Foundation; either 12 | # version 2.1 of the License, or (at your option) any later version. 13 | # 14 | # This library is distributed in the hope that it will be useful, 15 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 17 | # Lesser General Public License for more details. 18 | # 19 | # You should have received a copy of the GNU Lesser General Public 20 | # License along with this library; if not, write to the Free Software 21 | # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA 22 | # 23 | # Urwid web site: http://excess.org/urwid/ 24 | 25 | """ 26 | Urwid example lazy directory browser / tree view 27 | 28 | Features: 29 | - custom selectable widgets for files and directories 30 | - custom message widgets to identify access errors and empty directories 31 | - custom list walker for displaying widgets in a tree fashion 32 | """ 33 | 34 | import urwid 35 | import os 36 | import json 37 | from pprint import pprint 38 | 39 | 40 | class ExampleTreeWidget(urwid.TreeWidget): 41 | """ Display widget for leaf nodes """ 42 | def get_display_text(self): 43 | return self.get_node().get_value()['name'] 44 | 45 | 46 | class ExampleNode(urwid.TreeNode): 47 | """ Data storage object for leaf nodes """ 48 | def load_widget(self): 49 | return ExampleTreeWidget(self) 50 | 51 | 52 | class ExampleParentNode(urwid.ParentNode): 53 | """ Data storage object for interior/parent nodes """ 54 | def load_widget(self): 55 | return ExampleTreeWidget(self) 56 | 57 | def load_child_keys(self): 58 | data = self.get_value() 59 | return range(len(data['children'])) 60 | 61 | def load_child_node(self, key): 62 | """Return either an ExampleNode or ExampleParentNode""" 63 | childdata = self.get_value()['children'][key] 64 | childdepth = self.get_depth() + 1 65 | if 'children' in childdata: 66 | childclass = ExampleParentNode 67 | else: 68 | childclass = ExampleNode 69 | return childclass(childdata, parent=self, key=key, depth=childdepth) 70 | 71 | 72 | class ExampleTreeBrowser: 73 | palette = [ 74 | ('body', 'black', 'light gray'), 75 | ('focus', 'light gray', 'dark blue', 'standout'), 76 | ('head', 'yellow', 'black', 'standout'), 77 | ('foot', 'light gray', 'black'), 78 | ('key', 'light cyan', 'black','underline'), 79 | ('title', 'white', 'black', 'bold'), 80 | ('flag', 'dark gray', 'light gray'), 81 | ('error', 'dark red', 'light gray'), 82 | ] 83 | 84 | footer_text = [ 85 | ('title', "Example Data Browser"), " ", 86 | ('key', "UP"), ",", ('key', "DOWN"), ",", 87 | ('key', "PAGE UP"), ",", ('key', "PAGE DOWN"), 88 | " ", 89 | ('key', "+"), ",", 90 | ('key', "-"), " ", 91 | ('key', "LEFT"), " ", 92 | ('key', "HOME"), " ", 93 | ('key', "END"), " ", 94 | ('key', "Q"), 95 | ] 96 | 97 | def __init__(self, data=None): 98 | self.topnode = ExampleParentNode(data) 99 | self.listbox = urwid.TreeListBox(urwid.TreeWalker(self.topnode)) 100 | self.listbox.offset_rows = 1 101 | self.header = urwid.Text( "" ) 102 | self.footer = urwid.AttrWrap( urwid.Text( self.footer_text ), 103 | 'foot') 104 | self.view = urwid.Frame( 105 | urwid.AttrWrap( self.listbox, 'body' ), 106 | header=urwid.AttrWrap(self.header, 'head' ), 107 | footer=self.footer ) 108 | 109 | def main(self): 110 | """Run the program.""" 111 | 112 | self.loop = urwid.MainLoop(self.view, self.palette, 113 | unhandled_input=self.unhandled_input) 114 | self.loop.run() 115 | 116 | def unhandled_input(self, k): 117 | if k in ('q','Q'): 118 | raise urwid.ExitMainLoop() 119 | 120 | 121 | def get_example_tree(): 122 | """ generate a quick 100 leaf tree for demo purposes """ 123 | f = open("comments.data", "r").read() 124 | info = json.loads(f)[0] 125 | s = "%s %s\n%s\n" % (info["username"], info["time"], info["comment"]) 126 | retval = {"name":s, "children": []} 127 | for i in range(len(info["children"])): 128 | l = get_example_tree_recursion(info["children"][i]) 129 | retval["children"].append(l) 130 | return retval 131 | 132 | def get_example_tree_recursion(info): 133 | s = "%s %s\n%s\n" % (info["username"], info["time"], info["comment"]) 134 | n = {"name": s, "children": []} 135 | for j in range(len(info["children"])): 136 | l = get_example_tree_recursion(info["children"][j]) 137 | n["children"].append(l) 138 | return n 139 | 140 | 141 | def main(): 142 | sample = get_example_tree() 143 | ExampleTreeBrowser(sample).main() 144 | 145 | 146 | if __name__=="__main__": 147 | main() 148 | 149 | --------------------------------------------------------------------------------