├── .gitignore ├── screenshot.png ├── README.md ├── LICENSE.txt ├── helper.py ├── api.py ├── chancli.py └── state.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BanhmiDev/chancli/HEAD/screenshot.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | chancli 2 | ======= 3 | Browsing 4chan in terminal style. 4 | 5 | ![Screenshot](https://raw.github.com/gimu/chancli/master/screenshot.png) 6 | 7 | ## Dependencies 8 | chancli is written for Python 3 and requires the [urwid](https://pypi.python.org/pypi/urwid/) package. 9 | 10 | ## Commands 11 | `listboards` list available boards 12 | `open ` open a thread from the current window, specified by its index 13 | `board ` display the first page (ex: board g) 14 | `board ` display the nth page starting from 1 15 | `thread ` open a specific thread 16 | `archive ` display archived threads from a board 17 | `help` show the help page 18 | `license` display the license page 19 | `exit/quit/q` exit the application 20 | 21 | ## License 22 | chancli is licensed under the [MIT](http://opensource.org/licenses/MIT) License. 23 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Son Nguyen 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. 22 | -------------------------------------------------------------------------------- /helper.py: -------------------------------------------------------------------------------- 1 | import re 2 | import urwid 3 | from html.parser import HTMLParser 4 | 5 | # https://stackoverflow.com/questions/753052/strip-html-from-strings-in-python 6 | class MLStripper(HTMLParser): 7 | def __init__(self): 8 | self.reset() 9 | self.strict = False 10 | self.convert_charrefs = False 11 | self.fed = [] 12 | 13 | def handle_data(self, d): 14 | self.fed.append(d) 15 | 16 | def get_data(self): 17 | return ''.join(self.fed) 18 | 19 | class Helper: 20 | @staticmethod 21 | def parse_comment(html): 22 | """Return urwid.Text formatted string.""" 23 | # Replace HTML breaks with \n 24 | html = html.replace("
", '\n') 25 | html = html.replace(">", '>') 26 | html = html.replace(""", '"') 27 | 28 | s = MLStripper() 29 | s.feed(html) 30 | html = s.get_data() 31 | 32 | html_list = re.split(r'\n', html) 33 | 34 | for index, line in enumerate(html_list): 35 | html_list[index] += "\n" 36 | 37 | if re.search('>', line): # Green-texting 38 | html_list[index] = ('quote', line + "\n") 39 | 40 | return html_list 41 | -------------------------------------------------------------------------------- /api.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import urllib.error 3 | import urllib.request 4 | 5 | class ApiError(object): 6 | 7 | @staticmethod 8 | def get_error(target, error): 9 | """Return error message.""" 10 | return {'content': "\nCould not generate {}\nFull error code: {}".format(target, error), 'status': "Error occured"} 11 | 12 | class Api(object): 13 | 14 | def get_boards(self): 15 | """Return boards' information.""" 16 | data = {'error': False, 'result': None} 17 | 18 | try: 19 | data['result'] = urllib.request.urlopen("https://a.4cdn.org/boards.json").read().decode('utf-8') 20 | except urllib.error.HTTPError as error: 21 | data['error'] = ApiError.get_error("boards list", error) 22 | except urllib.error.URLError as error: 23 | data['error'] = ApiError.get_error("boards list", error) 24 | 25 | return data 26 | 27 | def get_threads(self, board, page=1): 28 | """Get threads by board and page.""" 29 | data = {'error': False, 'result': None} 30 | 31 | try: 32 | data['result'] = urllib.request.urlopen("https://a.4cdn.org/{}/{}.json".format(board, page)).read().decode('utf-8') 33 | except urllib.error.HTTPError as error: 34 | data['error'] = ApiError.get_error("threads list", error) 35 | except urllib.error.URLError as error: 36 | data['error'] = ApiError.get_error("threads list", error) 37 | 38 | return data 39 | 40 | def get_thread(self, board, thread_id): 41 | """Get particular thread by id.""" 42 | data = {'error': False, 'result': None} 43 | 44 | try: 45 | data['result'] = urllib.request.urlopen("https://a.4cdn.org/{}/thread/{}.json".format(board, thread_id)).read().decode('utf-8') 46 | except urllib.error.HTTPError as error: 47 | data['error'] = ApiError.get_error("thread list", error) 48 | except urllib.error.URLError as error: 49 | data['error'] = ApiError.get_error("thread list", error) 50 | 51 | return data 52 | 53 | def get_archive(self, board): 54 | """Get archive of board.""" 55 | data = {'error': False, 'result': None} 56 | 57 | try: 58 | data['result'] = urllib.request.urlopen("https://a.4cdn.org/{}/archive.json".format(board)).read().decode('utf-8') 59 | except urllib.error.HTTPError as error: 60 | data['error'] = ApiError.get_error("archive list", error) 61 | except urllib.error.URLError as error: 62 | data['error'] = ApiError.get_error("archive list", error) 63 | 64 | return data 65 | -------------------------------------------------------------------------------- /chancli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import sys 3 | import urwid 4 | from urwid import MetaSignals 5 | 6 | from state import State 7 | 8 | class MainWindow(object): 9 | 10 | __metaclass__ = MetaSignals 11 | signals = ["keypress", "quit"] 12 | 13 | _palette = [ 14 | ('divider', 'black', 'light gray'), 15 | ('text', 'white', 'default'), 16 | 17 | ('number', 'dark green', 'default'), 18 | ('time', 'dark gray', 'default'), 19 | ('quote', 'light green', 'default'), 20 | 21 | ('highlight', 'yellow', 'default'), 22 | ('underline', 'yellow', 'default', 'underline'), 23 | ('bold', 'yellow', 'default', 'bold'), 24 | ('body', 'text'), 25 | ('footer', 'text'), 26 | ('header', 'text'), 27 | ] 28 | 29 | def __init__(self, sender="1234567890"): 30 | self.mark_quit = False 31 | self.sender = sender 32 | 33 | self.state = State() 34 | 35 | def main(self): 36 | """Entry point.""" 37 | self.ui = urwid.raw_display.Screen() 38 | self.ui.register_palette(self._palette) 39 | self.build_ui() 40 | self.ui.run_wrapper(self.run) 41 | 42 | def build_ui(self): 43 | """Build the urwid UI.""" 44 | self.header = urwid.Text("Chancli") 45 | self.content = urwid.SimpleListWalker([]) 46 | self.content.append(self.state.splash()) 47 | self.body = urwid.ListBox(self.content) 48 | self.divider = urwid.Text("Type help for instructions, exit to quit.") 49 | self.footer = urwid.Edit("> ") 50 | 51 | self.header = urwid.AttrWrap(self.header, "divider") 52 | self.body = urwid.AttrWrap(self.body, "body") 53 | self.divider = urwid.AttrWrap(self.divider, "divider") 54 | self.footer = urwid.AttrWrap(self.footer, "footer") 55 | 56 | self.footer.set_wrap_mode("space") 57 | 58 | main_frame = urwid.Frame(self.body, header=self.header, footer=self.divider) 59 | 60 | self.context = urwid.Frame(main_frame, footer=self.footer) 61 | self.context.set_focus("footer") # Focus on the user input first 62 | 63 | def run(self): 64 | """Setup and start mainloop.""" 65 | 66 | def input_handler(key): 67 | if self.mark_quit: 68 | raise urwid.ExitMainLoop 69 | self.keypress(self.size, key) 70 | 71 | self.size = self.ui.get_cols_rows() 72 | 73 | self.main_loop = urwid.MainLoop( 74 | self.context, 75 | screen=self.ui, 76 | handle_mouse=False, 77 | unhandled_input=input_handler 78 | ) 79 | 80 | # Disable bold on bright fonts 81 | self.main_loop.screen.set_terminal_properties(bright_is_bold=False) 82 | 83 | try: 84 | self.main_loop.run() 85 | except KeyboardInterrupt: 86 | self.quit() 87 | 88 | def print_content(self, text): 89 | """Print given text as content.""" 90 | # Accept strings, convert them to urwid.Text instances 91 | if not isinstance(text, (urwid.Text, urwid.Pile)): 92 | text = urwid.Text(text) 93 | 94 | self.content.append(text) 95 | 96 | def parse_input(self): 97 | """Parse input data.""" 98 | text = self.footer.get_edit_text() 99 | 100 | # Remove input after submitting 101 | self.footer.set_edit_text("") 102 | 103 | # input: description 104 | # ------------------------------------------------------------- 105 | # exit, quit, q: exit the application 106 | # help: show help page 107 | # license: show license page 108 | # listboards: list all available boards 109 | # open: open specific thread by index (shown on the screen) 110 | # thread: open specific thread 111 | # board: trigger either "board " or "board " 112 | # archive: trigger "archive " 113 | # empty: show initial status message 114 | # else: invalid command 115 | 116 | if text in ("exit", "quit", "q"): 117 | self.quit() 118 | elif text == "help": 119 | _content = self.state.help() 120 | elif text == "license": 121 | _content = self.state.license() 122 | elif text == "listboards": 123 | _content = self.state.listboards() 124 | elif text.startswith("open"): 125 | _content = self.state.open(text) 126 | elif text.startswith("thread"): 127 | _content = self.state.thread(text) 128 | elif text.startswith("board"): 129 | _content = self.state.board(text) 130 | elif text.startswith("archive"): # archive 131 | _content = self.state.archive(text) 132 | elif text.strip() == "": 133 | _content = self.state.empty() 134 | else: 135 | _content = self.state.invalid(text) 136 | 137 | if _content['content']: # Only update if content given 138 | del self.content[:] # Remove previous content 139 | self.print_content(_content['content']) 140 | self.divider.set_text(_content['status']) 141 | 142 | def keypress(self, size, key): 143 | """Handle user input.""" 144 | urwid.emit_signal(self, "keypress", size, key) 145 | 146 | # Focus management 147 | if key == "up" or key == "down": 148 | self.context.set_focus("body") 149 | else: 150 | self.context.set_focus("footer") 151 | 152 | # New dimension on resize 153 | if key == "window resize": 154 | self.size = self.ui.get_cols_rows() 155 | elif key == "enter": 156 | # Parse input data 157 | self.parse_input() 158 | elif key in ("ctrl d", "ctrl c"): 159 | # Quit by key combination 160 | self.quit() 161 | 162 | def quit(self): 163 | """Quit the application.""" 164 | urwid.emit_signal(self, "quit") 165 | self.mark_quit = True 166 | 167 | sys.exit(0) 168 | 169 | if __name__ == "__main__": 170 | main_window = MainWindow() 171 | main_window.main() 172 | -------------------------------------------------------------------------------- /state.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import re 3 | import json 4 | import urwid 5 | 6 | from api import Api 7 | from helper import Helper 8 | 9 | class State(object): 10 | 11 | def __init__(self): 12 | # Api calls 13 | self.api = Api() 14 | 15 | # Save temporary data for quick opening (open command) 16 | self.current_threads = {'board': None, 'list': []} 17 | 18 | # JSON data 19 | self.boards_json = None 20 | self.threads_json = None 21 | self.thread_json = None 22 | self.archive_json = None 23 | 24 | def listboards(self): 25 | # Do not call the API more than once 26 | if not self.boards_json: 27 | data = self.api.get_boards() 28 | 29 | # Determine if an error occured 30 | if not data['error']: 31 | self.boards_json = data['result'] 32 | else: 33 | return data['error'] 34 | 35 | # Used for urwid.Text which is going to be displayed 36 | text = [("\nDisplaying all boards. Codes are "), (('highlight'), "highlighted"), ".\n\n"] 37 | 38 | if self.boards_json: 39 | data = json.loads(self.boards_json) 40 | for board in data['boards']: 41 | text.append("/") 42 | text.append(('highlight', board['board'])) 43 | text.append("/ - {}\n".format(board['title'])) 44 | 45 | return {'content': urwid.Text(text), 'status': "Displaying all boards"} 46 | 47 | def open(self, text): 48 | """Open thread by index shown on the screen.""" 49 | arg = re.match(' \w+$', text[4:]) 50 | 51 | if self.current_threads['board'] and arg: 52 | index = arg.group().strip() 53 | 54 | # Check if convertible to integer 55 | if index.isdigit(): 56 | index = int(index) - 1 # Indices are incremented by 1 57 | else: 58 | index = -1 59 | 60 | # Check if regex matches + index in list 61 | if arg and -1 < index < len(self.current_threads['list']): 62 | board = self.current_threads['board'] 63 | thread_id = self.current_threads['list'][index] # Get from the saved thread list 64 | 65 | return self.thread("thread {} {}".format(board, thread_id)) 66 | else: 67 | return {'content': False, 'status': "Invalid argument. Wrong index? Use open ."} 68 | else: 69 | return {'content': False, 'status': "Open a board first to issue this command."} 70 | 71 | def board(self, text): 72 | arg1 = re.match(' \w+$', text[5:]) # board 73 | arg2 = re.match(' \w+ \w+$', text[5:]) # board 74 | 75 | if arg1: 76 | board = arg1.group().strip() 77 | page = 1 78 | elif arg2: 79 | arg2 = arg2.group().strip() 80 | arg2 = arg2.split(" ") # Split to get real arguments 81 | board = arg2[0] 82 | page = arg2[1] 83 | else: 84 | return {'content': False, 'status': "Invalid arguments. Use board or board ."} 85 | 86 | data = self.api.get_threads(board, page) 87 | 88 | # Determine if an error occured 89 | if not data['error']: 90 | self.threads_json = data['result'] 91 | else: 92 | return data['error'] 93 | 94 | # List containing urwid widgets - to be wrapped up by urwid.Pile 95 | content = [ 96 | urwid.Text([("\nDisplaying page "), (('highlight'), str(page)), " of /", (('highlight'), str(board)), "/.\n"]) 97 | ] 98 | 99 | if self.threads_json: 100 | self.current_threads['board'] = board 101 | del self.current_threads['list'][:] # Reset previous temporary data 102 | 103 | data = json.loads(self.threads_json) 104 | for index, post in enumerate(data['threads'], 1): # index starting from 1 to open threads without specifying full id (see: open ) 105 | 106 | self.current_threads['list'].append(post['posts'][0]['no']) # Quick opening 107 | _header = [ 108 | ('highlight', "({}) ".format(index)), 109 | ('number', "No. {} ".format(post['posts'][0]['no'])), 110 | ('time', "{}".format(post['posts'][0]['now'])) 111 | ] 112 | 113 | # Check for empty comment 114 | if "com" in post['posts'][0]: 115 | _text = Helper.parse_comment(post['posts'][0]['com']) 116 | else: 117 | _text = "- no comment -\n" 118 | 119 | content.append(urwid.Padding(urwid.Text(_header), 'left', left=0)) 120 | content.append(urwid.Padding(urwid.Text(_text), 'left', left=4)) # Indent text content from header 121 | 122 | return {'content': urwid.Pile(content), 'status': "Displaying page {} of /{}/".format(page, board)} 123 | 124 | def thread(self, text): 125 | """Open thread by specifying board and id.""" 126 | arg = re.match(' \w+ \w+$', text[6:]) # thread 127 | 128 | if arg: 129 | arg = arg.group().strip() 130 | arg = arg.split(" ") # Split to get real arguments 131 | 132 | board = arg[0] 133 | thread_id = arg[1] 134 | else: 135 | return {'content': False, 'status': "Invalid arguments. Use thread ."} 136 | 137 | data = self.api.get_thread(board, thread_id) 138 | 139 | # Determine if an error occured 140 | if not data['error']: 141 | self.thread_json = data['result'] 142 | else: 143 | return data['error'] 144 | 145 | # List containing urwid widgets - to be wrapped up by urwid.Pile 146 | content = [ 147 | urwid.Text([("\nDisplaying thread "), (('highlight'), str(thread_id)), " in /", (('highlight'), str(board)), "/.\n"]) 148 | ] 149 | 150 | if self.thread_json: 151 | data = json.loads(self.thread_json) 152 | for post in data["posts"]: 153 | _header = [ 154 | ('number', "No. {} ".format(post['no'])), 155 | ('time', "{}".format(post['now'])) 156 | ] 157 | 158 | if "com" in post: 159 | _text = Helper.parse_comment(post['com']) 160 | else: 161 | _text = "- no comment -\n" 162 | 163 | content.append(urwid.Padding(urwid.Text(_header), 'left', left=0)) 164 | content.append(urwid.Padding(urwid.Text(_text), 'left', left=4)) # Indent text content from header 165 | 166 | return {'content': urwid.Pile(content), 'status': "Displaying thread {} in /{}/".format(thread_id, board)} 167 | 168 | def archive(self, text): 169 | arg = re.match(' \w+$', text[7:]) 170 | 171 | if arg: 172 | board = arg.group().strip() 173 | else: 174 | return {'content': False, 'status': "Invalid argument. Use archive ."} 175 | 176 | data = self.api.get_archive(board) 177 | 178 | # Determine if an error occured 179 | if not data['error']: 180 | self.archive_json = data['result'] 181 | else: 182 | return data['error'] 183 | 184 | # Used for urwid.Text which is going to be displayed 185 | text = [("\nDisplaying archive"), " of /", (('highlight'), str(board)), "/.\n\n"] 186 | 187 | if self.archive_json: 188 | self.current_threads['board'] = board 189 | del self.current_threads['list'][:] # Reset previous temporary data 190 | 191 | data = json.loads(self.archive_json) 192 | for index, thread in enumerate(data, 1): # index starting from 1 to open threads without specifying full id (see: open ) 193 | self.current_threads['list'].append(thread) # Quick opening 194 | text.append(('highlight', "[{}]".format(index))) 195 | text.append(" No. {}\n".format(thread)) 196 | 197 | return {'content': urwid.Text(text), 'status': "Displaying archive of /{}/".format(board)} 198 | 199 | def empty(self): 200 | return {'content': False, 'status': "Type help for instructions, exit to quit."} 201 | 202 | def invalid(self, text): 203 | return {'content': False, 'status': "Invalid command: {}".format(text)} 204 | 205 | @staticmethod 206 | def splash(): 207 | return urwid.Text([ 208 | ("\n\n ____ _ _ _ _ _ ____ _ ___\n" 209 | " / ___| | | | / \ | \ | | / ___| | |_ _|\n" 210 | " | | | |_| | / _ \ | \| | | | | | | |\n" 211 | " | |___| _ |/ ___ \| |\ | | |___| |___ | |\n" 212 | " \____|_| |_/_/ \_\_| \_| \____|_____|___|\n" 213 | " chancli version 0.0.1") 214 | ]) 215 | 216 | @staticmethod 217 | def help(): 218 | return { 219 | 'content': urwid.Text([ 220 | ('underline', "\nBasic Commands\n\n"), 221 | ('Chancli utilizes the official 4chan API, which can be found at https://github.com/4chan/4chan-API.\n\n'), 222 | ('highlight', "listboards"), " - list available boards aside their code\n", 223 | ('highlight', "open "), " - open a thread from the current window, specified by its index\n", 224 | ('highlight', "board "), " - display the first page (ex: board g)\n", 225 | ('highlight', "board "), " - display the nth page starting from 1\n", 226 | ('highlight', "thread "), " - open a specific thread\n", 227 | ('highlight', "archive "), " - display archived threads from a board\n\n", 228 | ('highlight', "help"), " - show this page\n", 229 | ('highlight', "license"), " - display the license page\n", 230 | ('highlight', "exit/quit/q"), " - exit the application" 231 | ]), 232 | 'status': "Help page" 233 | } 234 | 235 | @staticmethod 236 | def license(): 237 | return { 238 | 'content': ("\nThe MIT License (MIT)\n\n" 239 | "Copyright (c) 2015 Son Nguyen \n\n" 240 | "Permission is hereby granted, free of charge, to any person obtaining a copy\n" 241 | "of this software and associated documentation files (the \"Software\"), to deal\n" 242 | "in the Software without restriction, including without limitation the rights\n" 243 | "to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" 244 | "copies of the Software, and to permit persons to whom the Software is\n" 245 | "furnished to do so, subject to the following conditions:\n\n" 246 | "The above copyright notice and this permission notice shall be included in\n" 247 | "all copies or substantial portions of the Software.\n\n" 248 | "THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" 249 | "IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" 250 | "FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" 251 | "AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" 252 | "LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" 253 | "OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" 254 | "THE SOFTWARE."), 255 | 'status': "License page" 256 | } 257 | --------------------------------------------------------------------------------