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