├── qqqfome ├── __init__.py ├── test │ ├── __init__.py │ ├── common.py │ ├── test.json │ ├── test_common.py │ └── test_db.py ├── common.py ├── strings.py ├── config.json ├── backend.py ├── daemon.py ├── entry.py └── db.py ├── .gitignore ├── LICENSE ├── setup.py └── README.md /qqqfome/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.0.4' 2 | -------------------------------------------------------------------------------- /qqqfome/test/__init__.py: -------------------------------------------------------------------------------- 1 | from .test_common import * 2 | from .test_db import * 3 | -------------------------------------------------------------------------------- /qqqfome/common.py: -------------------------------------------------------------------------------- 1 | from . import strings as s 2 | 3 | 4 | def check_type(var, name, t): 5 | if not isinstance(t, type(str)): 6 | t = type(t) 7 | if not isinstance(var, t): 8 | raise ValueError(s.type_error.format( 9 | name, str(t), str(type(var)))) -------------------------------------------------------------------------------- /qqqfome/test/common.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | 4 | def ignore_warnings(test_func): 5 | def do_test(self, *args, **kwargs): 6 | warnings.filterwarnings("ignore", category=ResourceWarning) 7 | warnings.filterwarnings("ignore", category=DeprecationWarning) 8 | test_func(self, *args, **kwargs) 9 | return do_test 10 | -------------------------------------------------------------------------------- /qqqfome/strings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | 4 | file_dir = os.path.dirname(os.path.abspath(__file__)) 5 | config_file_path = os.path.join(file_dir, 'config.json') 6 | 7 | assert os.path.isfile(config_file_path) 8 | 9 | with open(config_file_path, 'r', encoding='utf-8') as f: 10 | json_string = f.read() 11 | json_dict = json.loads(json_string) 12 | for k, v in json_dict.items(): 13 | exec(k + " = '" + v + "'") 14 | -------------------------------------------------------------------------------- /qqqfome/test/test.json: -------------------------------------------------------------------------------- 1 | {"cap_id": "\"MzYyNTZiYzYxY2Y2NDZhMWIzYTljOWY0MDk4ZjFjZTk=|1455271798|93a1442634e79cbc53a832d8610495eea51aead5\"", "n_c": "1", "_xsrf": "96dcd016b82dc87db1f78de4e490b2f2", "q_c1": "6da51cc83ae742dbb1f4e9c9b4765cf5|1455271798000|1455271798000", "unlock_ticket": "\"QUJETWRxZ3ZKQWtYQUFBQVlRSlZUWVMydlZiR3pjTG9oaG5wcDh0QWJ6ME5YVW1ReWFaaU1RPT0=|1455271804|322526598d4650ed978b406000140ad8102d4d57\"", "aliyungf_tc": "AQAAAM6Rmio7LwYAtdOoJ3E6Jmop+njG", "z_c0": "\"QUJETWRxZ3ZKQWtYQUFBQVlRSlZUWHc4NVZabS1NZUQ4YWpPQWppSEhOMXhzSUF4SF9iUk9BPT0=|1455271804|59cf82c7e0f858fcc259b3cf74e0711a8ef9e440\""} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # PyCharm 2 | .idea/ 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | env/ 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *,cover 49 | .hypothesis/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | 58 | # Sphinx documentation 59 | docs/_build/ 60 | 61 | # PyBuilder 62 | target/ 63 | 64 | #Ipython Notebook 65 | .ipynb_checkpoints 66 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 7sDream 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 | -------------------------------------------------------------------------------- /qqqfome/test/test_common.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from .. import common as c 4 | 5 | 6 | class CommonFuncTest(unittest.TestCase): 7 | def test_check_type_success_with_ref_is_a_value(self): 8 | try: 9 | c.check_type(1, "num", 1) 10 | a = 1 11 | c.check_type(1, "num", a) 12 | except ValueError: 13 | self.fail("check_type function raise a exception " 14 | "when the check should be success.") 15 | 16 | def test_check_type_success_with_ref_is_a_type(self): 17 | try: 18 | c.check_type(1, "num", int) 19 | except ValueError: 20 | self.fail("check_type function raise a exception " 21 | "when the check should be success.") 22 | 23 | def test_check_type_fail_with_ref_is_a_value(self): 24 | with self.assertRaises(ValueError): 25 | c.check_type(1, "string", "") 26 | with self.assertRaises(ValueError): 27 | string = "" 28 | c.check_type(1, 'string', string) 29 | 30 | def test_check_type_fail_with_ref_is_a_type(self): 31 | with self.assertRaises(ValueError): 32 | c.check_type(1, "string", str) 33 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import re 5 | import ast 6 | 7 | try: 8 | from setuptools import setup 9 | except ImportError: 10 | from distutils.core import setup 11 | 12 | 13 | def extract_version(): 14 | with open('qqqfome/__init__.py', 'rb') as f_version: 15 | ast_tree = re.search( 16 | r'__version__ = (.*)', 17 | f_version.read().decode('utf-8') 18 | ).group(1) 19 | if ast_tree is None: 20 | raise RuntimeError('Cannot find version information') 21 | return str(ast.literal_eval(ast_tree)) 22 | 23 | 24 | packages = ['qqqfome', 'qqqfome.test'] 25 | package_data = {'qqqfome': ['config.json']} 26 | 27 | version = extract_version() 28 | 29 | long_description = '' 30 | 31 | with open('README.md', 'r') as f: 32 | long_description = f.read() 33 | 34 | setup( 35 | name='qqqfome', 36 | version=version, 37 | keywords=['internet', 'daemon', 'sqlite'], 38 | description='I\'m a daemon server that ' 39 | 'auto send message to your zhihu new followers.', 40 | long_description=long_description, 41 | author='7sDream', 42 | author_email='didislover@gmail.com', 43 | license='MIT', 44 | url='https://github.com/7sDream/qqqfome', 45 | 46 | install_requires=[ 47 | 'zhihu-py3', 48 | ], 49 | 50 | packages=packages, 51 | package_data=package_data, 52 | 53 | classifiers=[ 54 | 'Development Status :: 3 - Alpha', 55 | 'Environment :: No Input/Output (Daemon)', 56 | 'Environment :: Web Environment', 57 | 'License :: OSI Approved :: MIT License', 58 | 'Natural Language :: English', 59 | 'Operating System :: OS Independent', 60 | 'Programming Language :: Python :: 3', 61 | 'Topic :: Database', 62 | 'Topic :: Internet :: WWW/HTTP' 63 | ], 64 | 65 | entry_points={ 66 | 'console_scripts': [ 67 | 'qqqfome = qqqfome.entry:main' 68 | ] 69 | } 70 | ) 71 | -------------------------------------------------------------------------------- /qqqfome/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "type_error": "Parameter \"{0}\" type mismatch, expect {1}, got {2}.", 3 | "file_exist": "file {0} already exist.", 4 | "cmd_help_print_info": "turn on this to print info", 5 | "cmd_help_cookies": "provide cookies file if you have to skip login", 6 | "cmd_help_command": "command that you want exec", 7 | "cmd_help_file": "database file that you want run on.", 8 | "cmd_help_pid_file": "pid file location", 9 | "cmd_help_log_file": "log file location", 10 | "cmd_help_message": "the message that you want to send to your new follower", 11 | "cmd_help_stop_at": "found NUM continuously old followers will stop pass", 12 | "default_message": "你好{your_name},我是{my_name},谢谢你关注我,你是我的第{follower_num}号关注者哟!\\n\\n本消息由qqqfome项目自动发送。\\n项目地址:https://github.com/7sDream/qqqfome\\n{now}", 13 | "cmd_help_time": "set the interval time", 14 | "cmd_help_no_file_error": "need database filename when you want to {0}.", 15 | "log_get_user_id": "Calc file name from user id: {0}.", 16 | "log_db_not_exist_create": "Database {0} not exist, try to create it.", 17 | "log_file_not_exist": "File {0} not exist.", 18 | "log_connected_to_db": "Connected to database {0}.", 19 | "log_create_table_in_db": "Create user table in database.", 20 | "log_start_get_followers": "Get 100 followers of {0}.", 21 | "log_add_user_to_db": "Add user {0} to database.", 22 | "log_close_db": "Close database.", 23 | "log_login_failed": "Login failed.", 24 | "log_no_cookies_in_database": "No cookies in database.", 25 | "log_get_cookies_from_database": "Get cookies from database.", 26 | "log_build_zhihu_client": "Build Zhihu client success.", 27 | "log_build_me": "Get Zhihu account information.", 28 | "log_fail_to_build_me": "Get Zhihu account information fail more than 5 time, exit", 29 | "log_db_init": "Init the database.", 30 | "log_start_a_pass": "---------- START A PASS ----------", 31 | "log_finish_a_pass": "---------- FINISH A PASS ----------", 32 | "log_get_follower_num": "Get user follower number: {0}", 33 | "log_get_follower_num_failed": "Get user follower number failed.", 34 | "log_check_follower": "Check follower: {0}({1}).", 35 | "log_follower_in_db": "{0} is in database.", 36 | "log_follower_not_in_db": "{0} is not in database.", 37 | "log_continue_reach_max": "{0} continuously old followers found, stop.", 38 | "log_send_message": "Send message to {0}.", 39 | "log_send_failed": "Send Failed, retry...", 40 | "log_send_pass": "Send failed more than 5 time, pass.", 41 | "success": "Success.", 42 | "failed": "Failed.", 43 | "exit": "Exit.", 44 | "daemon_os_error": "Only support *Unix and Mac OS X system.", 45 | "daemon_umask_error": "Umask must be a 8-base number string.", 46 | "daemon_fork_error": "fork failed: {0} ({1}).", 47 | "daemon_pid_file_exist_error": "pid file {0} already exists. Is it already running?", 48 | "daemon_pid_file_not_found_error": "pid file {0} not exists. not running?", 49 | "daemon_can_not_kill_process": "Unable to kill the process {0}." 50 | } 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Thank you follow me - 谢谢你关注我呀! 2 | 3 | ## 简介 4 | 5 | 这是一个用于自动给知乎里你的新关注者发送一条信息的后台服务。 6 | 7 | 技术栈什么的非常简单: 8 | 9 | - 以前写的 `zhihu-py3` 用于获取知乎信息 10 | - 用 `sqlite` 数据库保存老的关注者 11 | - `daemon.py` 用于在 *unix 环境下创建 daemon proc 12 | 13 | ## 使用 14 | 15 | ### 安装 16 | 17 | ```bash 18 | sudo pip3 install qqqfome 19 | ``` 20 | 21 | ### 创建工作目录 22 | 23 | ```bash 24 | cd /path/that/you/want 25 | mkdir qqqfome_work 26 | cd qqqfome_work 27 | ``` 28 | 29 | ### 初始化数据库 30 | 31 | ```bash 32 | qqqfome -v init 33 | ``` 34 | 35 | 然后根据提示登录知乎。 36 | 37 | 过程中需要验证码……如果你是在VPS上部署的话,你得想办法把 `captcha.gif` 文件从远程服务器弄到本地来查看验证码…… 38 | 39 | 其实我更建议在本地用 `zhihu-py3` 生成 cookies 再弄到 VPS 上,这样就可以使用: 40 | 41 | ```bash 42 | qqqfome -c /path/to/cookie -v init 43 | ``` 44 | 45 | 来省略登录步骤。 46 | 47 | 如果一切正常的话,你会得到一个 sqlite 数据库文件。名字是 `.sqlite3` 48 | 49 | ### 启动 50 | 51 | ```bash 52 | qqqfome -m $'I\'m {my_name}:\nThank you follow me.' -d start .sqlite3 53 | ``` 54 | 55 | (如果只是测试的话,可以去掉 `-d` 参数,让他在前台模式运行。) 56 | 57 | `-m` 参数后跟需要发送的信息。注意,如果你在消息内部使用了转义字符,那么单引号前的`$`符号是必需的。 58 | 59 | 或者你可以将信息写在一个文件里,然后使用 `-M` 参数指定此文件。 60 | 61 | 两个都没有指定的话,默认的消息是: 62 | 63 | ```text 64 | 你好{your_name},我是{my_name},谢谢你关注我,你是我的第{follower_num}号关注者哟! 65 | 66 | 本消息由qqqfome项目自动发送。 67 | 项目地址:https://github.com/7sDream/qqqfome 68 | {now} 69 | ``` 70 | 71 | 程序支持的也就是例子里的这几个宏了…… 72 | 73 | ## 查看Log 74 | 75 | ```bash 76 | tail -f .sqlite3.log 77 | ``` 78 | 79 | 默认的 log 文件名是 `.sqlite3.log` 80 | 81 | 还有一个是 `.sqlite3.pid` 这个文件不要删。 82 | 83 | ### 停止 84 | 85 | 如果不是后台模式,`Ctrl-C` 即可停止。 86 | 87 | 如果是 Daemon 模式,则: 88 | 89 | ```bash 90 | qqqfome stop 91 | ``` 92 | 93 | ## 文档 94 | 95 | 还没写,暂时用 `qqqfome -h` 凑合看吧。 96 | 97 | ```text 98 | usage: qqqfome [-h] [-v] [-c FILE] [-p FILE] [-l FILE] [-t INTERVAL] 99 | [-m MESSAGE | -M FILE] [-s NUM] [-d] 100 | {init,start,stop} [file] 101 | 102 | Thank-you-follow-me cli. 103 | 104 | positional arguments: 105 | {init,start,stop} command that you want exec 106 | file database file that you want run on. 107 | 108 | optional arguments: 109 | -h, --help show this help message and exit 110 | -v, --verbose turn on this to print info 111 | -c FILE, --cookies FILE 112 | provide cookies file if you have to skip login 113 | -p FILE, --pid-file FILE 114 | pid file location 115 | -l FILE, --log-file FILE 116 | log file location 117 | -t INTERVAL, --time INTERVAL 118 | set the interval time 119 | -m MESSAGE, --message MESSAGE 120 | the message that you want to send to your new follower 121 | -M FILE, --message-file FILE 122 | the message that you want to send to your new follower 123 | -s NUM, --stop-at NUM 124 | found NUM continuously old followers will stop pass 125 | -d, --daemon work in daemon mode 126 | ``` 127 | 128 | ## TODO 129 | 130 | - 增加 update 命令,用于更新数据库里的 cookies 131 | - 选项 `--mc` 或者类似的东西,用于随机从文件中选取一段文本(一行,或者以特定分隔符分隔的一段)用作 message。 132 | - 写个教程 133 | - 完善 readme 和 文档 134 | - 重构代码 135 | - 写测试 136 | 137 | ## LICENSEE 138 | 139 | MIT. 140 | -------------------------------------------------------------------------------- /qqqfome/test/test_db.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | import shutil 4 | 5 | from zhihu import ZhihuClient 6 | 7 | from .. import db 8 | 9 | 10 | file_dir = os.path.dirname(os.path.abspath(__file__)) 11 | test_dir = os.path.join(file_dir, 'test') 12 | json_path = os.path.join(file_dir, 'test.json') 13 | author = ZhihuClient(json_path).me() 14 | db_path = os.path.join(test_dir, db.author_to_db_filename(author)) 15 | 16 | 17 | class InitDBTest(unittest.TestCase): 18 | @classmethod 19 | def setUpClass(cls): 20 | os.makedirs(test_dir, exist_ok=True) 21 | os.chdir(test_dir) 22 | 23 | def tearDown(self): 24 | self.db.close() if (hasattr(self, 'db') and self.db) else None 25 | try: 26 | os.remove(db_path) 27 | except FileNotFoundError: 28 | pass 29 | 30 | @classmethod 31 | def tearDownClass(cls): 32 | shutil.rmtree(test_dir) 33 | 34 | def test_create_db_truely_create_a_file(self): 35 | self.db = db.create_db(author) 36 | self.assertTrue(os.path.isfile(db_path)) 37 | 38 | def test_create_db_raise_error_when_file_exist(self): 39 | with open(db_path, 'w') as f: 40 | f.write('test file') 41 | 42 | with self.assertRaises(FileExistsError): 43 | self.db = db.create_db(author) 44 | 45 | def test_connect_db_when_file_exist(self): 46 | # create a db 47 | self.db = db.create_db(author) 48 | db.close_db(self.db) 49 | 50 | try: 51 | self.db = db.connect_db(db_path) 52 | except FileNotFoundError: 53 | self.fail("Raise error when try connect a exist database file.") 54 | 55 | def test_connect_db_when_file_not_exist(self): 56 | with self.assertRaises(FileNotFoundError): 57 | self.db = db.connect_db(db_path) 58 | 59 | def test_create_table(self): 60 | self.db = db.create_db(author) 61 | db.create_table(self.db) 62 | cursor = self.db.execute( 63 | """ 64 | select name from sqlite_master where type = 'table'; 65 | """ 66 | ) 67 | 68 | self.assertListEqual(list(cursor), 69 | [('followers',), ('sqlite_sequence',), 70 | ('meta',), ('log',)]) 71 | 72 | cursor = self.db.execute( 73 | """ 74 | select * from followers LIMIT 1; 75 | """ 76 | ) 77 | 78 | row_names = list(map(lambda x: x[0], cursor.description)) 79 | 80 | self.assertListEqual(row_names, ['id', 'name', 'in_name']) 81 | 82 | def test_add_one_user_to_db(self): 83 | self.db = db.create_db(author) 84 | db.create_table(self.db) 85 | db.add_user_to_db(self.db, author) 86 | 87 | cursor = self.db.execute( 88 | """ 89 | SELECT in_name FROM followers; 90 | """ 91 | ) 92 | 93 | for row in cursor: 94 | self.assertEqual(row[0], author.id) 95 | 96 | def test_is_db_closed_when_closed(self): 97 | self.db = db.create_db(author) 98 | self.db.close() 99 | self.assertTrue(db.is_db_closed(self.db)) 100 | 101 | def test_is_db_closed_when_not_closed(self): 102 | self.db = db.create_db(author) 103 | self.assertFalse(db.is_db_closed(self.db)) 104 | 105 | def test_close_db_when_closed(self): 106 | self.db = db.create_db(author) 107 | self.db.close() 108 | db.close_db(self.db) 109 | self.assertTrue(db.is_db_closed(self.db)) 110 | 111 | def test_close_db_when_not_closed(self): 112 | self.db = db.create_db(author) 113 | db.close_db(self.db) 114 | self.assertTrue(db.is_db_closed(self.db)) 115 | -------------------------------------------------------------------------------- /qqqfome/backend.py: -------------------------------------------------------------------------------- 1 | import time 2 | import datetime 3 | import logging 4 | 5 | from . import daemon 6 | from . import db 7 | from . import common as c 8 | from . import strings as s 9 | 10 | from zhihu import ZhihuClient 11 | 12 | 13 | def calc_message(pattern, me, you, new_follower_num): 14 | offset = datetime.timedelta(hours=8) 15 | china_now = datetime.datetime.utcnow() + offset 16 | my_name = me.name 17 | follower_num = me.follower_num - new_follower_num 18 | your_name = you.name 19 | 20 | return pattern.format(now=china_now, my_name=my_name, 21 | follower_num=follower_num, your_name=your_name) 22 | 23 | 24 | class BackendCode(daemon.DaemonProcess): 25 | def at_exit(self): 26 | pass 27 | 28 | def run(self, database, msg, interval, log_file, max_old=10): 29 | c.check_type(database, 'database', str) 30 | 31 | L = logging.getLogger('qqqfome-backend') 32 | formatter = logging.Formatter( 33 | '%(asctime)s - %(levelname)s - %(message)s') 34 | fh = logging.FileHandler(log_file) 35 | fh.setLevel(logging.DEBUG) 36 | fh.setFormatter(formatter) 37 | sh = logging.StreamHandler() 38 | sh.setLevel(logging.DEBUG) 39 | sh.setFormatter(formatter) 40 | L.setLevel(logging.DEBUG) 41 | L.addHandler(fh) 42 | L.addHandler(sh) 43 | 44 | try: 45 | L.info(s.log_connected_to_db.format(database)) 46 | conn = db.connect_db(database) 47 | L.info(s.success) 48 | except FileNotFoundError: 49 | L.exception(s.log_file_not_exist.format(database)) 50 | L.info(s.exit) 51 | return 52 | 53 | # get cookies from database 54 | cookies = db.get_cookies(conn) 55 | 56 | if not cookies: 57 | L.exception(s.log_no_cookies_in_database) 58 | L.info(s.exit) 59 | return 60 | 61 | L.info(s.log_get_cookies_from_database) 62 | L.debug(cookies) 63 | 64 | try: 65 | client = ZhihuClient(cookies) 66 | L.info(s.log_build_zhihu_client) 67 | except Exception as e: 68 | L.exception(e) 69 | return 70 | 71 | while True: 72 | L.info(s.log_start_a_pass) 73 | 74 | i = 0 75 | while i < 5: 76 | try: 77 | L.info(s.log_build_me) 78 | me = client.me() 79 | break 80 | except Exception as e: 81 | L.exception(e) 82 | i += 1 83 | else: 84 | L.error(s.log_fail_to_build_me) 85 | L.info(s.exit) 86 | return 87 | 88 | try: 89 | follower_num = me.follower_num 90 | except Exception as e: 91 | L.exception(e) 92 | L.info(s.log_get_follower_num_failed) 93 | L.info(s.log_finish_a_pass) 94 | time.sleep(interval) 95 | continue 96 | 97 | L.info(s.log_get_follower_num.format(follower_num)) 98 | db.log_to_db(conn, follower_num, s.log_start_a_pass) 99 | 100 | continue_in_db = 0 101 | new_follower_num = 0 102 | 103 | try: 104 | for follower in me.followers: 105 | L.info(s.log_check_follower.format( 106 | follower.name, follower.id)) 107 | if db.is_in_db(conn, follower.id): 108 | L.info(s.log_follower_in_db.format(follower.id)) 109 | continue_in_db += 1 110 | else: 111 | L.info(s.log_follower_not_in_db.format(follower.name)) 112 | continue_in_db = 0 113 | 114 | L.info(s.log_send_message.format(follower.name)) 115 | 116 | try: 117 | message = calc_message(msg, me, follower, 118 | new_follower_num) 119 | new_follower_num += 1 120 | except Exception as e: 121 | L.exception(e) 122 | message = msg 123 | 124 | L.debug(message) 125 | 126 | i = 0 127 | while i < 5: 128 | try: 129 | me.send_message(follower, message) 130 | break 131 | except Exception as e: 132 | L.exception(e) 133 | L.debug(s.log_send_failed) 134 | i += 1 135 | else: 136 | L.info(s.log_send_pass) 137 | continue 138 | 139 | L.info(s.success) 140 | L.info(s.log_add_user_to_db.format( 141 | follower.name)) 142 | db.add_user_to_db(conn, follower) 143 | 144 | if continue_in_db == max_old: 145 | L.info(s.log_continue_reach_max.format(max_old)) 146 | break 147 | except Exception as e: 148 | L.exception(e) 149 | 150 | L.info(s.log_finish_a_pass) 151 | time.sleep(interval) 152 | -------------------------------------------------------------------------------- /qqqfome/daemon.py: -------------------------------------------------------------------------------- 1 | """ 2 | Make some code running in daemon. 3 | 4 | Modify from Open Source file: 5 | https://github.com/serverdensity/python-daemon/blob/master/daemon.py 6 | """ 7 | 8 | import os 9 | import sys 10 | import signal 11 | import atexit 12 | import time 13 | 14 | from . import common as c 15 | from . import strings as s 16 | 17 | 18 | class DaemonProcess: 19 | def __init__(self, pidfile, 20 | stdin=os.devnull, stdout=os.devnull, stderr=os.devnull, 21 | workdir='.', umask='022'): 22 | c.check_type(pidfile, 'pidfile', str) 23 | c.check_type(workdir, 'workdir', str) 24 | c.check_type(umask, 'umask', str) 25 | 26 | self._pidfile = pidfile 27 | self._stdin = stdin 28 | self._stdout = stdout 29 | self._stderr = stderr 30 | self._work_dir = workdir 31 | self._alive = True 32 | 33 | try: 34 | self.umask = int(umask, base=8) 35 | except ValueError as e: 36 | raise ValueError(s.daemon_umask_error) 37 | 38 | def run(self, *args, **kwargs): 39 | """ 40 | Override me. 41 | """ 42 | pass 43 | 44 | def at_exit(self): 45 | """ 46 | Override me. 47 | """ 48 | pass 49 | 50 | def _write_pid_file(self, pid): 51 | with open(self._pidfile, 'w', encoding='utf-8') as f: 52 | f.writelines([str(pid)]) 53 | 54 | def _del_pid_file(self): 55 | self.at_exit() 56 | os.remove(self._pidfile) 57 | 58 | def get_pid_from_pidfile(self): 59 | try: 60 | with open(self._pidfile, 'r', encoding='utf-8') as f: 61 | return int(f.read().strip()) 62 | except IOError: 63 | return None 64 | except SystemExit: 65 | return None 66 | 67 | def _make_me_daemon(self): 68 | # first fork 69 | try: 70 | pid = os.fork() 71 | if pid != 0: 72 | sys.exit(0) 73 | except OSError as e: 74 | raise OSError(s.daemon_fork_error.format(e.errno, e.strerror)) 75 | 76 | os.setsid() 77 | os.umask(self.umask) 78 | os.chdir(self._work_dir) 79 | 80 | # second fork 81 | try: 82 | pid = os.fork() 83 | if pid != 0: 84 | sys.exit(0) 85 | except OSError as e: 86 | raise OSError(s.daemon_fork_error.format(e.errno, e.strerror)) 87 | 88 | # redirect streams 89 | sys.stdout.flush() 90 | sys.stderr.flush() 91 | si = open(self._stdin, 'r') if isinstance(self._stdin, str) \ 92 | else self._stdin 93 | so = open(self._stdout, 'a+') if isinstance(self._stdout, str) \ 94 | else self._stdout 95 | if self._stderr: 96 | se = open(self._stderr, 'a+') if isinstance(self._stderr, str) \ 97 | else self._stdout 98 | else: 99 | se = so 100 | os.dup2(si.fileno(), sys.stdin.fileno()) 101 | os.dup2(so.fileno(), sys.stdout.fileno()) 102 | os.dup2(se.fileno(), sys.stderr.fileno()) 103 | 104 | # set signal handler 105 | def signal_handler(signum, frame): 106 | self._alive = False 107 | exit(0) 108 | 109 | signal.signal(signal.SIGINT, signal_handler) 110 | signal.signal(signal.SIGTERM, signal_handler) 111 | pid = os.getpid() 112 | print(pid) 113 | atexit.register(self._del_pid_file) 114 | self._write_pid_file(pid) 115 | 116 | def start_when_pid_file_exist(self): 117 | raise FileExistsError( 118 | s.daemon_pid_file_exist_error.format(self._pidfile)) 119 | 120 | def start(self, *args, **kwargs): 121 | if not (sys.platform.startswith('linux') or sys.platform.startswith( 122 | 'darwin')): 123 | raise OSError(s.daemon_os_error) 124 | 125 | pid = self.get_pid_from_pidfile() 126 | 127 | if pid is not None: 128 | self.start_when_pid_file_exist() 129 | 130 | self._make_me_daemon() 131 | self.run(*args, **kwargs) 132 | 133 | def stop_when_pid_file_not_exist(self): 134 | raise FileNotFoundError( 135 | s.daemon_pid_file_not_found_error.format(self._pidfile)) 136 | 137 | def stop(self): 138 | pid = self.get_pid_from_pidfile() 139 | 140 | if pid is None: 141 | # Just to be sure. A ValueError might occur if the PID file is 142 | # empty but does actually exist 143 | if os.path.exists(self._pidfile): 144 | os.remove(self._pidfile) 145 | 146 | self.stop_when_pid_file_not_exist() 147 | else: 148 | try: 149 | i = 0 150 | while 1: 151 | os.kill(pid, signal.SIGTERM) 152 | time.sleep(0.1) 153 | i += 1 154 | if i % 10 == 0: 155 | os.kill(pid, signal.SIGHUP) 156 | except OSError as err: 157 | err = str(err) 158 | if err.find("No such process") > 0: 159 | if os.path.exists(self._pidfile): 160 | os.remove(self._pidfile) 161 | else: 162 | raise OSError(s.daemon_can_not_kill_process.format(pid)) 163 | 164 | def is_running(self): 165 | pid = self.get_pid_from_pidfile() 166 | 167 | return self._pid and os.path.exists('/proc/%d' % pid) 168 | -------------------------------------------------------------------------------- /qqqfome/entry.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | import os 4 | import sys 5 | 6 | from zhihu import ZhihuClient 7 | 8 | from . import common as c 9 | from . import strings as s 10 | from . import db 11 | from . import backend 12 | 13 | L = logging.getLogger('qqqfome-entry') 14 | 15 | 16 | def init_db(cookies): 17 | if cookies is not None: 18 | c.check_type(cookies, 'cookies', str) 19 | 20 | # login in with cookies file 21 | if cookies: 22 | author = ZhihuClient(cookies=cookies).me() 23 | # login in terminal 24 | else: 25 | client = ZhihuClient() 26 | 27 | try: 28 | cookies = client.login_in_terminal() 29 | except KeyboardInterrupt: 30 | print() 31 | cookies = '' 32 | 33 | if not cookies: 34 | L.error(s.log_login_failed) 35 | L.info(s.exit) 36 | exit(0) 37 | 38 | author = client.me() 39 | 40 | try: 41 | conn = db.create_db(author) 42 | db.create_table(conn) 43 | db.dump_init_data_to_db(conn, author) 44 | db.close_db(conn) 45 | print(s.success) 46 | except FileExistsError as e: 47 | L.error(s.file_exist.format(e.filename)) 48 | print(s.failed) 49 | 50 | 51 | class SetDefaultPID(argparse.Action): 52 | def __call__(self, parser, namespace, values, option_string=None): 53 | setattr(namespace, self.dest, values) 54 | default_pid = getattr(namespace, 'pid_file') 55 | default_log = getattr(namespace, 'log_file') 56 | try: 57 | if values: 58 | filename = os.path.basename(os.path.abspath(values)) 59 | default_pid = default_pid.format(filename) 60 | default_log = default_log.format(filename) 61 | except TypeError: 62 | # pid has already been replaced 63 | pass 64 | setattr(namespace, 'pid_file', default_pid) 65 | setattr(namespace, 'log_file', default_log) 66 | 67 | 68 | def main(): 69 | parser = argparse.ArgumentParser(prog='qqqfome', 70 | description='Thank-you-follow-me cli.') 71 | 72 | parser.add_argument('-v', '--verbose', dest='verbose', 73 | action='count', default=0, 74 | help=s.cmd_help_print_info) 75 | parser.add_argument('-c', '--cookies', dest='cookies', metavar='FILE', 76 | help=s.cmd_help_cookies, 77 | type=str) 78 | parser.add_argument('-p', '--pid-file', dest='pid_file', metavar='FILE', 79 | help=s.cmd_help_pid_file, 80 | type=str, default='{0}.pid') 81 | parser.add_argument('-l', '--log-file', dest='log_file', metavar='FILE', 82 | help=s.cmd_help_log_file, 83 | type=str, default='{0}.log') 84 | parser.add_argument('-t', '--time', dest='time', metavar='INTERVAL', 85 | help=s.cmd_help_time, 86 | type=int, default=90) 87 | 88 | group = parser.add_mutually_exclusive_group() 89 | group.add_argument('-m', '--message', dest='message', 90 | help=s.cmd_help_message, 91 | type=str, default=s.default_message) 92 | group.add_argument('-M', '--message-file', dest='message_file', 93 | metavar='FILE', 94 | help=s.cmd_help_message, 95 | type=str) 96 | 97 | parser.add_argument('-s', '--stop-at', dest='stop_at', metavar='NUM', 98 | help=s.cmd_help_stop_at, type=int, default=10) 99 | parser.add_argument('-d', '--daemon', dest='daemon', action='store_true', 100 | default=False, help='work in daemon mode') 101 | parser.add_argument('command', help=s.cmd_help_command, type=str, 102 | choices=['init', 'start', 'stop']) 103 | parser.add_argument('file', help=s.cmd_help_file, type=str, 104 | action=SetDefaultPID, nargs='?') 105 | 106 | args = parser.parse_args() 107 | 108 | # Logger settings 109 | level = logging.ERROR 110 | if args.verbose == 1: 111 | level = logging.INFO 112 | if args.verbose >= 2: 113 | level = logging.DEBUG 114 | 115 | ch = logging.StreamHandler() 116 | ch.setLevel(level) 117 | formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') 118 | ch.setFormatter(formatter) 119 | db.set_logger_level(level) 120 | db.set_logger_handle(ch) 121 | 122 | L.setLevel(level) 123 | L.addHandler(ch) 124 | 125 | L.debug(args) 126 | 127 | if args.command == "init": 128 | init_db(args.cookies) 129 | elif args.file is None: 130 | parser.error(s.cmd_help_no_file_error.format(args.command)) 131 | 132 | if args.command == 'start': 133 | if args.message_file is not None: 134 | with open(args.message_file, 'r') as f: 135 | args.message = f.read() 136 | if args.daemon: 137 | p = backend.BackendCode(args.pid_file) 138 | try: 139 | p.start(args.file, args.message, args.time, args.log_file) 140 | except OSError: # daemon mode not support the system 141 | p.run(args.file, args.message, args.time, args.log_file) 142 | else: 143 | p = backend.BackendCode(args.pid_file, stdin=sys.stdin, 144 | stdout=sys.stdout, stderr=sys.stderr) 145 | p.run(args.file, args.message, args.time, args.log_file) 146 | elif args.command == 'stop': 147 | try: 148 | p = backend.BackendCode(args.pid_file) 149 | p.stop() 150 | except FileNotFoundError as e: 151 | L.error(e) 152 | 153 | 154 | if __name__ == '__main__': 155 | main() 156 | -------------------------------------------------------------------------------- /qqqfome/db.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sqlite3 3 | import json 4 | import logging 5 | import datetime 6 | 7 | from zhihu import Author, ZhihuClient 8 | 9 | from . import common as c 10 | from . import strings as s 11 | 12 | L = logging.getLogger('qqqufome-db') 13 | 14 | 15 | def set_logger_level(level): 16 | c.check_type(level, 'level', logging.NOTSET) 17 | global L 18 | L.setLevel(level) 19 | 20 | 21 | def set_logger_handle(handle): 22 | L.addHandler(handle) 23 | 24 | 25 | def author_to_db_filename(author): 26 | c.check_type(author, 'author', Author) 27 | 28 | return author.id + '.sqlite3' 29 | 30 | 31 | def create_db(author): 32 | c.check_type(author, 'author', Author) 33 | 34 | filename = author_to_db_filename(author) 35 | 36 | L.info(s.log_get_user_id.format(filename)) 37 | 38 | if os.path.isfile(filename): 39 | e = FileExistsError() 40 | e.filename = filename 41 | raise e 42 | 43 | L.info(s.log_db_not_exist_create.format(filename)) 44 | 45 | db = sqlite3.connect(author_to_db_filename(author)) 46 | 47 | L.info(s.log_connected_to_db.format(filename)) 48 | 49 | return db 50 | 51 | 52 | def connect_db(database): 53 | c.check_type(database, 'database', str) 54 | 55 | if not os.path.isfile(database): 56 | e = FileNotFoundError() 57 | e.filename = database 58 | raise e 59 | 60 | return sqlite3.connect(database) 61 | 62 | 63 | def create_table(db: sqlite3.Connection): 64 | c.check_type(db, 'db', sqlite3.Connection) 65 | 66 | L.info(s.log_create_table_in_db) 67 | with db: 68 | db.execute( 69 | ''' 70 | CREATE TABLE followers 71 | ( 72 | id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 73 | name TEXT NOT NULL, 74 | in_name TEXT NOT NULL 75 | ); 76 | ''' 77 | ) 78 | 79 | db.execute( 80 | """ 81 | CREATE TABLE meta 82 | ( 83 | id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 84 | name TEXT NOT NULL, 85 | in_name TEXT NOT NULL, 86 | cookies TEXT NOT NULL 87 | ); 88 | """ 89 | ) 90 | 91 | db.execute( 92 | """ 93 | CREATE TABLE log 94 | ( 95 | id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 96 | time DATETIME NOT NULL, 97 | follower_number INT NOT NULL, 98 | increase INT NOT NULL, 99 | message TEXT NOT NULL 100 | ); 101 | """ 102 | ) 103 | L.info(s.success) 104 | 105 | 106 | def add_user_to_db(db, author): 107 | c.check_type(db, 'db', sqlite3.Connection) 108 | c.check_type(author, 'author', Author) 109 | 110 | with db: 111 | L.debug(s.log_add_user_to_db.format(author.name)) 112 | db.execute( 113 | """ 114 | INSERT INTO followers 115 | (name, in_name) VALUES 116 | ( ?, ? ); 117 | """, 118 | (author.name, author.id) 119 | ) 120 | 121 | 122 | def dump_init_data_to_db(db, author): 123 | c.check_type(db, 'db', sqlite3.Connection) 124 | c.check_type(author, 'author', Author) 125 | 126 | # meta data 127 | with db: 128 | name = author.name 129 | in_name = author.id 130 | cookies = json.dumps(author._session.cookies.get_dict()) 131 | 132 | db.execute( 133 | """ 134 | INSERT INTO meta 135 | (name, in_name, cookies) VALUES 136 | ( ?, ?, ? ); 137 | """, 138 | (name, in_name, cookies) 139 | ) 140 | 141 | # followers 142 | L.info(s.log_start_get_followers.format(author.name)) 143 | with db: 144 | for _, follower in zip(range(100), author.followers): 145 | add_user_to_db(db, follower) 146 | 147 | # log 148 | with db: 149 | log_to_db(db, author.follower_num, s.log_db_init) 150 | 151 | 152 | def is_db_closed(db): 153 | c.check_type(db, 'db', sqlite3.Connection) 154 | 155 | try: 156 | with db: 157 | db.execute( 158 | """ 159 | SELECT name from sqlite_master where type = 'table'; 160 | """ 161 | ) 162 | return False 163 | except sqlite3.ProgrammingError: 164 | return True 165 | 166 | 167 | def close_db(db): 168 | c.check_type(db, 'db', sqlite3.Connection) 169 | 170 | if not is_db_closed(db): 171 | db.close() 172 | L.info(s.log_close_db) 173 | 174 | 175 | def get_cookies(db): 176 | c.check_type(db, 'db', sqlite3.Connection) 177 | 178 | cursor = db.execute('SELECT cookies from meta') 179 | 180 | row = cursor.fetchone() 181 | 182 | if row is None: 183 | return None 184 | 185 | return row[0] 186 | 187 | 188 | def log_to_db(db, follower_num, message): 189 | c.check_type(db, 'db', sqlite3.Connection) 190 | c.check_type(follower_num, 'follower_num', int) 191 | c.check_type(message, 'message', str) 192 | 193 | cursor = db.execute( 194 | """ 195 | SELECT follower_number FROM log ORDER BY id DESC; 196 | """ 197 | ) 198 | 199 | row = cursor.fetchone() 200 | 201 | if row: 202 | increase = follower_num - row[0] 203 | else: 204 | # first log 205 | increase = 0 206 | 207 | with db: 208 | db.execute( 209 | """ 210 | INSERT INTO log 211 | (time, follower_number, increase, message) VALUES 212 | ( ?, ?, ?, ? ); 213 | """, 214 | (datetime.datetime.now(), follower_num, increase, message) 215 | ) 216 | 217 | 218 | def is_in_db(db, in_name): 219 | c.check_type(db, 'db', sqlite3.Connection) 220 | c.check_type(in_name, 'in_name', str) 221 | 222 | with db: 223 | cursor = db.execute( 224 | """ 225 | SELECT * FROM followers WHERE in_name = ?; 226 | """, 227 | (in_name,) 228 | ) 229 | 230 | row = cursor.fetchone() 231 | 232 | return row is not None 233 | --------------------------------------------------------------------------------