├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── cache └── cache.go ├── cmd ├── gowatch.yml └── main.go ├── config.yml ├── config └── config.go ├── docs ├── answer.jpeg ├── screenshot.png ├── 冲顶大会_iphone6s.yml └── 百万英雄_iphone6s.yml ├── image.go ├── ocr.go ├── ocr ├── baidu.go └── tesseract.go ├── proto └── const.go ├── qanswer.go ├── screenshot.go ├── screenshot ├── android.go └── ios.go ├── search.go ├── util ├── http.go └── util.go └── vendor └── vendor.json /.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 | .DS_Store 26 | .vscode/ 27 | vendor/*/ 28 | images 29 | cmd/images 30 | cmd/*.yml 31 | cmd/cmd 32 | cmd/bin 33 | qanswer -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 silenceper 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | cd cmd && go build -o ../qanswer 3 | build_linux_amd64: 4 | cd cmd && GOOS=linux GOARCH=amd64 go build -o ../qanswer_linux_amd64 5 | build_linux_386: 6 | cd cmd && GOOS=linux GOARCH=386 go build -o ../qanswer_linux_386 7 | build_linux_arm: 8 | cd cmd && GOOS=linux GOARCH=arm go build -o ../qanswer_linux_arm 9 | build_windows_amd64: 10 | cd cmd && GOOS=windows GOARCH=amd64 go build -o ../qanswer_windows_amd64.exe 11 | build_windows_386: 12 | cd cmd && GOOS=windows GOARCH=386 go build -o ../qanswer_windows_386.exe 13 | build_darwin_amd64: 14 | cd cmd && GOOS=darwin GOARCH=amd64 go build -o ../qanswer_darwin_amd64.exe 15 | 16 | build_all:build_linux_amd64 build_linux_386 build_linux_arm build_windows_amd64 build_windows_386 build_darwin_amd64 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 答题神器 2 | 3 | 4 | 《冲顶大会》,《百万英雄》等答题游戏的答题神器,顺利吃鸡! 5 | 6 | 通过抓取手机屏幕截图经过文字识别,结合搜索引擎给出一个参考值。 7 | 8 | ![题目](./docs/screenshot.png) 9 | 10 | 分析结果: 11 | 12 | ![结果](./docs/answer.jpeg) 13 | 14 | **结果说明:** 15 | 16 | - 结果数:通过题目+答案的搜索形式在搜索引擎中的结果数量 17 | 18 | - 答案出现频率:通过搜索题目,答案在第一页结果中出现的频率 19 | 20 | 结果并不是100%的,只给出一个参考值,还需用户自己判断。理论上可支持多款APP,只需要修改`config.yml`中的题目和答案的截取位置即可。 21 | 22 | 23 | ## 安装 24 | 有两种方式: 25 | ##### 1.手动编译 26 | 27 | - 安装go环境 28 | - 将本项目放入gopath中 29 | - 通过[govendor](https://github.com/kardianos/govendor)进行依赖管理,执行`govendor sync`下载依赖 30 | - 执行`make build`会在当前目录生成`qanswer`文件 31 | 32 | ##### 2.直接下载 33 | 34 | 根据运行平台可以直接在这里下载: [releases](https://github.com/silenceper/qanswer/releases) 35 | 36 | ### 配置文件说明 37 | 38 | 默认为`./config.yml`文件,也可通过`-config`参数指定自定义路径。 39 | 40 | 执行`qanswer`时,默认读取当前目录下的`config.yml`配置文件。 41 | 42 | 各种答题类APP以及适配机型的配置:[机型配置](./docs) 43 | 44 | **配置参数说明:** 45 | 46 | ``` 47 | # 是否开始调试模式 48 | debug: false 49 | # 对应的设备类型:ios or android 50 | device: ios 51 | # 使用的ocr工具:baidu or tesseract 52 | ocr_type: baidu 53 | # ios 设备连接wda的地址 54 | wda_address: '127.0.0.1:8100' 55 | # 截取题目的位置 : 56 | question_x: 30 57 | question_y: 310 58 | question_w: 650 59 | question_h: 135 60 | # 截取答案的位置 61 | answer_x: 30 62 | answer_y: 500 63 | answer_w: 680 64 | answer_h: 370 65 | #当选用baidu ocr时,需要执行api_key和secret_key 66 | baidu_api_key: "xxx...." 67 | baidu_secret_key: "xxx...." 68 | 69 | ``` 70 | 71 | ### iOS 72 | `device: ios` 73 | 74 | 75 | - 安装WDA :[iOS 真机如何安装 WebDriverAgent](https://testerhome.com/topics/7220) 76 | - 编译或直接下载编译好的`qanswer`文件 77 | - 修改配置文件:根据设备尺寸以及答题APP,修改题目和答案截取位置参数,并且指定`wda_address` WDA 连接地址 78 | - 执行`./qanswer`,也可以通过`-config`参数指定配置文件地址 79 | - 按空格键开始 80 | 81 | 82 | ### Android 83 | `device: android` 84 | 85 | ##### 安装ADB 86 | 87 | 安装完后插入安卓设备且安卓已打开 USB 调试模式,终端输入 `adb devices `,显示设备号则表示成功。 88 | 89 | ``` 90 | List of devices attached 91 | MWUBB17518200733 device 92 | ``` 93 | 94 | 95 | 96 | ## 百度ocr 97 | `ocr_type: baidu` 98 | 99 | 如果使用[百度ocr](https://cloud.baidu.com/product/ocr.html),则需要预先申请api key 和secret key ,并且免费的额度有限 100 | 101 | ## tesseract 102 | `ocr_type: tesseract` 103 | 104 | 安装tesseract以及简体中文包。 105 | 106 | 以mac:为例 107 | 108 | ``` 109 | brew install tesseract 110 | cd /usr/local/Cellar/tesseract/{version}/share/tessdata 111 | wget https://github.com/tesseract-ocr/tessdata/raw/master/chi_sim.traineddata 112 | ``` 113 | 114 | 其他系统的安装说明:https://github.com/tesseract-ocr/tesseract/wiki 115 | 116 | 117 | 118 | ### TODO: 119 | 120 | - 不同机型,不同答题app的配置参数 121 | - 支持google搜索 122 | -------------------------------------------------------------------------------- /cache/cache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "time" 5 | 6 | cache "github.com/patrickmn/go-cache" 7 | ) 8 | 9 | func init() { 10 | //初始化cache 11 | GetCache() 12 | } 13 | 14 | var c *cache.Cache 15 | 16 | //GetCache 获取cache对象 17 | func GetCache() *cache.Cache { 18 | if c != nil { 19 | return c 20 | } 21 | c = cache.New(5*time.Minute, 10*time.Minute) 22 | return c 23 | } 24 | -------------------------------------------------------------------------------- /cmd/gowatch.yml: -------------------------------------------------------------------------------- 1 | output: ./bin/qanswer 2 | watch_paths: 3 | - ../ 4 | -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/silenceper/qanswer" 4 | 5 | func main() { 6 | qanswer.Run() 7 | } 8 | -------------------------------------------------------------------------------- /config.yml: -------------------------------------------------------------------------------- 1 | # 冲顶大会 iphone6s 2 | debug: false 3 | device: ios 4 | ocr_type: tesseract 5 | wda_address: '127.0.0.1:8100' 6 | question_x: 30 7 | question_y: 290 8 | question_w: 650 9 | question_h: 190 10 | answer_x: 30 11 | answer_y: 500 12 | answer_w: 680 13 | answer_h: 370 14 | baidu_api_key: xxx... 15 | baidu_secret_key: xxx... 16 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "io/ioutil" 5 | "path/filepath" 6 | 7 | yaml "gopkg.in/yaml.v1" 8 | ) 9 | 10 | //Config 全局配置 11 | type Config struct { 12 | Debug bool `yaml:"debug"` 13 | Device string `yaml:"device"` 14 | OcrType string `yaml:"ocr_type"` 15 | WdaAddress string `yaml:"wda_address"` 16 | 17 | //baidu ocr 18 | BaiduAPIKey string `yaml:"baidu_api_key"` 19 | BaiduSecretKey string `yaml:"baidu_secret_key"` 20 | 21 | //截图题目位置 22 | QuestionX int `yaml:"question_x"` 23 | QuestionY int `yaml:"question_y"` 24 | QuestionW int `yaml:"question_w"` 25 | QuestionH int `yaml:"question_h"` 26 | 27 | //截取答案位置 28 | AnswerX int `yaml:"answer_x"` 29 | AnswerY int `yaml:"answer_y"` 30 | AnswerW int `yaml:"answer_w"` 31 | AnswerH int `yaml:"answer_h"` 32 | } 33 | 34 | var cfg *Config 35 | 36 | var cfgFilename = "./config.yml" 37 | 38 | //SetConfigFile 设置配置文件地址 39 | func SetConfigFile(path string) { 40 | cfgFilename = path 41 | } 42 | 43 | //GetConfig 解析配置 44 | func GetConfig() *Config { 45 | if cfg != nil { 46 | return cfg 47 | } 48 | filename, _ := filepath.Abs(cfgFilename) 49 | yamlFile, err := ioutil.ReadFile(filename) 50 | 51 | if err != nil { 52 | panic(err) 53 | } 54 | var c *Config 55 | err = yaml.Unmarshal(yamlFile, &c) 56 | if err != nil { 57 | panic(err) 58 | } 59 | cfg = c 60 | return cfg 61 | } 62 | -------------------------------------------------------------------------------- /docs/answer.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/silenceper/qanswer/fb5aa2ba5fc24271137dd2fa26e4f0c4b576ef54/docs/answer.jpeg -------------------------------------------------------------------------------- /docs/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/silenceper/qanswer/fb5aa2ba5fc24271137dd2fa26e4f0c4b576ef54/docs/screenshot.png -------------------------------------------------------------------------------- /docs/冲顶大会_iphone6s.yml: -------------------------------------------------------------------------------- 1 | debug: false 2 | device: ios 3 | ocr_type: tesseract 4 | wda_address: '127.0.0.1:8100' 5 | question_x: 30 6 | question_y: 290 7 | question_w: 650 8 | question_h: 190 9 | answer_x: 30 10 | answer_y: 500 11 | answer_w: 680 12 | answer_h: 370 13 | baidu_api_key: xxx... 14 | baidu_secret_key: xxx... 15 | -------------------------------------------------------------------------------- /docs/百万英雄_iphone6s.yml: -------------------------------------------------------------------------------- 1 | debug: true 2 | device: ios 3 | ocr_type: tesseract 4 | wda_address: '127.0.0.1:8100' 5 | question_x: 30 6 | question_y: 250 7 | question_w: 660 8 | question_h: 135 9 | answer_x: 30 10 | answer_y: 420 11 | answer_w: 680 12 | answer_h: 430 13 | baidu_api_key: xxxxx 14 | baidu_secret_key: xxxx 15 | -------------------------------------------------------------------------------- /image.go: -------------------------------------------------------------------------------- 1 | package qanswer 2 | 3 | import ( 4 | "fmt" 5 | "image" 6 | "image/draw" 7 | "sync" 8 | 9 | "github.com/ngaut/log" 10 | "github.com/silenceper/qanswer/config" 11 | "github.com/silenceper/qanswer/proto" 12 | "github.com/silenceper/qanswer/util" 13 | ) 14 | 15 | func saveImage(png image.Image, cfg *config.Config) error { 16 | go func() { 17 | screenshotPath := fmt.Sprintf("%sscreenshot.png", proto.ImagePath) 18 | err := util.SavePNG(screenshotPath, png) 19 | if err != nil { 20 | log.Errorf("保存截图失败,%v", err) 21 | } 22 | log.Debugf("保存完整截图成功,%s", screenshotPath) 23 | }() 24 | 25 | //裁剪图片 26 | questionImg, answerImg, err := cutImage(png, cfg) 27 | if err != nil { 28 | return fmt.Errorf("截图失败,%v", err) 29 | } 30 | 31 | var wg sync.WaitGroup 32 | wg.Add(2) 33 | 34 | go func() { 35 | defer wg.Done() 36 | pic := thresholdingImage(questionImg) 37 | err = util.SavePNG(proto.QuestionImage, pic) 38 | if err != nil { 39 | log.Errorf("保存question截图失败,%v", err) 40 | } 41 | log.Debugf("保存question截图成功") 42 | }() 43 | 44 | go func() { 45 | defer wg.Done() 46 | pic := thresholdingImage(answerImg) 47 | err = util.SavePNG(proto.AnswerImage, pic) 48 | if err != nil { 49 | log.Errorf("保存answer截图失败,%v", err) 50 | } 51 | log.Debugf("保存answer截图成功") 52 | }() 53 | 54 | wg.Wait() 55 | return nil 56 | } 57 | 58 | //裁剪图片 59 | func cutImage(src image.Image, cfg *config.Config) (questionImg image.Image, answerImg image.Image, err error) { 60 | questionImg, err = util.CutImage(src, cfg.QuestionX, cfg.QuestionY, cfg.QuestionW, cfg.QuestionH) 61 | // questionImg, err = util.CutImage(src, 30, 250, 660, 135) 62 | if err != nil { 63 | return 64 | } 65 | 66 | answerImg, err = util.CutImage(src, cfg.AnswerX, cfg.AnswerY, cfg.AnswerW, cfg.AnswerH) 67 | // answerImg, err = util.CutImage(src, 30, 420, 690, 430) 68 | if err != nil { 69 | return 70 | } 71 | return 72 | } 73 | 74 | //二值化图片 75 | func thresholdingImage(img image.Image) image.Image { 76 | size := img.Bounds() 77 | pic := image.NewGray(size) 78 | draw.Draw(pic, size, img, size.Min, draw.Src) 79 | 80 | width := size.Dx() 81 | height := size.Dy() 82 | zft := make([]int, 256) //用于保存每个像素的数量,注意这里用了int类型,在某些图像上可能会溢出。 83 | var idx int 84 | for i := 0; i < width; i++ { 85 | for j := 0; j < height; j++ { 86 | idx = i*height + j 87 | zft[pic.Pix[idx]]++ //image对像有一个Pix属性,它是一个slice,里面保存的是所有像素的数据。 88 | } 89 | } 90 | 91 | fz := getOSTUThreshold(zft) 92 | for i := 0; i < len(pic.Pix); i++ { 93 | if int(pic.Pix[i]) > fz { 94 | pic.Pix[i] = 255 95 | } else { 96 | pic.Pix[i] = 0 97 | } 98 | } 99 | return pic 100 | } 101 | 102 | //getOSTUThreshold OSTU大律法 计算阀值 103 | func getOSTUThreshold(HistGram []int) int { 104 | var Y, Amount int 105 | var PixelBack, PixelFore, PixelIntegralBack, PixelIntegralFore, PixelIntegral int 106 | var OmegaBack, OmegaFore, MicroBack, MicroFore, SigmaB, Sigma float64 // 类间方差; 107 | var MinValue, MaxValue int 108 | var Threshold int 109 | for MinValue = 0; MinValue < 256 && HistGram[MinValue] == 0; MinValue++ { 110 | } 111 | for MaxValue = 255; MaxValue > MinValue && HistGram[MinValue] == 0; MaxValue-- { 112 | } 113 | if MaxValue == MinValue { 114 | return MaxValue // 图像中只有一个颜色 115 | } 116 | if MinValue+1 == MaxValue { 117 | return MinValue // 图像中只有二个颜色 118 | } 119 | for Y = MinValue; Y <= MaxValue; Y++ { 120 | Amount += HistGram[Y] // 像素总数 121 | } 122 | PixelIntegral = 0 123 | for Y = MinValue; Y <= MaxValue; Y++ { 124 | PixelIntegral += HistGram[Y] * Y 125 | } 126 | SigmaB = -1 127 | for Y = MinValue; Y < MaxValue; Y++ { 128 | PixelBack = PixelBack + HistGram[Y] 129 | PixelFore = Amount - PixelBack 130 | OmegaBack = float64(PixelBack) / float64(Amount) 131 | OmegaFore = float64(PixelFore) / float64(Amount) 132 | PixelIntegralBack += HistGram[Y] * Y 133 | PixelIntegralFore = PixelIntegral - PixelIntegralBack 134 | MicroBack = float64(PixelIntegralBack) / float64(PixelBack) 135 | MicroFore = float64(PixelIntegralFore) / float64(PixelFore) 136 | Sigma = OmegaBack * OmegaFore * (MicroBack - MicroFore) * (MicroBack - MicroFore) 137 | if Sigma > SigmaB { 138 | SigmaB = Sigma 139 | Threshold = Y 140 | } 141 | } 142 | return Threshold 143 | } 144 | -------------------------------------------------------------------------------- /ocr.go: -------------------------------------------------------------------------------- 1 | package qanswer 2 | 3 | import ( 4 | "github.com/silenceper/qanswer/config" 5 | "github.com/silenceper/qanswer/ocr" 6 | "github.com/silenceper/qanswer/proto" 7 | ) 8 | 9 | //Ocr ocr 识别图片文字 10 | type Ocr interface { 11 | GetText(imgPath string) (string, error) 12 | } 13 | 14 | //NewOcr 使用哪种ocr识别 15 | func NewOcr(cfg *config.Config) Ocr { 16 | if cfg.OcrType == proto.OcrTesseract { 17 | return ocr.NewTesseract(cfg) 18 | } 19 | return ocr.NewBaidu(cfg) 20 | } 21 | -------------------------------------------------------------------------------- /ocr/baidu.go: -------------------------------------------------------------------------------- 1 | package ocr 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/url" 7 | "strings" 8 | "sync" 9 | "time" 10 | 11 | "github.com/silenceper/qanswer/cache" 12 | "github.com/silenceper/qanswer/config" 13 | "github.com/silenceper/qanswer/proto" 14 | "github.com/silenceper/qanswer/util" 15 | ) 16 | 17 | //Baidu baidu ocr api 18 | type Baidu struct { 19 | apiKey string 20 | secretKey string 21 | 22 | sync.RWMutex 23 | } 24 | 25 | type accessTokenRes struct { 26 | AccessToken string `json:"access_token"` 27 | ExpiresIn int32 `json:"expires_in"` 28 | } 29 | 30 | //wordsResults 匹配 31 | type wordsResults struct { 32 | WordsNum int32 `json:"words_result_num"` 33 | WordsResult []struct { 34 | Words string `json:"words"` 35 | } `json:"words_result"` 36 | } 37 | 38 | //NewBaidu new 39 | func NewBaidu(cfg *config.Config) *Baidu { 40 | baidu := new(Baidu) 41 | baidu.apiKey = cfg.BaiduAPIKey 42 | baidu.secretKey = cfg.BaiduSecretKey 43 | return baidu 44 | } 45 | 46 | //GetText 识别图片中的文字 47 | func (baidu *Baidu) GetText(imgPath string) (string, error) { 48 | accessToken, err := baidu.getAccessToken() 49 | if err != nil { 50 | return "", err 51 | } 52 | base64Data, err := util.OpenImageToBase64(imgPath) 53 | if err != nil { 54 | return "", err 55 | } 56 | uri := fmt.Sprintf("https://aip.baidubce.com/rest/2.0/ocr/v1/general_basic?access_token=%s", accessToken) 57 | 58 | postData := url.Values{} 59 | postData.Add("image", base64Data) 60 | body, err := util.PostForm(uri, postData, 6) 61 | if err != nil { 62 | return "", err 63 | } 64 | wordResults := new(wordsResults) 65 | err = json.Unmarshal(body, wordResults) 66 | if err != nil { 67 | return "", err 68 | } 69 | var text string 70 | for _, words := range wordResults.WordsResult { 71 | text = fmt.Sprintf("%s\n%s", text, strings.TrimSpace(words.Words)) 72 | } 73 | text = strings.TrimLeft(text, "\n") 74 | return text, nil 75 | } 76 | 77 | func (baidu *Baidu) getAccessToken() (accessToken string, err error) { 78 | baidu.Lock() 79 | defer baidu.Unlock() 80 | 81 | c := cache.GetCache() 82 | cacheAccessToken, found := c.Get(proto.BaiduAccessTokenKey) 83 | if found { 84 | accessToken = cacheAccessToken.(string) 85 | return 86 | } 87 | uri := fmt.Sprintf("https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=%s&client_secret=%s", baidu.apiKey, baidu.secretKey) 88 | body, e := util.PostForm(uri, nil, 5) 89 | if e != nil { 90 | err = e 91 | return 92 | } 93 | res := new(accessTokenRes) 94 | err = json.Unmarshal(body, res) 95 | if err != nil { 96 | return 97 | } 98 | accessToken = res.AccessToken 99 | if accessToken != "" { 100 | //set cache 101 | c.Set(proto.BaiduAccessTokenKey, accessToken, time.Second*time.Duration((res.ExpiresIn-100))) 102 | } 103 | 104 | return 105 | } 106 | -------------------------------------------------------------------------------- /ocr/tesseract.go: -------------------------------------------------------------------------------- 1 | package ocr 2 | 3 | import ( 4 | "os/exec" 5 | 6 | "github.com/silenceper/qanswer/config" 7 | ) 8 | 9 | //Tesseract tesseract 识别 10 | type Tesseract struct{} 11 | 12 | //NewTesseract new 13 | func NewTesseract(cfg *config.Config) *Tesseract { 14 | return new(Tesseract) 15 | } 16 | 17 | //GetText 根据图片路径获取识别文字 18 | func (tesseract *Tesseract) GetText(imgPath string) (string, error) { 19 | body, err := exec.Command("tesseract", imgPath, "stdout", "-l", "chi_sim").Output() 20 | if err != nil { 21 | return "", err 22 | } 23 | return string(body), nil 24 | } 25 | -------------------------------------------------------------------------------- /proto/const.go: -------------------------------------------------------------------------------- 1 | package proto 2 | 3 | const ( 4 | DeviceiOS = "ios" 5 | DeviceAndroid = "android" 6 | 7 | OcrTesseract = "tesseract" 8 | OcrBaidu = "baidu" 9 | 10 | ImagePath = "./images/" 11 | QuestionImage = ImagePath + "question.png" 12 | AnswerImage = ImagePath + "answer.png" 13 | 14 | BaiduAccessTokenKey = "qanswer_baidu_access_token" 15 | ) 16 | -------------------------------------------------------------------------------- /qanswer.go: -------------------------------------------------------------------------------- 1 | package qanswer 2 | 3 | import ( 4 | "flag" 5 | "regexp" 6 | "strings" 7 | "sync" 8 | "time" 9 | 10 | "github.com/fatih/color" 11 | "github.com/ngaut/log" 12 | termbox "github.com/nsf/termbox-go" 13 | "github.com/silenceper/qanswer/config" 14 | "github.com/silenceper/qanswer/proto" 15 | "github.com/silenceper/qanswer/util" 16 | ) 17 | 18 | var cfgFilename = flag.String("config", "./config.yml", "配置文件路径") 19 | 20 | func init() { 21 | flag.Parse() 22 | } 23 | 24 | //Run start run 25 | func Run() { 26 | config.SetConfigFile(*cfgFilename) 27 | 28 | cfg := config.GetConfig() 29 | err := util.MkDirIfNotExist(proto.ImagePath) 30 | if err != nil { 31 | panic(err) 32 | } 33 | err = termbox.Init() 34 | if err != nil { 35 | panic(err) 36 | } 37 | defer termbox.Close() 38 | 39 | if !cfg.Debug { 40 | log.SetLevel(log.LOG_LEVEL_INFO) 41 | } 42 | 43 | color.Cyan("配置文件:%s", *cfgFilename) 44 | color.Cyan("设备:%s; 图片识别方式:%s", cfg.Device, cfg.OcrType) 45 | color.Yellow("\n请按空格键开始搜索答案...") 46 | 47 | Loop: 48 | for { 49 | switch ev := termbox.PollEvent(); ev.Type { 50 | case termbox.EventKey: 51 | switch ev.Key { 52 | case termbox.KeySpace: 53 | answerQuestion(cfg) 54 | color.Yellow("\n\n请按空格键开始搜索答案...") 55 | default: 56 | break Loop 57 | } 58 | } 59 | } 60 | 61 | } 62 | 63 | func answerQuestion(cfg *config.Config) { 64 | start := time.Now() 65 | color.Cyan("正在开始搜索....\n") 66 | //区分ios 或android 获取图像 67 | screenshot := NewScreenshot(cfg) 68 | png, err := screenshot.GetImage() 69 | if err != nil { 70 | log.Errorf("获取截图失败,%v", err) 71 | return 72 | } 73 | err = saveImage(png, cfg) 74 | if err != nil { 75 | log.Errorf("保存图片失败,%v", err) 76 | return 77 | } 78 | 79 | //识别文字 80 | ocr := NewOcr(cfg) 81 | var wg sync.WaitGroup 82 | wg.Add(2) 83 | 84 | var questionText string 85 | go func() { 86 | defer wg.Done() 87 | //TIPS: 去除第一个数字 1-9 题目标号等干扰字符 88 | //虽然有12个数字,但是 10-12 与最后的数字识别混在一起了 89 | questionText, err = ocr.GetText(proto.QuestionImage) 90 | replaceRe, _ := regexp.Compile(`^[1-9]?\'?\.?`) 91 | questionText = replaceRe.ReplaceAllString(questionText, "") 92 | if err != nil { 93 | log.Errorf("识别题目失败,%v", err) 94 | return 95 | } 96 | questionText = processQuestion(questionText) 97 | }() 98 | 99 | var answerArr []string 100 | go func() { 101 | defer wg.Done() 102 | answerText, err := ocr.GetText(proto.AnswerImage) 103 | if err != nil { 104 | log.Errorf("识别答案失败,%v", err) 105 | return 106 | } 107 | answerArr = processAnswer(answerText) 108 | }() 109 | wg.Wait() 110 | 111 | if cfg.Debug { 112 | color.Yellow("识别题目:") 113 | color.Green("%s", questionText) 114 | color.Yellow("识别答案:") 115 | color.Green("%v", answerArr) 116 | } 117 | 118 | //搜索答案并显示 119 | result := GetSearchResult(questionText, answerArr) 120 | for engine, answerResult := range result { 121 | color.Red("================%s搜索==============", engine) 122 | color.Cyan("%s \n", questionText) 123 | color.Yellow("答案:") 124 | for key, val := range answerResult { 125 | color.Green("%s : 结果总数 %d , 答案出现频率: %d", answerArr[key], val.Sum, val.Freq) 126 | } 127 | color.Red("======================================") 128 | } 129 | color.Cyan("\n耗时:%v", time.Now().Sub(start)) 130 | } 131 | 132 | func processQuestion(text string) string { 133 | log.Debug(text) 134 | text = strings.Replace(text, "\n", "", -1) 135 | text = strings.Replace(text, "\r", "", -1) 136 | 137 | //去除编号 138 | re, _ := regexp.Compile("\\d\\.") 139 | text = re.ReplaceAllString(text, "") 140 | return text 141 | } 142 | 143 | func processAnswer(text string) []string { 144 | log.Debug(text) 145 | text = strings.Replace(text, " ", "", -1) 146 | arr := strings.Split(text, "\n") 147 | //去除空白 148 | textArr := []string{} 149 | for _, val := range arr { 150 | if strings.TrimSpace(val) == "" { 151 | continue 152 | } 153 | textArr = append(textArr, val) 154 | } 155 | return textArr 156 | } 157 | -------------------------------------------------------------------------------- /screenshot.go: -------------------------------------------------------------------------------- 1 | package qanswer 2 | 3 | import ( 4 | "image" 5 | 6 | "github.com/silenceper/qanswer/config" 7 | "github.com/silenceper/qanswer/proto" 8 | "github.com/silenceper/qanswer/screenshot" 9 | ) 10 | 11 | //Screenshot 获取屏幕截图 12 | type Screenshot interface { 13 | GetImage() (image.Image, error) 14 | } 15 | 16 | //NewScreenshot new 17 | func NewScreenshot(cfg *config.Config) Screenshot { 18 | if cfg.Device == proto.DeviceiOS { 19 | return screenshot.NewIOS(cfg) 20 | } 21 | return screenshot.NewAndroid(cfg) 22 | } 23 | -------------------------------------------------------------------------------- /screenshot/android.go: -------------------------------------------------------------------------------- 1 | package screenshot 2 | 3 | import ( 4 | "image" 5 | "os/exec" 6 | 7 | "github.com/silenceper/qanswer/config" 8 | "github.com/silenceper/qanswer/proto" 9 | "github.com/silenceper/qanswer/util" 10 | ) 11 | 12 | //Android android 13 | type Android struct{} 14 | 15 | //NewAndroid new 16 | func NewAndroid(cfg *config.Config) *Android { 17 | return new(Android) 18 | } 19 | 20 | //GetImage 通过adb获取截图 21 | func (android *Android) GetImage() (img image.Image, err error) { 22 | err = exec.Command("adb", "shell", "screencap", "-p", "/sdcard/screenshot.png").Run() 23 | if err != nil { 24 | return 25 | } 26 | originImagePath := proto.ImagePath + "origin.png" 27 | err = exec.Command("adb", "pull", "/sdcard/screenshot.png", originImagePath).Run() 28 | if err != nil { 29 | return 30 | } 31 | img, err = util.OpenPNG(originImagePath) 32 | return 33 | } 34 | -------------------------------------------------------------------------------- /screenshot/ios.go: -------------------------------------------------------------------------------- 1 | package screenshot 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "encoding/json" 7 | "fmt" 8 | "image" 9 | "image/png" 10 | 11 | "github.com/silenceper/qanswer/config" 12 | "github.com/silenceper/qanswer/util" 13 | ) 14 | 15 | //IOS 获取iOS截图 16 | type IOS struct { 17 | wdaAddress string 18 | } 19 | 20 | type screenshotRes struct { 21 | Value string `json:"value"` 22 | SessionID string `json:"sessionId"` 23 | Status int `json:"status"` 24 | } 25 | 26 | //NewIOS new 27 | func NewIOS(cfg *config.Config) *IOS { 28 | ios := new(IOS) 29 | ios.wdaAddress = cfg.WdaAddress 30 | if ios.wdaAddress == "" { 31 | panic("请指定 wda 连接地址") 32 | } 33 | return ios 34 | } 35 | 36 | //GetImage 返回图片生成的路径 37 | func (ios *IOS) GetImage() (img image.Image, err error) { 38 | body, e := util.HTTPGet(fmt.Sprintf("http://%s/screenshot", ios.wdaAddress), 3) 39 | if e != nil { 40 | err = fmt.Errorf("WebDriverAgentRunner 连接失败, err=%v", e) 41 | return 42 | } 43 | 44 | res := new(screenshotRes) 45 | e = json.Unmarshal(body, res) 46 | if err != nil { 47 | err = fmt.Errorf("WebDriverAgentRunner 响应数据异常,请检查 WebDriverAgentRunner 运行状态, err=%v", e) 48 | return 49 | } 50 | pngValue, e := base64.StdEncoding.DecodeString(res.Value) 51 | if err != nil { 52 | err = fmt.Errorf("图片解码失败, err=%v", e) 53 | return 54 | } 55 | 56 | src, err := png.Decode(bytes.NewReader(pngValue)) 57 | if err != nil { 58 | err = fmt.Errorf("图片解码失败, err=%v", e) 59 | return 60 | } 61 | img = src 62 | return 63 | } 64 | -------------------------------------------------------------------------------- /search.go: -------------------------------------------------------------------------------- 1 | package qanswer 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "regexp" 7 | "strings" 8 | "sync" 9 | 10 | "github.com/ngaut/log" 11 | "github.com/silenceper/qanswer/util" 12 | ) 13 | 14 | //SearchResult 搜索总数跟频率 15 | type SearchResult struct { 16 | Sum int32 17 | Freq int32 18 | } 19 | 20 | //GetSearchResult 返回各个搜索引擎返回的结果 21 | func GetSearchResult(question string, answers []string) map[string][]*SearchResult { 22 | if question == "" { 23 | return nil 24 | } 25 | res := make(map[string][]*SearchResult) 26 | res["百度"] = baiduSearch(question, answers) 27 | return res 28 | } 29 | 30 | func baiduSearch(question string, answers []string) (result []*SearchResult) { 31 | resultMap := make(map[string]*SearchResult, len(answers)) 32 | 33 | //搜索题目 34 | searchURL := fmt.Sprintf("http://www.baidu.com/s?wd=%s", url.QueryEscape(question)) 35 | questionBody, err := util.HTTPGet(searchURL, 5) 36 | if err != nil { 37 | log.Errorf("search question:%s error", question) 38 | return 39 | } 40 | 41 | var wg sync.WaitGroup 42 | 43 | for k, answer := range answers { 44 | answer = plainAnswer(answer) 45 | answers[k] = answer 46 | 47 | wg.Add(1) 48 | go func(answer string) { 49 | defer wg.Done() 50 | searchResult := new(SearchResult) 51 | //题目搜索结果中包含的答案的数量 52 | searchResult.Freq = int32(strings.Count(string(questionBody), answer)) 53 | 54 | //题目+结果搜索的总数 55 | keyword := fmt.Sprintf("%s %s", question, answer) 56 | searchURL := fmt.Sprintf("http://www.baidu.com/s?wd=%s", url.QueryEscape(keyword)) 57 | body, err := util.HTTPGet(searchURL, 5) 58 | if err != nil { 59 | log.Errorf("search %s error", answer) 60 | } else { 61 | countRe, _ := regexp.Compile(`百度为您找到相关结果约([\d\,]+)`) 62 | result := countRe.FindAllStringSubmatch(string(body), -1) 63 | if len(result) > 0 { 64 | sum := result[0][1] 65 | sum = strings.Replace(sum, ",", "", -1) 66 | searchResult.Sum = util.MustInt32(sum) 67 | } 68 | } 69 | resultMap[answer] = searchResult 70 | }(answer) 71 | } 72 | wg.Wait() 73 | 74 | //将map转为slice 方便顺序输出 75 | for _, answer := range answers { 76 | result = append(result, resultMap[answer]) 77 | } 78 | return result 79 | } 80 | 81 | //plainAnswer 去除答案中的 《》等字符 82 | func plainAnswer(answer string) string { 83 | answer = strings.TrimPrefix(answer, "《") 84 | answer = strings.TrimSuffix(answer, "》") 85 | return answer 86 | } 87 | -------------------------------------------------------------------------------- /util/http.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "net/url" 9 | "time" 10 | ) 11 | 12 | //HTTPGet 代理请求 13 | func HTTPGet(uri string, timeout int32) ([]byte, error) { 14 | return HTTPGetCustom(uri, timeout, "", nil) 15 | } 16 | 17 | //HTTPGetCustom custom http client 18 | func HTTPGetCustom(uri string, timeout int32, proxyURL string, header http.Header) ([]byte, error) { 19 | tr := &http.Transport{ 20 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, 21 | } 22 | if proxyURL != "" { 23 | proxy, _ := url.Parse(proxyURL) 24 | tr.Proxy = http.ProxyURL(proxy) 25 | } 26 | client := &http.Client{ 27 | Transport: tr, 28 | Timeout: time.Second * time.Duration(timeout), 29 | } 30 | req, err := http.NewRequest("GET", uri, nil) 31 | if err != nil { 32 | return nil, err 33 | } 34 | req.Header = header 35 | response, err := client.Do(req) 36 | if err != nil { 37 | return nil, err 38 | } 39 | defer response.Body.Close() 40 | if response.StatusCode != http.StatusOK { 41 | return nil, fmt.Errorf("http get error : uri=%v , statusCode=%v", uri, response.StatusCode) 42 | } 43 | return ioutil.ReadAll(response.Body) 44 | } 45 | 46 | //PostForm PostForm 47 | func PostForm(uri string, data url.Values, timeout int32) ([]byte, error) { 48 | tr := &http.Transport{ 49 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, 50 | } 51 | client := &http.Client{ 52 | Transport: tr, 53 | Timeout: time.Second * time.Duration(timeout), 54 | } 55 | response, err := client.PostForm(uri, data) 56 | if err != nil { 57 | return nil, err 58 | } 59 | defer response.Body.Close() 60 | 61 | if response.StatusCode != http.StatusOK { 62 | return nil, fmt.Errorf("http post error : uri=%v , statusCode=%v", uri, response.StatusCode) 63 | } 64 | return ioutil.ReadAll(response.Body) 65 | } 66 | -------------------------------------------------------------------------------- /util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "encoding/base64" 5 | "errors" 6 | "image" 7 | "image/png" 8 | "io/ioutil" 9 | "os" 10 | "strconv" 11 | ) 12 | 13 | // MkDirIfNotExist 如果指定文件夹不存在则创建 14 | func MkDirIfNotExist(path string) error { 15 | if _, err := os.Stat(path); os.IsNotExist(err) { 16 | return os.MkdirAll(path, 0755) 17 | } 18 | return nil 19 | } 20 | 21 | //OpenImageToBase64 OpenImageToBase64 22 | func OpenImageToBase64(filename string) (string, error) { 23 | f, err := ioutil.ReadFile(filename) 24 | if err != nil { 25 | return "", err 26 | } 27 | return base64.StdEncoding.EncodeToString(f), nil 28 | } 29 | 30 | //OpenPNG 打开png图片 31 | func OpenPNG(filename string) (image.Image, error) { 32 | f, err := os.Open(filename) 33 | if err != nil { 34 | return nil, err 35 | } 36 | defer f.Close() 37 | return png.Decode(f) 38 | } 39 | 40 | //SavePNG 保存png图片 41 | func SavePNG(filename string, pic image.Image) error { 42 | f, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE, 0600) 43 | if err != nil { 44 | return err 45 | } 46 | defer f.Close() 47 | return png.Encode(f, pic) 48 | } 49 | 50 | //CutImage 裁剪图片 51 | func CutImage(src image.Image, x, y, w, h int) (image.Image, error) { 52 | var subImg image.Image 53 | 54 | if rgbImg, ok := src.(*image.YCbCr); ok { 55 | subImg = rgbImg.SubImage(image.Rect(x, y, x+w, y+h)).(*image.YCbCr) //图片裁剪x0 y0 x1 y1 56 | } else if rgbImg, ok := src.(*image.RGBA); ok { 57 | subImg = rgbImg.SubImage(image.Rect(x, y, x+w, y+h)).(*image.RGBA) //图片裁剪x0 y0 x1 y1 58 | } else if rgbImg, ok := src.(*image.NRGBA); ok { 59 | subImg = rgbImg.SubImage(image.Rect(x, y, x+w, y+h)).(*image.NRGBA) //图片裁剪x0 y0 x1 y1 60 | } else { 61 | return subImg, errors.New("图片解码失败") 62 | } 63 | 64 | return subImg, nil 65 | } 66 | 67 | //MustInt32 MustInt32 68 | func MustInt32(str string) int32 { 69 | i, err := strconv.ParseInt(str, 10, 32) 70 | if err != nil { 71 | return int32(0) 72 | } 73 | return int32(i) 74 | } 75 | -------------------------------------------------------------------------------- /vendor/vendor.json: -------------------------------------------------------------------------------- 1 | { 2 | "comment": "", 3 | "ignore": "test", 4 | "package": [ 5 | { 6 | "checksumSHA1": "p5z5hdUt68Z3tK7Is+yLGrCNzoA=", 7 | "path": "github.com/fatih/color", 8 | "revision": "5df930a27be2502f99b292b7cc09ebad4d0891f4", 9 | "revisionTime": "2017-09-26T11:14:11Z" 10 | }, 11 | { 12 | "checksumSHA1": "MrqRXuqj5suGXqcRUUwi+YA7PmE=", 13 | "origin": "github.com/fatih/color/vendor/github.com/mattn/go-colorable", 14 | "path": "github.com/mattn/go-colorable", 15 | "revision": "5df930a27be2502f99b292b7cc09ebad4d0891f4", 16 | "revisionTime": "2017-09-26T11:14:11Z" 17 | }, 18 | { 19 | "checksumSHA1": "a/chskqRYBBHS8lDTpB3FQqgHb8=", 20 | "origin": "github.com/fatih/color/vendor/github.com/mattn/go-isatty", 21 | "path": "github.com/mattn/go-isatty", 22 | "revision": "5df930a27be2502f99b292b7cc09ebad4d0891f4", 23 | "revisionTime": "2017-09-26T11:14:11Z" 24 | }, 25 | { 26 | "checksumSHA1": "cJE7dphDlam/i7PhnsyosNWtbd4=", 27 | "path": "github.com/mattn/go-runewidth", 28 | "revision": "97311d9f7767e3d6f422ea06661bc2c7a19e8a5d", 29 | "revisionTime": "2017-05-10T07:48:58Z" 30 | }, 31 | { 32 | "checksumSHA1": "94Mvr/SU9I9Zl3pBtbHsBPN0LTg=", 33 | "path": "github.com/ngaut/log", 34 | "revision": "d2af3a61f64d093457fb23b25d20f4ce3cd551ce", 35 | "revisionTime": "2017-03-07T01:10:05Z" 36 | }, 37 | { 38 | "checksumSHA1": "Zi8hWUMkKtii1fc6YaGgoYAssIw=", 39 | "path": "github.com/nsf/termbox-go", 40 | "revision": "aa4a75b1c20a2b03751b1a9f7e41d58bd6f71c43", 41 | "revisionTime": "2017-11-04T16:23:16Z" 42 | }, 43 | { 44 | "checksumSHA1": "gOBaM54JHx9XEjZAl81GGOJr0Ic=", 45 | "path": "github.com/otiai10/gosseract", 46 | "revision": "63ad056c2e74a796684a8e9312a839aa5c76c8f3", 47 | "revisionTime": "2017-12-03T14:31:49Z" 48 | }, 49 | { 50 | "checksumSHA1": "JVGDxPn66bpe6xEiexs1r+y6jF0=", 51 | "path": "github.com/patrickmn/go-cache", 52 | "revision": "a3647f8e31d79543b2d0f0ae2fe5c379d72cedc0", 53 | "revisionTime": "2017-07-22T04:01:10Z" 54 | }, 55 | { 56 | "checksumSHA1": "oBjdFvDUZpioGsB6URqe16hDQUg=", 57 | "origin": "github.com/fatih/color/vendor/golang.org/x/sys/unix", 58 | "path": "golang.org/x/sys/unix", 59 | "revision": "5df930a27be2502f99b292b7cc09ebad4d0891f4", 60 | "revisionTime": "2017-09-26T11:14:11Z" 61 | }, 62 | { 63 | "checksumSHA1": "MGk7cSnHqiL5soaovzgcJaNH3fc=", 64 | "path": "gopkg.in/yaml.v1", 65 | "revision": "9f9df34309c04878acc86042b16630b0f696e1de", 66 | "revisionTime": "2014-09-24T16:16:07Z" 67 | } 68 | ], 69 | "rootPath": "github.com/silenceper/qanswer" 70 | } 71 | --------------------------------------------------------------------------------