├── .gitignore ├── README.md ├── art └── screenshot.png └── sayhi.py /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .DS_Store 3 | *.pyc 4 | *.log 5 | captcha.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # zhihuSayHi 2 | ~This script help you to automatically say hi to your new follower in **[zhihu.com](https://www.zhihu.com/)**.~ 3 | **(This project is unmaintained now for personal reason.)** 4 | 5 | ### Screenshot 6 | ![](art/screenshot.png) 7 | 8 | ### Usage 9 | 1. Run the **`sayhi.py`** by python3.5 10 | 2. If necessary, it will remind you to enter a captcha (It will generated a `captcha.png` in the project root path) 11 | 3. Input your email and password 12 | 4. Have fun~ 13 | 14 | ### Test 15 | Yes, I have run it on my server for some time. It seems very stable. You can follow my zhihu account [nekocode](https://www.zhihu.com/people/nekocode) for testing. And then you will recieve a message sends by this script. 16 | -------------------------------------------------------------------------------- /art/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nekocode/zhihuSayHi/8aa738ebd9a117b8093985204b3f955ee9cbd705/art/screenshot.png -------------------------------------------------------------------------------- /sayhi.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | import requests 4 | import time 5 | import hmac 6 | from hashlib import sha1 7 | import json 8 | import base64 9 | import asyncio 10 | import websockets 11 | import logging 12 | 13 | 14 | class ZhihuSayHi: 15 | STATUS_CODE_UNAUTHORIZED = 401 16 | CLIENT_ID = '8d5227e0aaaa4797a763ac64e0c3b8' 17 | CLIENT_SECRET = b'ecbefbf6b17e47ecb9035107866380' 18 | SOURCE = 'com.zhihu.android' 19 | REFRESH_TOKEN_TIME = 60 * 1 20 | 21 | def __init__(self): 22 | self.looper = None 23 | self.headers = { 24 | 'Authorization': 'oauth 8d5227e0aaaa4797a763ac64e0c3b8', 25 | 'Connection': 'keep-alive', 26 | 'Cache-Control': 'no-cache', 27 | 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_5) ' 28 | 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36', 29 | 'Host': 'api.zhihu.com', 30 | } 31 | 32 | self.cookies = None 33 | self.show_captcha = True 34 | self.token = { 35 | 'user_id': 0, 36 | 'uid': '', 37 | 'access_token': '', 38 | 'expires_in': 0, 39 | 'refresh_token': '' 40 | } 41 | 42 | self.old_followers = [] 43 | self.new_followers = [] 44 | 45 | @staticmethod 46 | def sign(msg, pwd): 47 | a = hmac.new(pwd, str.encode(msg), sha1) 48 | return a.hexdigest() 49 | 50 | @staticmethod 51 | def decode_json(response): 52 | return json.loads(bytes.decode(response)) 53 | 54 | def get_cookit_str(self): 55 | ck = self.token['cookie'] 56 | if 'z_c0' in ck: 57 | return 'z_c0=' + ck['z_c0'] 58 | elif 'q_c0' in ck: 59 | return 'q_c0=' + ck['q_c0'] 60 | else: 61 | return '' 62 | 63 | def check_token(self, req): 64 | if req.status_code == self.STATUS_CODE_UNAUTHORIZED: 65 | raise TokenException() 66 | 67 | def login(self, email, pwd): 68 | grant_type = 'password' 69 | ts = int(time.time()) 70 | signature = self.sign(grant_type + self.CLIENT_ID + self.SOURCE + str(ts), self.CLIENT_SECRET) 71 | 72 | req = requests.post("https://api.zhihu.com/sign_in", data={ 73 | 'grant_type': grant_type, 74 | 'username': email, 75 | 'password': pwd, 76 | 'client_id': self.CLIENT_ID, 77 | 'source': self.SOURCE, 78 | 'signature': signature, 79 | 'timestamp': ts 80 | }, headers=self.headers, cookies=self.cookies) 81 | 82 | self.token = self.decode_json(req.content) 83 | self.headers['Authorization'] = 'Bearer ' + self.token['access_token'] 84 | logging.info("Login Success.") 85 | 86 | def refresh_token(self): 87 | grant_type = 'refresh_token' 88 | ts = int(time.time()) 89 | signature = self.sign(grant_type + self.CLIENT_ID + self.SOURCE + str(ts), self.CLIENT_SECRET) 90 | 91 | req = requests.post("https://api.zhihu.com/sign_in", data={ 92 | 'grant_type': grant_type, 93 | 'refresh_token': self.token['refresh_token'], 94 | 'client_id': self.CLIENT_ID, 95 | 'source': self.SOURCE, 96 | 'signature': signature, 97 | 'timestamp': ts 98 | }, headers=self.headers, cookies=self.cookies) 99 | 100 | self.token = self.decode_json(req.content) 101 | self.headers['Authorization'] = 'Bearer ' + self.token['access_token'] 102 | logging.info("Refresh Token Success.") 103 | 104 | def get_captcha(self): 105 | req = requests.get('https://api.zhihu.com/captcha', headers=self.headers) 106 | self.cookies = req.cookies 107 | rsp = self.decode_json(req.content) 108 | self.show_captcha = rsp['show_captcha'] 109 | 110 | if self.show_captcha: 111 | rsp = self.decode_json( 112 | requests.put('https://api.zhihu.com/captcha', headers=self.headers, cookies=self.cookies).content 113 | ) 114 | img_b64 = rsp['img_base64'] 115 | img_bin = base64.b64decode(img_b64) 116 | with open('captcha.png', 'wb') as f: 117 | f.write(img_bin) 118 | 119 | def input_captcha(self, text): 120 | req = requests.post('https://api.zhihu.com/captcha', data={ 121 | 'input_text': text 122 | }, headers=self.headers, cookies=self.cookies) 123 | 124 | return self.decode_json(req.content)['success'] 125 | 126 | async def get_followers(self): 127 | is_end = False 128 | next_page = 'https://api.zhihu.com/notifications/follows?limit=20&offset=0' 129 | while not is_end: 130 | req = requests.get(next_page, headers=self.headers, cookies=self.cookies) 131 | self.check_token(req) 132 | 133 | rsp = self.decode_json(req.content) 134 | is_end = rsp['paging']['is_end'] 135 | next_page = rsp['paging']['next'] 136 | 137 | for data in rsp['data']: 138 | for fol in data['operators']: 139 | finded = False 140 | for old_fol in self.old_followers: 141 | if fol['id'] == old_fol['id']: 142 | finded = True 143 | break 144 | 145 | if not finded: 146 | self.new_followers.append(fol) 147 | self.old_followers.append(fol) 148 | 149 | print_str = '[' 150 | for fol in self.new_followers: 151 | print_str += fol['name'] + ', ' 152 | print_str += ']' 153 | logging.info('New Followers:' + print_str) 154 | 155 | async def send_msg(self, receiver_id, content): 156 | req = requests.post('https://api.zhihu.com/messages', data={ 157 | 'receiver_id': receiver_id, 158 | 'content': content 159 | }, headers=self.headers, cookies=self.cookies) 160 | self.check_token(req) 161 | 162 | msg = self.decode_json(req.content) 163 | logging.info('Send Msg To [%s]: %s' % (msg['receiver']['name'], content)) 164 | 165 | async def sayhi_to_followers(self): 166 | for fol in self.new_followers: 167 | await self.send_msg(fol['id'], 168 | 'Hi, %s! Thanks for your following~ \n' 169 | '[This message is sent by https://github.com/nekocode/zhihuSayHi]' 170 | % fol['name']) 171 | 172 | self.new_followers.clear() 173 | 174 | async def listen_push(self): 175 | listen_retry_count = 0 176 | while True: 177 | try: 178 | async with websockets.connect('ws://apilive.zhihu.com/apilive', 179 | extra_headers={'Cookie': self.get_cookit_str()}) as websocket: 180 | listen_retry_count = 0 # Reset 181 | 182 | # Pinging task 183 | async def ping(): 184 | logging.info("Start Pinging...") 185 | ping_retry_count = 0 186 | 187 | while True: 188 | try: 189 | await asyncio.sleep(10) 190 | await websocket.ping() 191 | ping_retry_count = 0 # Reset 192 | 193 | except Exception as e1: 194 | ping_retry_count += 1 195 | 196 | # Retry over 5 times 197 | if ping_retry_count > 5: 198 | logging.error("Pinging Error: " + str(e1)) 199 | raise e1 200 | 201 | # Add pinging task to event loop 202 | self.looper.create_task(ping()) 203 | 204 | # Listening task 205 | logging.info("Start Listening...") 206 | recv_retry_count = 0 207 | while True: 208 | try: 209 | push_msg = self.decode_json(await websocket.recv()) 210 | recv_retry_count = 0 # Reset 211 | if push_msg['follow_has_new']: 212 | await self.get_followers() 213 | await self.sayhi_to_followers() 214 | 215 | except TokenException: 216 | # Token is invaild, refresh it 217 | try: 218 | self.refresh_token() 219 | except Exception as e3: 220 | logging.error("Refresh Token Error: " + str(e3)) 221 | raise e3 222 | 223 | raise TokenException() 224 | 225 | except Exception as e2: 226 | recv_retry_count += 1 227 | 228 | # Retry over 3 times 229 | if recv_retry_count > 3: 230 | logging.error("Listening Error: " + str(e2)) 231 | raise e2 232 | 233 | except TokenException: 234 | # Sleep 5 secends before the next connection 235 | await asyncio.sleep(5) 236 | 237 | except Exception: 238 | logging.info("Reconnecting...") 239 | listen_retry_count += 1 240 | await asyncio.sleep(10) 241 | 242 | if listen_retry_count > 5: 243 | self.looper.stop() 244 | return 245 | 246 | def start(self): 247 | self.looper = asyncio.get_event_loop() 248 | 249 | self.get_captcha() 250 | if self.show_captcha: 251 | self.input_captcha(input('Captcha:')) 252 | 253 | self.login(input('Email:'), input('Password:')) 254 | 255 | self.looper.run_until_complete(self.get_followers()) 256 | self.looper.run_until_complete(self.sayhi_to_followers()) 257 | self.looper.run_until_complete(self.listen_push()) 258 | self.looper.stop() 259 | 260 | 261 | class TokenException(Exception): 262 | pass 263 | 264 | 265 | # Logging config 266 | fmt = '%(asctime)s [%(levelname)s] %(message)s' 267 | datefmt = '%Y-%m-%d,%H:%M:%S' 268 | 269 | logging.basicConfig( 270 | level=logging.INFO, 271 | format=fmt, 272 | datefmt=datefmt, 273 | filename='sayhi.log', 274 | filemode='w') 275 | 276 | console = logging.StreamHandler() 277 | console.setLevel(logging.INFO) 278 | formatter = logging.Formatter(fmt, datefmt) 279 | console.setFormatter(formatter) 280 | logging.getLogger('').addHandler(console) 281 | 282 | logging.getLogger('requests').setLevel(logging.CRITICAL) 283 | 284 | 285 | if __name__ == '__main__': 286 | ZhihuSayHi().start() 287 | 288 | --------------------------------------------------------------------------------