├── .gitignore ├── LICENSE ├── README.md ├── common.go ├── config.default.ini ├── g2ex.gif ├── main.go ├── ui.go └── v2ex.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | 26 | v2ex-go 27 | config.ini 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 dudongcheng 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## v2ex-go 2 | 3 | ![g2ex.gif](./g2ex.gif) 4 | 5 | #### 安装 6 | 7 | ``` shell 8 | # 由于golang.org被墙,安装时可能需要梯子 9 | go get github.com/six-ddc/v2ex-go 10 | cd $GOPATH/bin 11 | ./v2ex-go 12 | ``` 13 | 14 | * 或者直接下载对应的二进制 [Release包](https://github.com/six-ddc/v2ex-go/releases) 15 | 16 | 17 | #### 使用 18 | 19 | * ~~支持登录,修改[user]配置,然后`C-l`右下角可以查看用户基本信息~~ 20 | * 查看节点,`C-t` 切换到tab栏,然后输入`字母`选择对应的tab和节点,`回车`打开 21 | * 查看回复,输入`字母`或者`数字编号`选择匹配主题,`C-n`切换结果,`回车`打开 22 | * 节点翻页,`C-f`滚动到页尾,然后再次`C-f`即加载下一页 (tab主题不支持翻页) 23 | * 回复翻页,主题回复页面,默认加载最后一页,`C-b`滚动到上一页 24 | * `C-r`刷新主题或回复页面 25 | * `C-p`切换主题和回复页面 26 | * `C-q`退出 27 | 28 | 更多快捷键,参考`config.default.ini`,里面有所有快捷键的介绍 29 | 30 | 也可以重命名为`config.ini`,并拷贝到执行文件所在目录,进行自定义配置。 31 | 32 | -------------------------------------------------------------------------------- /common.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "github.com/go-ini/ini" 6 | rw "github.com/mattn/go-runewidth" 7 | "strings" 8 | ) 9 | 10 | type State int 11 | 12 | const ( 13 | StateDefault State = iota 14 | StateTab 15 | StateBody 16 | StateMax 17 | ) 18 | 19 | const ( 20 | BodyStateTopic State = StateMax + 1 21 | BodyStateReply State = StateMax + 2 22 | ) 23 | 24 | type UserInfo struct { 25 | Name string 26 | Notify string 27 | Silver int 28 | Bronze int 29 | } 30 | 31 | type ReplyInfo struct { 32 | Floor int 33 | Member string 34 | Reply string 35 | Source string 36 | } 37 | 38 | type ReplyList struct { 39 | Title string 40 | Content []string 41 | Lz string 42 | PostTime string 43 | ClickNum string 44 | List []ReplyInfo 45 | } 46 | 47 | type TopicInfo struct { 48 | Title string 49 | Url string 50 | Author string 51 | AuthorImg string 52 | Node string 53 | Time string 54 | LastReply string 55 | ReplyCount int 56 | } 57 | 58 | type TopicType uint16 59 | 60 | const ( 61 | TopicTab TopicType = iota 62 | TopicNode 63 | ) 64 | 65 | var UserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36" 66 | 67 | func MatchKey(str, key []byte) string { 68 | colorRc := "[c](fg-green)" 69 | colorBytes := []byte(colorRc) 70 | keyMap := make(map[byte]uint16) 71 | for _, c := range key { 72 | keyMap[c]++ 73 | } 74 | nameMap := make(map[byte]uint16) 75 | for _, c := range str { 76 | nameMap[c]++ 77 | } 78 | has := true 79 | for _, rc := range key { 80 | if keyMap[rc] > nameMap[rc] { 81 | has = false 82 | break 83 | } 84 | } 85 | if has && len(ShortKeys) > 0 { 86 | short := []byte{} 87 | for _, rc := range str { 88 | if keyMap[rc] != 0 { 89 | colorBytes[1] = rc 90 | short = append(short, colorBytes...) 91 | } else { 92 | short = append(short, rc) 93 | } 94 | } 95 | return string(short) 96 | } 97 | return string(str) 98 | } 99 | 100 | func WrapString(str string, limit int) string { 101 | wid := 0 102 | buf := bytes.NewBuffer([]byte{}) 103 | for _, ch := range str { 104 | w := rw.RuneWidth(ch) 105 | if ch == '\n' { 106 | wid = 0 107 | } else { 108 | // ui.Lise的最后一个rune会被显示... 109 | if wid+w >= limit-3 { 110 | buf.WriteRune('\n') 111 | wid = 0 112 | } else { 113 | wid += w 114 | } 115 | } 116 | buf.WriteRune(ch) 117 | } 118 | return buf.String() 119 | } 120 | 121 | var iniCfg *ini.File 122 | 123 | func SetConfFile(f string) (err error) { 124 | iniCfg, err = ini.Load(f) 125 | return err 126 | } 127 | 128 | func GetConfString(secKey string, defau string) string { 129 | if iniCfg == nil { 130 | return defau 131 | } 132 | sk := strings.Split(secKey, ".") 133 | s, k := sk[0], sk[1] 134 | sec, err := iniCfg.GetSection(s) 135 | if err != nil { 136 | return defau 137 | } 138 | key, err := sec.GetKey(k) 139 | if err != nil { 140 | return defau 141 | } 142 | return key.String() 143 | } 144 | 145 | func GetConfInt(secKey string, defau int) int { 146 | if iniCfg == nil { 147 | return defau 148 | } 149 | sk := strings.Split(secKey, ".") 150 | s, k := sk[0], sk[1] 151 | sec, err := iniCfg.GetSection(s) 152 | if err != nil { 153 | return defau 154 | } 155 | key, err := sec.GetKey(k) 156 | if err != nil { 157 | return defau 158 | } 159 | i, _ := key.Int() 160 | return i 161 | } 162 | 163 | func GetConfBool(secKey string, defau bool) bool { 164 | if iniCfg == nil { 165 | return defau 166 | } 167 | sk := strings.Split(secKey, ".") 168 | s, k := sk[0], sk[1] 169 | sec, err := iniCfg.GetSection(s) 170 | if err != nil { 171 | return defau 172 | } 173 | key, err := sec.GetKey(k) 174 | if err != nil { 175 | return defau 176 | } 177 | b, _ := key.Bool() 178 | return b 179 | } 180 | -------------------------------------------------------------------------------- /config.default.ini: -------------------------------------------------------------------------------- 1 | [ui] 2 | # 是否显示日志打印UI, 即便未启用,也可以使用[key.log]打开 3 | # 同时登录的用户信息窗口需要启用该项才能显示 4 | enable_log=false 5 | 6 | [user] 7 | # 部分帖子和节点需要登录才能查看 8 | # 在启用ui.enable_log或[key.log]后即可查看用户金币和未读通知等 9 | name= 10 | pass= 11 | 12 | # 快捷键设置只能是Ctrl+单字符组合键或者以下特殊键 13 | # "", "", "", "", "", "", "", "", "", "" 14 | [key] 15 | # 退出快捷键 16 | quit=C-q 17 | # 切换到tab窗口 18 | tab=C-t 19 | # tab和reply|topic轮流切换(连续按下两次才能生效) 20 | switch=C-w 21 | # 主题和回复轮流切换 22 | topic2reply=C-p 23 | # 打开或隐藏日志和用户信息状态 24 | log=C-l 25 | # 刷新主题或者回复 26 | update=C-r 27 | # 翻页 28 | pagedown=C-f 29 | pageup=C-b 30 | # 翻屏 31 | scrolldown=C-e 32 | scrollup=C-y 33 | # 打开匹配的主题节点或者回复列表 34 | enter= 35 | # 轮流切换匹配项 36 | next=C-n 37 | 38 | [color] 39 | 40 | -------------------------------------------------------------------------------- /g2ex.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/six-ddc/v2ex-go/2480e8d6d3831767f5fe9247caff436f46611ce7/g2ex.gif -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | // "fmt" 5 | ui "github.com/six-ddc/termui" 6 | "log" 7 | "time" 8 | ) 9 | 10 | var ( 11 | uiTab *UITab 12 | uiTopic *UITopicList 13 | uiReply *UIReplyList 14 | uiLog *UILog 15 | uiUser *UIUser 16 | 17 | TopicRows *ui.Row 18 | ReplyRows *ui.Row 19 | 20 | LastCtrlW int64 21 | CurrState State 22 | CurrBodyState State 23 | ) 24 | 25 | func switchState(st State) { 26 | log.Println("st", st, "CurrState", CurrState, "CurrBodyState", CurrBodyState) 27 | if st == BodyStateReply { 28 | if CurrBodyState != BodyStateReply { 29 | ui.Body.Rows[1] = ReplyRows 30 | ui.Body.Align() 31 | ui.Render(ui.Body) 32 | CurrBodyState = BodyStateReply 33 | } 34 | st = StateBody 35 | } else if st == BodyStateTopic { 36 | if CurrBodyState != BodyStateTopic { 37 | ui.Body.Rows[1] = TopicRows 38 | ui.Body.Align() 39 | ui.Render(ui.Body) 40 | CurrBodyState = BodyStateTopic 41 | } 42 | st = StateBody 43 | } 44 | ResetMatch() 45 | switch st { 46 | case StateDefault: 47 | uiTab.ResetTabList() 48 | uiTab.Highlight(false) 49 | uiTab.UpdateLabel() 50 | 51 | if CurrBodyState == BodyStateTopic { 52 | uiTopic.Highlight(false) 53 | uiTopic.UpdateLabel() 54 | } else if CurrBodyState == BodyStateReply { 55 | uiReply.Highlight(false) 56 | uiReply.UpdateLabel() 57 | } 58 | 59 | CurrState = StateDefault 60 | case StateTab: 61 | uiTab.MatchTab() 62 | uiTab.Highlight(true) 63 | uiTab.UpdateLabel() 64 | 65 | if CurrBodyState == BodyStateTopic { 66 | uiTopic.Highlight(false) 67 | uiTopic.UpdateLabel() 68 | } else if CurrBodyState == BodyStateReply { 69 | uiReply.Highlight(false) 70 | uiReply.UpdateLabel() 71 | } 72 | 73 | CurrState = StateTab 74 | case StateBody: 75 | uiTab.ResetTabList() 76 | uiTab.Highlight(false) 77 | uiTab.UpdateLabel() 78 | 79 | if CurrBodyState == BodyStateTopic { 80 | uiTopic.Highlight(true) 81 | uiTopic.UpdateLabel() 82 | } else if CurrBodyState == BodyStateReply { 83 | uiReply.Highlight(true) 84 | uiReply.UpdateLabel() 85 | } 86 | 87 | CurrState = StateBody 88 | } 89 | } 90 | 91 | func handleKey(e ui.Event) { 92 | switch CurrState { 93 | case StateDefault: 94 | log.Println(e.Data.(ui.EvtKbd).KeyStr, "default") 95 | case StateTab, StateBody: 96 | key := e.Data.(ui.EvtKbd).KeyStr 97 | if len(key) == 1 && ((key[0] >= '0' && key[0] <= '9') || (key[0] >= 'a' && key[0] <= 'z') || (key[0] >= 'A' && key[0] <= 'Z')) { 98 | MatchIndex = 0 99 | log.Println(e.Data.(ui.EvtKbd).KeyStr, "select") 100 | ShortKeys = append(ShortKeys, key[0]) 101 | if CurrState == StateTab { 102 | uiTab.MatchTab() 103 | uiTab.UpdateLabel() 104 | } else if CurrState == StateBody { 105 | if CurrBodyState == BodyStateTopic { 106 | uiTopic.MatchTopic() 107 | uiTopic.UpdateLabel() 108 | } 109 | } 110 | } else if key == "" || key == "C-8" || key == "C-c" { 111 | // 这里可能是bug,C-8其实是 112 | MatchIndex = 0 113 | ShortKeys = ShortKeys[:0] 114 | if CurrState == StateTab { 115 | uiTab.MatchTab() 116 | uiTab.UpdateLabel() 117 | } else if CurrState == StateBody { 118 | uiTopic.MatchTopic() 119 | uiTopic.UpdateLabel() 120 | } 121 | } else if key == GetConfString("key.next", "C-n") && len(MatchList) > 0 { 122 | MatchIndex++ 123 | MatchIndex = MatchIndex % len(MatchList) 124 | if CurrState == StateTab { 125 | uiTab.MatchTab() 126 | } else { 127 | uiTopic.MatchTopic() 128 | } 129 | } else if key == GetConfString("key.enter", "") { 130 | if CurrState == StateTab { 131 | uiTab.CurrChildTab = -1 132 | if len(MatchList) > 0 { 133 | tab := MatchList[MatchIndex] 134 | sz := len(uiTab.NameList[0]) 135 | if tab >= sz { 136 | uiTab.CurrChildTab = tab - sz 137 | } else { 138 | uiTab.CurrTab = tab 139 | } 140 | } else { 141 | uiTab.CurrTab = 0 142 | } 143 | uiTopic.Fresh(uiTab.GetTabNode()) 144 | switchState(BodyStateTopic) 145 | } else if CurrState == StateBody { 146 | if len(MatchList) == 0 { 147 | return 148 | } 149 | if CurrBodyState == BodyStateTopic { 150 | idx := MatchList[MatchIndex] 151 | idx += uiTopic.Index 152 | 153 | uiReply.Fresh(&uiTopic.AllTopicInfo[idx], false) 154 | switchState(BodyStateReply) 155 | } 156 | } 157 | } 158 | } 159 | } 160 | 161 | func init() { 162 | log.SetFlags(log.Ltime | log.Lshortfile) 163 | if err := ui.Init(); err != nil { 164 | log.Panic(err) 165 | } 166 | 167 | uiTab = NewTab() 168 | uiLog = NewLog() 169 | uiUser = NewUser() 170 | uiTopic = NewTopicList(uiTab, uiUser) 171 | uiReply = NewReplyList() 172 | 173 | SetConfFile("config.ini") 174 | } 175 | 176 | func uiResize() { 177 | ui.Body.Width = ui.TermWidth() 178 | if GetConfBool("ui.enable_log", false) { 179 | uiTopic.Height = ui.TermHeight() - uiLog.Height - uiTab.Height 180 | } else { 181 | uiTopic.Height = ui.TermHeight() - uiTab.Height 182 | } 183 | uiReply.Height = uiTopic.Height 184 | } 185 | 186 | func main() { 187 | 188 | defer ui.Close() 189 | 190 | uiResize() 191 | 192 | TopicRows = ui.NewCol(12, 0, uiTopic) 193 | ReplyRows = ui.NewCol(12, 0, uiReply) 194 | 195 | ui.Body.AddRows( 196 | ui.NewCol(12, 0, uiTab), 197 | TopicRows, 198 | ui.NewRow( 199 | ui.NewCol(9, 0, uiLog), 200 | ui.NewCol(3, 0, uiUser))) 201 | ui.Body.Align() 202 | ui.Render(ui.Body) 203 | 204 | log.SetOutput(uiLog) 205 | 206 | user := GetConfString("user.name", "") 207 | pass := GetConfString("user.pass", "") 208 | if len(user) > 0 && len(pass) > 0 { 209 | if err := Login(user, pass); err != nil { 210 | log.Println(err) 211 | } 212 | } else { 213 | log.Println("$V2EX_NAME or $V2EX_PASS is empty") 214 | } 215 | 216 | switchState(StateTab) 217 | // 这里来回切换状态是为了在开始时候即对UIRow进行初始化(align, inner...) 218 | switchState(BodyStateReply) 219 | switchState(BodyStateTopic) 220 | 221 | ui.Handle("/sys/kbd/"+GetConfString("key.quit", "C-q"), func(ui.Event) { 222 | ui.StopLoop() 223 | }) 224 | ui.Handle("/sys/kbd/"+GetConfString("key.switch", "C-w"), func(ui.Event) { 225 | if LastCtrlW == 0 { 226 | LastCtrlW = time.Now().Unix() 227 | } else { 228 | now := time.Now().Unix() 229 | if now-LastCtrlW <= 2 { 230 | state := (CurrState + 1) % StateMax 231 | if state == StateDefault { 232 | state++ 233 | } 234 | switchState(state) 235 | LastCtrlW = 0 236 | } else { 237 | LastCtrlW = now 238 | } 239 | } 240 | }) 241 | ui.Handle("/sys/kbd/"+GetConfString("key.tab", "C-t"), func(ui.Event) { 242 | if CurrState != StateTab { 243 | switchState(StateTab) 244 | } else { 245 | switchState(StateDefault) 246 | } 247 | }) 248 | ui.Handle("/sys/kbd/"+GetConfString("key.update", "C-r"), func(ui.Event) { 249 | if CurrState == StateBody { 250 | if CurrBodyState == BodyStateTopic { 251 | uiTopic.Fresh(uiTab.GetTabNode()) 252 | } else if CurrBodyState == BodyStateReply { 253 | uiReply.Fresh(nil, false) 254 | } 255 | } 256 | }) 257 | ui.Handle("/sys/kbd/"+GetConfString("key.pagedown", "C-f"), func(ui.Event) { 258 | if CurrBodyState == BodyStateReply { 259 | if uiReply.PageDown() { 260 | } 261 | } else if CurrBodyState == BodyStateTopic { 262 | if !uiTopic.PageDown() { 263 | uiTopic.LoadNext() 264 | } 265 | } 266 | }) 267 | ui.Handle("/sys/kbd/"+GetConfString("key.pageup", "C-b"), func(ui.Event) { 268 | if CurrBodyState == BodyStateReply { 269 | if !uiReply.PageUp() { 270 | uiReply.LoadPrev() 271 | } 272 | } else if CurrBodyState == BodyStateTopic { 273 | uiTopic.PageUp() 274 | } 275 | }) 276 | ui.Handle("/sys/kbd/"+GetConfString("key.scrolldown", "C-e"), func(ui.Event) { 277 | if CurrBodyState == BodyStateReply { 278 | uiReply.ScrollDown() 279 | } else if CurrBodyState == BodyStateTopic { 280 | if !uiTopic.ScrollDown() { 281 | uiTopic.LoadNext() 282 | } 283 | } 284 | }) 285 | ui.Handle("/sys/kbd/"+GetConfString("key.scrollup", "C-y"), func(ui.Event) { 286 | if CurrBodyState == BodyStateReply { 287 | if !uiReply.ScrollUp() { 288 | uiReply.LoadPrev() 289 | } 290 | } else if CurrBodyState == BodyStateTopic { 291 | uiTopic.ScrollUp() 292 | } 293 | }) 294 | ui.Handle("/sys/kbd/"+GetConfString("key.topic2reply", "C-p"), func(ui.Event) { 295 | if CurrBodyState == BodyStateReply { 296 | switchState(BodyStateTopic) 297 | } else if CurrBodyState == BodyStateTopic { 298 | switchState(BodyStateReply) 299 | } 300 | }) 301 | // 这里其实是C-i 302 | ui.Handle("/sys/kbd/", func(ui.Event) { 303 | if CurrBodyState == BodyStateTopic { 304 | switchState(BodyStateReply) 305 | } 306 | }) 307 | ui.Handle("/sys/kbd/C-o", func(ui.Event) { 308 | if CurrBodyState == BodyStateReply { 309 | switchState(BodyStateTopic) 310 | } 311 | }) 312 | ui.Handle("/sys/kbd/"+GetConfString("key.log", "C-l"), func(e ui.Event) { 313 | if CurrBodyState == BodyStateReply { 314 | if uiReply.Height == ui.TermHeight()-uiTab.Height { 315 | uiReply.Height = ui.TermHeight() - uiLog.Height - uiTab.Height 316 | } else { 317 | uiReply.Height = ui.TermHeight() - uiTab.Height 318 | } 319 | } else if CurrBodyState == BodyStateTopic { 320 | if uiTopic.Height == ui.TermHeight()-uiTab.Height { 321 | uiTopic.Height = ui.TermHeight() - uiLog.Height - uiTab.Height 322 | } else { 323 | uiTopic.Height = ui.TermHeight() - uiTab.Height 324 | } 325 | } 326 | ui.Body.Align() 327 | ui.Render(ui.Body) 328 | }) 329 | ui.Handle("/sys/kbd", handleKey) 330 | ui.Handle("/sys/wnd/resize", func(e ui.Event) { 331 | uiResize() 332 | ui.Body.Align() 333 | ui.Render(ui.Body) 334 | }) 335 | firstLoad := true 336 | ui.Handle("/timer/1s", func(e ui.Event) { 337 | if firstLoad { 338 | firstLoad = false 339 | uiTopic.Fresh(uiTab.GetTabNode()) 340 | switchState(BodyStateTopic) 341 | } 342 | }) 343 | ui.Loop() 344 | } 345 | -------------------------------------------------------------------------------- /ui.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | rw "github.com/mattn/go-runewidth" 6 | ui "github.com/six-ddc/termui" 7 | "log" 8 | "math/rand" 9 | "strings" 10 | ) 11 | 12 | var ( 13 | ShortKeys []byte 14 | MatchList []int 15 | MatchIndex int 16 | ) 17 | 18 | func ResetMatch() { 19 | ShortKeys = ShortKeys[:0] 20 | MatchList = MatchList[:0] 21 | MatchIndex = 0 22 | } 23 | 24 | type ScrollList struct { 25 | ui.List 26 | AllItems []string 27 | Index int 28 | } 29 | 30 | func NewScrollList() *ScrollList { 31 | s := &ScrollList{List: *ui.NewList()} 32 | return s 33 | } 34 | 35 | func (l *ScrollList) SetItem(i int, item string) { 36 | l.Items[i] = item 37 | } 38 | 39 | func (l *ScrollList) SetAllItems(items []string) { 40 | l.AllItems = items 41 | sz := l.InnerHeight() 42 | log.Println("sz", sz, "len(items)", len(items), "index", l.Index) 43 | if len(items)-l.Index < sz { 44 | sz = len(items) - l.Index 45 | } 46 | l.Items = make([]string, sz) 47 | // 显示index后面的部分 48 | copy(l.Items, items[l.Index:]) // 复制长度以较小的slice为准 49 | l.ResetBgColor() 50 | ui.Render(l) 51 | } 52 | 53 | func (l *ScrollList) SetBgColor(i int, color ui.Attribute) { 54 | if len(l.ItemBg) != len(l.Items) { 55 | l.ResetBgColor() 56 | } 57 | l.ItemBg[i] = color 58 | ui.Render(l) 59 | } 60 | 61 | func (l *ScrollList) Highlight(b bool) { 62 | if b { 63 | l.BorderFg = ui.ColorRed 64 | } else { 65 | l.BorderFg = ui.ColorDefault 66 | } 67 | ui.Render(l) 68 | } 69 | 70 | func (l *ScrollList) ResetBgColor() { 71 | l.ItemBg = make([]ui.Attribute, len(l.Items)) 72 | for i := range l.ItemBg { 73 | l.ItemBg[i] = ui.ThemeAttr("list.item.bg") 74 | } 75 | ui.Render(l) 76 | } 77 | 78 | func (l *ScrollList) ScrollDown() bool { 79 | sz := len(l.AllItems) 80 | screenHeigth := l.InnerHeight() 81 | if sz > screenHeigth+l.Index { 82 | l.ResetBgColor() 83 | l.Index++ 84 | l.SetAllItems(l.AllItems) 85 | ui.Render(l) 86 | return true 87 | } 88 | return false 89 | } 90 | 91 | func (l *ScrollList) PageDown() bool { 92 | sz := len(l.AllItems) 93 | screenHeigth := l.InnerHeight() 94 | if sz < screenHeigth { 95 | return false 96 | } 97 | index := l.Index + screenHeigth 98 | if index >= sz { 99 | return false 100 | } 101 | l.Index = index 102 | l.SetAllItems(l.AllItems) 103 | ui.Render(l) 104 | return true 105 | } 106 | 107 | func (l *ScrollList) PageUp() bool { 108 | if l.Index == 0 { 109 | return false 110 | } 111 | screenHeigth := l.InnerHeight() 112 | index := l.Index - screenHeigth 113 | if index < 0 { 114 | index = 0 115 | } 116 | l.Index = index 117 | l.SetAllItems(l.AllItems) 118 | ui.Render(l) 119 | return true 120 | } 121 | 122 | func (l *ScrollList) ScrollUp() bool { 123 | if l.Index > 0 { 124 | l.ResetBgColor() 125 | l.Index-- 126 | l.SetAllItems(l.AllItems) 127 | ui.Render(l) 128 | return true 129 | } 130 | return false 131 | } 132 | 133 | type UILog struct { 134 | ui.List 135 | Label string 136 | Index int 137 | } 138 | 139 | func NewLog() *UILog { 140 | l := &UILog{Index: 0, Label: "Log [C-l]"} 141 | l.List = *ui.NewList() 142 | l.Height = 3 + 2 143 | l.BorderLabel = l.Label 144 | l.Items = make([]string, l.Height-2) 145 | return l 146 | } 147 | 148 | func (l *UILog) Write(p []byte) (n int, err error) { 149 | if len(l.Items) == 0 { 150 | return 0, nil 151 | } 152 | str := fmt.Sprintf("[%d] %s", l.Index+1, p) 153 | if l.Items[len(l.Items)-1] != "" { 154 | i := 0 155 | for ; i < len(l.Items)-1; i++ { 156 | l.Items[i] = l.Items[i+1] 157 | } 158 | l.Items[i] = str 159 | } else { 160 | for i, item := range l.Items { 161 | if item == "" { 162 | l.Items[i] = str 163 | break 164 | } 165 | } 166 | } 167 | l.Index++ 168 | ui.Render(l) 169 | return len(p), nil 170 | } 171 | 172 | type UIUser struct { 173 | ui.List 174 | User *UserInfo 175 | } 176 | 177 | func NewUser() *UIUser { 178 | u := &UIUser{User: &UserInfo{}} 179 | u.List = *ui.NewList() 180 | u.Height = 3 + 2 181 | u.Items = make([]string, u.Height-2) 182 | return u 183 | } 184 | 185 | func (u *UIUser) Fresh() { 186 | if len(u.User.Name) > 0 { 187 | u.Items[0] = fmt.Sprintf("[%s](fg-green)", u.User.Name) 188 | balance := fmt.Sprintf("%d/%d", u.User.Silver, u.User.Bronze) 189 | spaceWidth := u.InnerWidth() - 1 - rw.StringWidth(u.User.Notify) - rw.StringWidth(balance) 190 | if spaceWidth > 0 { 191 | u.Items[2] = fmt.Sprintf("%s%s%s", u.User.Notify, strings.Repeat(" ", spaceWidth), balance) 192 | } else { 193 | u.Items[2] = fmt.Sprintf("%s %s", u.User.Notify, balance) 194 | } 195 | ui.Render(u) 196 | } 197 | } 198 | 199 | type UITab struct { 200 | ui.List 201 | Label string 202 | NameList [][]string 203 | ChildList [][][]string 204 | CurrTab int 205 | CurrChildTab int 206 | } 207 | 208 | func NewTab() *UITab { 209 | t := &UITab{Label: "Tab [C-t]", CurrTab: 9 /*(全部)*/, CurrChildTab: -1} 210 | t.NameList = [][]string{ 211 | {"技术", "创意", "好玩", "Apple", "酷工作", "交易", "城市", "问与答", "最热", "全部", "R2", "节点", "关注"}, 212 | {"tech", "creative", "play", "apple", "jobs", "deals", "city", "qna", "hot", "all", "r2", "nodes", "members"}} 213 | t.ChildList = make([][][]string, len(t.NameList[0])) 214 | t.List = *ui.NewList() 215 | t.BorderLabel = t.Label 216 | t.Height = 2 + 2 217 | t.Items = make([]string, 2) 218 | t.ResetTabList() 219 | return t 220 | } 221 | 222 | func (t *UITab) Highlight(b bool) { 223 | if b { 224 | t.BorderFg = ui.ColorRed 225 | } else { 226 | t.BorderFg = ui.ColorDefault 227 | } 228 | ui.Render(t) 229 | } 230 | 231 | func (t *UITab) ResetTabList() { 232 | strList := []string{} 233 | for i, names := range (t.NameList)[0] { 234 | if i == t.CurrTab { 235 | strList = append(strList, fmt.Sprintf("[%s](bg-red)", names)) 236 | } else { 237 | strList = append(strList, names) 238 | } 239 | } 240 | t.Items[0] = strings.Join(strList, " ") 241 | childList := (t.ChildList)[t.CurrTab] 242 | if len(childList) == 0 { 243 | t.Items[1] = "" 244 | return 245 | } 246 | strList = strList[:0] 247 | for i, names := range childList[0] { 248 | if i == t.CurrChildTab { 249 | strList = append(strList, fmt.Sprintf("[%s](bg-red)", names)) 250 | } else { 251 | strList = append(strList, names) 252 | } 253 | } 254 | t.Items[1] = strings.Join(strList, " ") 255 | ui.Render(t) 256 | } 257 | 258 | func (t *UITab) UpdateLabel() { 259 | str := t.Label 260 | if len(ShortKeys) > 0 { 261 | str = fmt.Sprintf("%s (%s)", str, ShortKeys) 262 | } 263 | t.BorderLabel = str 264 | ui.Render(t) 265 | } 266 | 267 | func (t *UITab) GetTabNode() (cate string, node string) { 268 | cate = t.NameList[1][t.CurrTab] 269 | if t.CurrChildTab >= 0 { 270 | childList := (t.ChildList)[t.CurrTab] 271 | if len(childList) > 0 { 272 | node = childList[1][t.CurrChildTab] 273 | } 274 | } 275 | return 276 | } 277 | 278 | func (t *UITab) MatchTab() { 279 | strList := []string{} 280 | count := 0 281 | MatchList = MatchList[:0] 282 | for i, names := range (t.NameList)[1] { 283 | str := MatchKey([]byte(names), ShortKeys) 284 | if str != names { 285 | if count == MatchIndex { 286 | strList = append(strList, fmt.Sprintf("[%s](bg-red)<%s>", t.NameList[0][i], str)) 287 | } else { 288 | strList = append(strList, fmt.Sprintf("[%s](bg-blue)<%s>", t.NameList[0][i], str)) 289 | } 290 | count++ 291 | MatchList = append(MatchList, i) 292 | } else { 293 | strList = append(strList, fmt.Sprintf("%s<%s>", t.NameList[0][i], names)) 294 | } 295 | } 296 | t.Items[0] = strings.Join(strList, " ") 297 | childList := (t.ChildList)[t.CurrTab] 298 | if len(childList) == 0 { 299 | return 300 | } 301 | strList = strList[:0] 302 | for i, names := range childList[1] { 303 | str := MatchKey([]byte(names), ShortKeys) 304 | if str != names { 305 | if count == MatchIndex { 306 | strList = append(strList, fmt.Sprintf("[%s](bg-red)<%s>", childList[0][i], str)) 307 | } else { 308 | strList = append(strList, fmt.Sprintf("[%s](bg-blue)<%s>", childList[0][i], str)) 309 | } 310 | count++ 311 | MatchList = append(MatchList, i+len(t.NameList[1])) 312 | } else { 313 | strList = append(strList, fmt.Sprintf("%s<%s>", childList[0][i], names)) 314 | } 315 | } 316 | t.Items[1] = strings.Join(strList, " ") 317 | ui.Render(t) 318 | } 319 | 320 | type UITopicList struct { 321 | ScrollList 322 | uiTab *UITab 323 | uiUser *UIUser 324 | Label string 325 | Name string 326 | Type TopicType 327 | AllTopicInfo []TopicInfo 328 | Page int 329 | } 330 | 331 | func NewTopicList(tab *UITab, user *UIUser) *UITopicList { 332 | l := &UITopicList{Label: "Topic [C-p]", Type: TopicTab, Page: 1, uiTab: tab, uiUser: user} 333 | l.ScrollList = *NewScrollList() 334 | l.BorderLabel = l.Label 335 | return l 336 | } 337 | 338 | func (l *UITopicList) UpdateLabel() { 339 | str := l.Label 340 | if len(l.Name) > 0 { 341 | str = fmt.Sprintf("%s (%s)", str, l.Name) 342 | } 343 | if len(ShortKeys) > 0 { 344 | str = fmt.Sprintf("%s (%s)", str, ShortKeys) 345 | } 346 | l.BorderLabel = str 347 | ui.Render(l) 348 | } 349 | 350 | func (l *UITopicList) MatchTopic() { 351 | count := 0 352 | MatchList = MatchList[:0] 353 | l.ResetBgColor() 354 | log.Println("+", len(l.Items), len(l.AllItems), l.Index) 355 | for i := 0; i < len(l.Items); i++ { 356 | item := l.AllItems[i+l.Index] 357 | matchStr := []byte(item)[:10] 358 | str := MatchKey(matchStr, ShortKeys) 359 | if str != string(matchStr) { 360 | if count == MatchIndex { 361 | l.SetBgColor(i, ui.ColorRed) 362 | } else { 363 | l.SetBgColor(i, ui.ColorBlue) 364 | } 365 | count++ 366 | MatchList = append(MatchList, i) 367 | l.SetItem(i, fmt.Sprintf("%s%s", str, []byte(item)[10:])) 368 | } else { 369 | l.SetItem(i, item) 370 | } 371 | } 372 | ui.Render(l) 373 | } 374 | 375 | func (l *UITopicList) Fresh(cate, node string) { 376 | l.Index = 0 377 | log.Println(cate, node) 378 | ResetMatch() 379 | l.Name = "..." 380 | l.UpdateLabel() 381 | ui.Render(l) 382 | if len(node) > 0 { 383 | l.Name = node 384 | l.Type = TopicNode 385 | l.AllTopicInfo = ParseTopicByNode(l.Name, 1) 386 | } else { 387 | l.Name = cate 388 | l.Type = TopicTab 389 | tabList := [][]string{{}, {}} 390 | l.AllTopicInfo = ParseTopicByTab(l.Name, l.uiUser.User, tabList) 391 | l.uiUser.Fresh() 392 | l.uiTab.ChildList[l.uiTab.CurrTab] = tabList 393 | l.uiTab.ResetTabList() 394 | } 395 | l.DrawTopic() 396 | l.UpdateLabel() 397 | ui.Render(l) 398 | } 399 | 400 | func (l *UITopicList) LoadNext() { 401 | if l.Type == TopicTab { 402 | // tab 不支持翻页 403 | return 404 | } 405 | l.Page++ 406 | name := l.Name 407 | log.Println(l.Name, l.Page) 408 | ResetMatch() 409 | l.Name = "..." 410 | l.UpdateLabel() 411 | ui.Render(l) 412 | l.Name = name 413 | tpList := ParseTopicByNode(l.Name, l.Page) 414 | l.AllTopicInfo = append(l.AllTopicInfo, tpList...) 415 | l.DrawTopic() 416 | l.UpdateLabel() 417 | ui.Render(l) 418 | } 419 | 420 | func randID() []byte { 421 | ret := make([]byte, 2) 422 | ret[0] = byte(rand.Int()%26) + 'a' 423 | ret[1] = byte(rand.Int()%26) + 'a' 424 | return ret 425 | } 426 | 427 | func (l *UITopicList) DrawTopic() { 428 | lst := make([]string, len(l.AllTopicInfo)) 429 | for i, info := range l.AllTopicInfo { 430 | prefix := fmt.Sprintf("<%02d> <%s> ", i, randID()) 431 | prefixWidth := rw.StringWidth(prefix) 432 | title := info.Title 433 | titleWitth := rw.StringWidth(title) 434 | var suffix string 435 | // if len(info.Time) > 0 { 436 | if l.Type == TopicTab { 437 | suffix = fmt.Sprintf("[<%d>](fg-bold,fg-blue) %s [%s](fg-green)", info.ReplyCount, info.Node, info.Author) 438 | } else { 439 | suffix = fmt.Sprintf("[<%d>](fg-bold,fg-blue) [%s](fg-green)", info.ReplyCount, info.Author) 440 | } 441 | if info.Author == "susiemaoo" { 442 | log.Println(suffix) 443 | } 444 | suffixWidth := rw.StringWidth(suffix) - rw.StringWidth("[](fg-bold,fg-blue)[](fg-green)") 445 | spaceWidth := l.InnerWidth() - 1 - (prefixWidth + suffixWidth + titleWitth) 446 | if spaceWidth < 0 { 447 | trimWidth := l.InnerWidth() - 1 - prefixWidth - suffixWidth 448 | titleRune := []rune(title) 449 | w := 0 450 | ellipWidh := rw.StringWidth("…") 451 | for i, ch := range titleRune { 452 | w += rw.RuneWidth(ch) 453 | if w > trimWidth-ellipWidh { 454 | if i > 0 { 455 | title = string(titleRune[:i]) + "…" 456 | } else { 457 | title = "" 458 | } 459 | break 460 | } 461 | } 462 | spaceWidth = 0 463 | } 464 | lst[i] = fmt.Sprintf("%s%s%s%s", prefix, title, strings.Repeat(" ", spaceWidth), suffix) 465 | } 466 | l.SetAllItems(lst) 467 | } 468 | 469 | type UIReplyList struct { 470 | ScrollList 471 | Topic *TopicInfo 472 | Reply ReplyList 473 | Label string 474 | } 475 | 476 | func NewReplyList() *UIReplyList { 477 | r := &UIReplyList{ScrollList: *NewScrollList(), Label: "Reply [C-p]"} 478 | return r 479 | } 480 | 481 | func (l *UIReplyList) UpdateLabel() { 482 | str := l.Label 483 | if l.Topic != nil && len(l.Topic.Url) > 0 { 484 | str = fmt.Sprintf("%s [%s](fg-cyan) (%s)", str, l.Reply.Lz, l.Topic.Url) 485 | } 486 | /* 487 | if len(ShortKeys) > 0 { 488 | str = fmt.Sprintf("%s (%s)", str, ShortKeys) 489 | } 490 | */ 491 | l.BorderLabel = str 492 | ui.Render(l) 493 | } 494 | 495 | func (l *UIReplyList) Fresh(topic *TopicInfo, addToHead bool) { 496 | l.Index = 0 497 | if topic != nil { 498 | l.Topic = topic 499 | } 500 | if l.Topic == nil { 501 | return 502 | } 503 | if addToHead { // 加载上一页的情况 504 | var reply ReplyList 505 | if ParseReply(l.Topic.Url, &reply) != nil { 506 | return 507 | } 508 | l.Reply.List = append(reply.List, l.Reply.List...) 509 | } else { 510 | if ParseReply(l.Topic.Url, &l.Reply) != nil { 511 | return 512 | } 513 | } 514 | log.Println("addToHead", addToHead) 515 | items := []string{"\n"} 516 | text := WrapString(l.Topic.Title, l.InnerWidth()) 517 | items = append(items, strings.Split(text, "\n")...) 518 | items = append(items, "\n") 519 | if len(l.Reply.Content) > 0 { 520 | items = append(items, strings.Repeat("=", l.InnerWidth()-1)) 521 | } 522 | for i, content := range l.Reply.Content { 523 | text := WrapString(content, l.InnerWidth()) 524 | items = append(items, strings.Split(text, "\n")...) 525 | if i != len(l.Reply.Content)-1 { 526 | items = append(items, strings.Repeat("-", l.InnerWidth()-1)) 527 | } 528 | } 529 | if len(l.Reply.Content) > 0 { 530 | items = append(items, strings.Repeat("=", l.InnerWidth()-1)) 531 | items = append(items, "\n") 532 | } 533 | for i, rep := range l.Reply.List { 534 | source := strings.Replace(rep.Source, "♥", " [♥](fg-red)", 1) 535 | if rep.Member == l.Reply.Lz { 536 | items = append(items, fmt.Sprintf("[%d](fg-blue) [%s](fg-cyan) %s", rep.Floor, rep.Member, source)) 537 | } else { 538 | items = append(items, fmt.Sprintf("[%d](fg-blue) [%s](fg-green) %s", rep.Floor, rep.Member, source)) 539 | } 540 | text := WrapString(rep.Reply, l.InnerWidth()) 541 | text = strings.Replace(text, "@"+l.Reply.Lz, fmt.Sprintf("[@%s](fg-cyan)", l.Reply.Lz), 1) 542 | items = append(items, strings.Split(text, "\n")...) 543 | if i != len(l.Reply.List)-1 { 544 | items = append(items, "\n") 545 | } 546 | } 547 | l.SetAllItems(items) 548 | ui.Render(l) 549 | } 550 | 551 | func (l *UIReplyList) LoadPrev() { 552 | if len(l.Reply.List) == 0 || l.Reply.List[0].Floor/100 == 0 { 553 | return 554 | } 555 | page := l.Reply.List[0].Floor / 100 556 | idx := strings.Index(l.Topic.Url, "#reply") 557 | if idx > -1 { 558 | l.Topic.Url = l.Topic.Url[:idx] 559 | } 560 | url := fmt.Sprintf("%s?p=%d", l.Topic.Url, page) 561 | l.Topic.Url = "..." 562 | l.UpdateLabel() 563 | l.Topic.Url = url 564 | l.Fresh(l.Topic, true) 565 | l.UpdateLabel() 566 | } 567 | -------------------------------------------------------------------------------- /v2ex.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/PuerkitoBio/goquery" 7 | requests "github.com/levigross/grequests" 8 | "golang.org/x/net/html" 9 | "log" 10 | "strconv" 11 | "strings" 12 | ) 13 | 14 | var Session *requests.Session 15 | 16 | func init() { 17 | Session = requests.NewSession(nil) 18 | } 19 | 20 | func Login(username, password string) error { 21 | 22 | resp, err := Session.Get("https://www.v2ex.com/signin", &requests.RequestOptions{ 23 | UserAgent: UserAgent, 24 | }) 25 | if err != nil { 26 | return err 27 | } 28 | if resp.StatusCode != 200 { 29 | return fmt.Errorf("resp.StatusCode=%d", resp.StatusCode) 30 | } 31 | 32 | doc, err := goquery.NewDocumentFromReader(resp) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | formData := make(map[string]string) 38 | 39 | doc.Find("input.sl").Each(func(i int, s *goquery.Selection) { 40 | if val, _ := s.Attr("type"); val == "text" { 41 | val, _ = s.Attr("name") 42 | formData[val] = username 43 | } else if val, _ := s.Attr("type"); val == "password" { 44 | val, _ = s.Attr("name") 45 | formData[val] = password 46 | } 47 | }) 48 | 49 | doc.Find("input").Each(func(i int, s *goquery.Selection) { 50 | name, h1 := s.Attr("name") 51 | value, h2 := s.Attr("value") 52 | if h1 && h2 && len(value) > 0 { 53 | formData[name] = value 54 | } 55 | }) 56 | 57 | Headers := make(map[string]string) 58 | Headers["Referer"] = "https://www.v2ex.com/signin" 59 | Headers["Content-Type"] = "application/x-www-form-urlencoded" 60 | resp, err = Session.Post("https://www.v2ex.com/signin", &requests.RequestOptions{ 61 | Data: formData, 62 | UserAgent: UserAgent, 63 | Headers: Headers, 64 | }) 65 | 66 | return err 67 | } 68 | 69 | func ParseTopicByTab(tab string, uInfo *UserInfo, tabList [][]string) (ret []TopicInfo) { 70 | url := fmt.Sprintf("https://www.v2ex.com/?tab=%s", tab) 71 | resp, err := Session.Get(url, &requests.RequestOptions{ 72 | UserAgent: UserAgent, 73 | }) 74 | if err != nil { 75 | log.Println(err) 76 | return 77 | } 78 | defer log.Println(url, "status_code", resp.StatusCode) 79 | if resp.StatusCode != 200 { 80 | return 81 | } 82 | doc, err := goquery.NewDocumentFromResponse(resp.RawResponse) 83 | if err != nil { 84 | log.Println(err) 85 | return 86 | } 87 | uInfo.Name = strings.TrimSpace(doc.Find("span.bigger a").Text()) 88 | doc.Find("a.fade").Each(func(i int, s *goquery.Selection) { 89 | if v, has := s.Attr("href"); has && v == "/notifications" { 90 | uInfo.Notify = s.Text() 91 | } 92 | }) 93 | sliverStr := doc.Find("a.balance_area").Text() 94 | sliverLst := strings.Split(sliverStr, " ") 95 | setSli := false 96 | for _, sli := range sliverLst { 97 | if len(sli) > 0 { 98 | if !setSli { 99 | uInfo.Silver, _ = strconv.Atoi(sli) 100 | setSli = true 101 | } else { 102 | uInfo.Bronze, _ = strconv.Atoi(sli) 103 | break 104 | } 105 | } 106 | } 107 | log.Println("UserInfo", uInfo) 108 | doc.Find("div.box div.cell").Each(func(i int, s *goquery.Selection) { 109 | if s.Next().HasClass("cell item") && s.Prev().HasClass("inner") { 110 | s.Find("a").Each(func(i int, s *goquery.Selection) { 111 | href, _ := s.Attr("href") 112 | hrefSplit := strings.Split(href, "/") 113 | if hrefSplit[len(hrefSplit)-2] == "go" { 114 | href = hrefSplit[len(hrefSplit)-1] 115 | tabList[1] = append(tabList[1], href) 116 | tabList[0] = append(tabList[0], s.Text()) 117 | } 118 | }) 119 | } 120 | }) 121 | doc.Find("div.cell.item").Each(func(i int, s *goquery.Selection) { 122 | topic := TopicInfo{} 123 | title := s.Find(".item_title a") 124 | topic.Title = title.Text() 125 | topic.Title = strings.Replace(topic.Title, "[", "<", -1) 126 | topic.Title = strings.Replace(topic.Title, "]", ">", -1) 127 | topic.Url, _ = title.Attr("href") 128 | topic.Url = "https://www.v2ex.com" + topic.Url 129 | info := s.Find(".topic_info").Text() 130 | info = strings.Replace(info, " ", "", -1) 131 | info = strings.Replace(info, string([]rune{0xA0}), "", -1) // 替换  132 | infoList := strings.Split(info, "•") 133 | topic.Node = s.Find("a.node").Text() 134 | topic.Author = infoList[1] 135 | if len(infoList) > 2 { 136 | topic.Time = infoList[2] 137 | if len(infoList) > 3 { 138 | topic.LastReply = infoList[3] 139 | } 140 | } 141 | replyCount := s.Find("a.count_livid").Text() 142 | if replyCount != "" { 143 | topic.ReplyCount, _ = strconv.Atoi(replyCount) 144 | } 145 | ret = append(ret, topic) 146 | }) 147 | return 148 | } 149 | 150 | func ParseTopicByNode(node string, page int) (ret []TopicInfo) { 151 | var url string 152 | if page > 1 { 153 | url = fmt.Sprintf("https://www.v2ex.com/go/%s?p=%d", node, page) 154 | } else { 155 | url = fmt.Sprintf("https://www.v2ex.com/go/%s", node) 156 | } 157 | resp, err := Session.Get(url, &requests.RequestOptions{ 158 | UserAgent: UserAgent, 159 | }) 160 | if err != nil { 161 | log.Println(err) 162 | return 163 | } 164 | defer log.Println(url, "status_code", resp.StatusCode) 165 | if resp.StatusCode != 200 { 166 | return 167 | } 168 | doc, err := goquery.NewDocumentFromReader(resp) 169 | if err != nil { 170 | log.Println(err) 171 | return 172 | } 173 | sel := doc.Find("div#TopicsNode") 174 | // log.Println(sel.Size()) 175 | sel.Find("div.cell").Each(func(i int, s *goquery.Selection) { 176 | info := s.Find(".small.fade").Text() 177 | info = strings.Replace(info, " ", "", -1) 178 | info = strings.Replace(info, string([]rune{0xA0}), "", -1) // 替换  179 | if len(info) == 0 { 180 | return 181 | } 182 | topic := TopicInfo{} 183 | title := s.Find(".item_title a") 184 | topic.Title = title.Text() 185 | topic.Title = strings.Replace(topic.Title, "[", "<", -1) 186 | topic.Title = strings.Replace(topic.Title, "]", ">", -1) 187 | topic.Url, _ = title.Attr("href") 188 | topic.Url = "https://www.v2ex.com" + topic.Url 189 | infoList := strings.Split(info, "•") 190 | topic.Node = node 191 | topic.Author = infoList[0] 192 | if len(infoList) > 2 { 193 | topic.Time = infoList[1] 194 | if len(infoList) > 3 { 195 | topic.LastReply = infoList[2] 196 | } 197 | } 198 | replyCount := s.Find("a.count_livid").Text() 199 | if replyCount != "" { 200 | topic.ReplyCount, _ = strconv.Atoi(replyCount) 201 | } 202 | ret = append(ret, topic) 203 | // log.Println(s.Find(".small.fade a.node").Text()) 204 | // log.Println(s.Find(".small.fade strong a").Text()) 205 | }) 206 | return 207 | } 208 | 209 | func ParseReply(url string, reply *ReplyList) error { 210 | *reply = ReplyList{} 211 | resp, err := Session.Get(url, &requests.RequestOptions{ 212 | UserAgent: UserAgent, 213 | }) 214 | if err != nil { 215 | return err 216 | } 217 | defer log.Println(url, "status_code", resp.StatusCode) 218 | if resp.StatusCode != 200 { 219 | return fmt.Errorf("resp.StatusCode=%d", resp.StatusCode) 220 | } 221 | doc, err := goquery.NewDocumentFromReader(resp) 222 | if err != nil { 223 | return err 224 | } 225 | 226 | head := doc.Find("div.header small.gray").Text() 227 | head = strings.Replace(head, string([]rune{0xA0}), "", -1) 228 | head = strings.Replace(head, " ", "", -1) 229 | headList := strings.Split(head, "·") 230 | if len(headList) != 3 { 231 | // 这里好像是部分帖子需要登录才能浏览 232 | // https://www.v2ex.com/t/297344#reply12 233 | return errors.New("maybe need to login") 234 | } 235 | reply.Lz = headList[0] 236 | reply.PostTime = headList[1] 237 | reply.ClickNum = headList[2] 238 | 239 | doc.Find("div.topic_content").Each(func(i int, sel *goquery.Selection) { 240 | selMD := sel.Find("div.markdown_body") 241 | contentList := []string{} 242 | if selMD.Size() > 0 { 243 | sel = selMD.Children() 244 | for _, node := range sel.Nodes { 245 | if node.Type == html.ElementNode { 246 | if node.Data == "p" { 247 | cnode := node.FirstChild 248 | pList := []string{} 249 | for cnode != nil { 250 | if cnode.Type == html.TextNode { 251 | pList = append(pList, cnode.Data) 252 | } else if cnode.Type == html.ElementNode { 253 | if cnode.Data == "a" { 254 | var href, text string 255 | for _, attr := range cnode.Attr { 256 | if attr.Key == "href" { 257 | href = attr.Val 258 | } 259 | } 260 | if len(href) > 0 && cnode.FirstChild != nil { 261 | text = cnode.FirstChild.Data 262 | } 263 | if href == text { 264 | pList = append(pList, href) 265 | } else { 266 | pList = append(pList, fmt.Sprintf("<%s>(%s)", text, href)) 267 | } 268 | } else if cnode.Data == "img" { 269 | for _, attr := range cnode.Attr { 270 | if attr.Key == "src" { 271 | pList = append(pList, attr.Val) 272 | } 273 | } 274 | } else if cnode.Data == "strong" { 275 | pList = append(pList, cnode.FirstChild.Data) 276 | } 277 | } 278 | cnode = cnode.NextSibling 279 | } 280 | contentList = append(contentList, strings.Join(pList, "")) 281 | } else if node.Data == "ol" || node.Data == "ul" { 282 | cnode := node.FirstChild 283 | idx := 1 284 | olList := []string{} 285 | for cnode != nil { 286 | if cnode.Data == "li" { 287 | if node.Data == "ol" { 288 | olList = append(olList, fmt.Sprintf("%d. %s", idx, cnode.FirstChild.Data)) 289 | } else { 290 | olList = append(olList, fmt.Sprintf("* %s", cnode.FirstChild.Data)) 291 | } 292 | idx++ 293 | } 294 | cnode = cnode.NextSibling 295 | } 296 | contentList = append(contentList, strings.Join(olList, "\n")) 297 | } else if node.Data == "img" { 298 | for _, attr := range node.Attr { 299 | if attr.Key == "src" { 300 | contentList = append(contentList, attr.Val) 301 | } 302 | } 303 | } else if node.Data[0] == 'h' && (node.Data[1] >= '1' && node.Data[1] <= '6') { 304 | var hstr string 305 | if node.FirstChild.Type == html.TextNode { 306 | hstr = node.FirstChild.Data 307 | } else if node.FirstChild.Type == html.ElementNode { 308 | if node.FirstChild.Data == "a" { 309 | var href, text string 310 | for _, attr := range node.FirstChild.Attr { 311 | if attr.Key == "href" { 312 | href = attr.Val 313 | } 314 | } 315 | if len(href) > 0 && node.FirstChild.FirstChild != nil { 316 | text = node.FirstChild.FirstChild.Data 317 | } 318 | if href == text { 319 | hstr = href 320 | } else { 321 | hstr = fmt.Sprintf("<%s>(%s)", text, href) 322 | } 323 | } 324 | } 325 | contentList = append(contentList, fmt.Sprintf("%s %s", strings.Repeat("#", int(node.Data[1]-'0')), hstr)) 326 | } 327 | } 328 | } 329 | content := strings.Join(contentList, "\n\n") 330 | content = strings.Replace(content, "[", "<", -1) 331 | content = strings.Replace(content, "]", ">", -1) 332 | reply.Content = append(reply.Content, content) 333 | } else { 334 | cnode := sel.Nodes[0].FirstChild 335 | for cnode != nil { 336 | if cnode.Type == html.TextNode { 337 | contentList = append(contentList, cnode.Data) 338 | } else if cnode.Data == "img" { 339 | for _, attr := range cnode.Attr { 340 | if attr.Key == "src" { 341 | contentList = append(contentList, attr.Val) 342 | } 343 | } 344 | } else if cnode.Data == "a" { 345 | for _, attr := range cnode.Attr { 346 | if attr.Key == "href" { 347 | contentList = append(contentList, attr.Val) 348 | break 349 | } 350 | } 351 | } 352 | cnode = cnode.NextSibling 353 | } 354 | content := strings.Join(contentList, "") 355 | content = strings.Replace(content, "[", "<", -1) 356 | content = strings.Replace(content, "]", ">", -1) 357 | reply.Content = append(reply.Content, content) 358 | } 359 | }) 360 | 361 | doc.Find("div#Main div.box").Find("div").Each(func(i int, sel *goquery.Selection) { 362 | idAttr, has := sel.Attr("id") 363 | if has && string(idAttr[:2]) == "r_" { 364 | replySel := sel.Find("div.reply_content") 365 | if len(replySel.Nodes) == 0 { 366 | return 367 | } 368 | info := ReplyInfo{} 369 | contentList := []string{} 370 | cnode := replySel.Nodes[0].FirstChild 371 | for cnode != nil { 372 | if cnode.Type == html.TextNode { 373 | contentList = append(contentList, cnode.Data) 374 | } else if cnode.Data == "img" { 375 | for _, attr := range cnode.Attr { 376 | if attr.Key == "src" { 377 | contentList = append(contentList, attr.Val) 378 | } 379 | } 380 | } else if cnode.Data == "a" { 381 | for _, attr := range cnode.Attr { 382 | if attr.Key == "href" { 383 | if strings.HasPrefix(attr.Val, "/member") { 384 | contentList = append(contentList, cnode.FirstChild.Data) 385 | } else { 386 | contentList = append(contentList, attr.Val) 387 | } 388 | break 389 | } 390 | } 391 | } 392 | cnode = cnode.NextSibling 393 | } 394 | info.Reply = strings.Join(contentList, "") 395 | info.Reply = strings.Replace(info.Reply, "[", "<", -1) 396 | info.Reply = strings.Replace(info.Reply, "]", ">", -1) 397 | info.Floor, _ = strconv.Atoi(sel.Find("span.no").Text()) 398 | info.Member = sel.Find("a.dark").Text() 399 | info.Source = sel.Find("span.fade.small").Text() 400 | reply.List = append(reply.List, info) 401 | // log.Println(info.Floor, info.Member, info.Time) 402 | } 403 | }) 404 | 405 | return nil 406 | } 407 | --------------------------------------------------------------------------------