├── .gitignore ├── LICENSE ├── README.md ├── TeamFlowy.py └── config.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | .idea/ 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 kingname 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 | # TeamFlowy——结合Teambition与Workflowy 2 | 3 | 4 | Teambition是一个跨平台的团队协作和项目管理工具,相当于国外的Trello。使用Teambition可以像使用白板与便签纸一样来管理项目进度,如下图所示。 5 | 6 | ![](http://7sbpmp.com1.z0.glb.clouddn.com/2017-10-03-23-45-13.png) 7 | 8 | Teambition虽然便于管理项目,但是如果直接在Teambition上面创建一个项目对应的任务,却容易陷入面对茫茫白板,不知道如何拆分任务的尴尬境地。如下图所示。 9 | 10 | ![](http://7sbpmp.com1.z0.glb.clouddn.com/2017-10-03-23-47-38.png) 11 | 12 | 面对这个空荡荡的窗口,应该添加哪些任务进去?直接用脑子现想,恐怕容易出现顾此失彼或者干脆漏掉了任务的情况。 13 | 14 | 当我要开始一个项目的时候,我一般不会直接打开Teambition就写任务,而是使用一个大纲工具——Workflowy来梳理思路,切分任务。等任务已经切分好了,在誊写到Teambition中,如下图所示。 15 | 16 | ![](http://7sbpmp.com1.z0.glb.clouddn.com/2017-10-03-23-53-06.png) 17 | 18 | 但这样就出现了一个问题:首先在Workflowy上面把需要做的任务写好。然后再打开Teambition,把这些任务又誊写到Teambition中。为了减少“誊写”这一步重复劳动,于是就有了TeamFlowy这个小工具。它的作用是自动誊写Workflowy中的特定条目到Teambition中。 19 | 20 | ## 功能介绍 21 | TeamFlowy是一个Python脚本,运行以后,它会登录Workflowy账号,读取上面所有的条目。名为[Teambition]的条目是任务开始的标记,这个条目下面的一级条目会作为任务被添加到Teambition中。如果任务下面还有二级条目,这些二级条目会作为子任务被添加到任务中。由于Teambition是按照项目-Stage-任务-子任务的形式组织一个工程(其中Stage对应了Teambition中工程下面的面板,例如:“待处理”,“进行中”,“完成”。)不会存在子任务的子任务,所以Workflowy中[Teambition]这个条目下面最多出现二级缩进。如下图所示。 22 | 23 | ![](http://7sbpmp.com1.z0.glb.clouddn.com/2017-10-04-10-28-13.png) 24 | 25 | ## 实现原理 26 | 27 | ### Workflowy 28 | 获取Workflowy上面的条目,需要进行三步操作: 29 | 30 | 1. 登录Workflowy 31 | 2. 获取所有条目对应的JSON字符串 32 | 3. 提取需要添加到Teambition中的条目 33 | 34 | #### 登录Workflowy 35 | 打开Chrome监控登录Wokrflowy的过程,可以看到登录Workflowy需要访问的接口为:`https://workflowy.com/accounts/login/`。使用HTTP `POST`方式发送请求,提交的数据包括`username`,`password`和一个不知道用途的`next`。如下图所示。 36 | 37 | ![](http://7sbpmp.com1.z0.glb.clouddn.com/2017-10-04-10-47-51.png) 38 | 39 | 使用Python的第三方网络模块`requests`向这个模块发送`POST`请求,提交用户名和密码即可实现登录。其代码如下: 40 | 41 | ```python 42 | login_url = 'https://workflowy.com/accounts/login/' 43 | session = requests.Session() 44 | session.post(login_url, 45 | data={'username': '12345@qq.com', 46 | 'password': '8888888', 47 | 'next': ''}) 48 | ``` 49 | 50 | #### 获取所有条目 51 | 52 | 使用`requests`的`session`登录Workflowy以后,Cookies会被自动保存到`session`这个对象里面。于是使用`session`继续访问Workfowy就可以以登录后的身份查看自己的各个条目。 53 | 54 | 通过Chrome可以看到获取所有条目的接口为`https://workflowy.com/get_initialization_data?client_version=18`,接口返回的数据是一个包含所有条目的超大型JSON字符串,如下图所示。 55 | 56 | ![](http://7sbpmp.com1.z0.glb.clouddn.com/2017-10-04-10-55-40.png) 57 | 58 | 使用Python的json模块可以解析这个JSON字符串为字典,并获取所有条目,代码如下: 59 | 60 | ```python 61 | outline_url = 'https://workflowy.com/get_initialization_data?client_version=18' 62 | outlines_json = session.get(outline_url).text 63 | outlines_dict = json.loads(outlines_json) 64 | project_list = outlines_dict.get('projectTreeData', {})\ 65 | .get('mainProjectTreeInfo', {})\ 66 | .get('rootProjectChildren', []) 67 | ``` 68 | 69 | #### 提取任务与子任务 70 | 71 | 所有的条目层层嵌套在列表-字典结构中,其基本的形态如下: 72 | 73 | ```python 74 | { 75 | "ch": [子条目], 76 | "lm": 308496, 77 | "id": "957996b9-67ce-51c7-a796-bfbee44e3d3f", 78 | "nm": "AutoEmo" 79 | } 80 | ``` 81 | 其中的`nm`为这个条目的名字。如果一个条目有子条目,那么`ch`列表中就会有很多个字典,每个字典的都是这个结构,如果一个条目没有子条目,那么就没有`ch`这个key。这样一层一层嵌套下去: 82 | 83 | ```python 84 | { 85 | "ch": [ 86 | { 87 | "lm": 558612, 88 | "id": "5117e20b-25ba-ba91-59e1-790c0636f78e", 89 | "nm": "准备并熟背一段自我介绍,在任何需要自我介绍的场合都有用" 90 | }, 91 | { 92 | "lm": 558612, 93 | "id": "4894b23e-6f47-8028-a26a-5fb315fc4e6f", 94 | "nm": "姓名,来自哪里,什么工作", 95 | "ch": [ 96 | {"lm": 5435246, 97 | "id": "4894b23e-6f47-8028-a26a-5fbadfasdc4e6f", 98 | "nm": "工作经验"} 99 | ] 100 | } 101 | ], 102 | "lm": 558612, 103 | "id": "ea282a1c-94f3-1a44-c5b3-7907792e9e6e", 104 | "nm": "自我介绍" 105 | } 106 | ``` 107 | 108 | 由于条目和子条目的结构是一样的,那么就可以使用递归来解析每一个条目。由于需要添加到Teambition的任务,从名为[Teambition]的条目开始,于是可以使用下面这样一个函数来解析: 109 | 110 | ```python 111 | task_dict = {} 112 | def extract_task(sections, task_dict, target_section=False): 113 | for section in sections: 114 | name = section['nm'] 115 | if target_section: 116 | task_dict[name] = [x['nm'] for x in section.get('ch', [])] 117 | continue 118 | 119 | if name == '[Teambition]': 120 | target_section = True 121 | sub_sections = section.get('ch', []) 122 | extract_task(sub_sections, task_dict, target_section=target_section) 123 | ``` 124 | 125 | 下图所示为一段需要添加到Teambition中的条目,运行这段函数以后,得到的结果为: 126 | 127 | ```python 128 | {'登录Workflowy': [], '获取需要添加到Teambition的内容': ['获取任务', '获取子任务'], '调试Teambition API': [], '添加任务到Teambition': []} 129 | ``` 130 | 131 | ![](http://7sbpmp.com1.z0.glb.clouddn.com/2017-10-04-16-09-03.png) 132 | 133 | ## Teambition 134 | 将任务添加到Teambition,需要使用Teambition的Python SDK登录Teambition并调用API添加任务。Teambition的Python SDK在使用`OAuth2`获取`access_token`的时候有一个坑,需要特别注意。 135 | 136 | ### 登录Teambition 137 | 138 | #### 设置Teambition应用 139 | Teambition 是使用`OAuth2`来进行权限验证的,所以需要获取`access_token`。 140 | 141 | 首先打开Teambition的[开发者中心](https://developer.teambition.com/dashboard)并单击`新建应用`,如下图所示。 142 | 143 | ![](http://7sbpmp.com1.z0.glb.clouddn.com/2017-10-04-16-14-38.png) 144 | 145 | 应用名称可以随便写。创建好应用以后,可以看到应用的信息,需要记录`Client ID`和`Client Secret`,如下图所示。 146 | 147 | ![](http://7sbpmp.com1.z0.glb.clouddn.com/2017-10-04-16-18-20.png) 148 | 149 | 点击左侧的`OAuth2`配置,填写回调URL,如下图所示。这里的这个URL其实使用任何一个可以访问的网站的域名都可以,这里以我的博客地址为例。 150 | 151 | ![](http://7sbpmp.com1.z0.glb.clouddn.com/2017-10-04-16-20-49.png) 152 | 153 | #### 使用Python获取access_token 154 | 155 | 首先在Python中安装Teambition的SDK: 156 | 157 | ```bash 158 | pip install teambition 159 | ``` 160 | 接下来,在Python中获取授权URL: 161 | 162 | ```python 163 | from teambition import Teambition 164 | 165 | tb_client_id = '7bfae080-a8dc-11e7-b543-77a936726657' 166 | tb_client_secret = '9830fc8c-81b3-45ed-b3c0-e039ab8f2d8b' 167 | tb = Teambition(tb_client_id, 168 | tb_client_secret) 169 | authorize_url = tb.oauth.get_authorize_url('https://kingname.info') 170 | print(authorize_url) 171 | ``` 172 | 代码运行以后,会得到一段形如下面这段URL的授权URL: 173 | 174 | ``` 175 | https://account.teambition.com/oauth2/authorize?client_id=7bfae080-a8dc-11e7-b543-77a936726657&redirect_uri=https://kingname.info&state=&lang=zh 176 | ``` 177 | 178 | 在电脑浏览器中**人工**访问这个URL,会出现下面这样的页面。 179 | 180 | ![](http://7sbpmp.com1.z0.glb.clouddn.com/2017-10-04-16-31-25.png) 181 | 182 | 单击`授权并登录`,可以看到浏览器上面的网址变为形如:`https://kingname.info/?code=Pn7ebs4sZh3NYOz2FvVJQ4uu`,此时,需要记录`code=`后面的这一串字符串`Pn7ebs4sZh3NYOz2FvVJQ4uu`。 183 | 184 | 接下来就是Teambition的SDK的坑点了,根据Teambition官方文档的说法,要获取access_token,只需要如下两段代码: 185 | 186 | ```python 187 | code = 'Pn7ebs4sZh3NYOz2FvVJQ4uu' #前面浏览器中的字符串 188 | tb.oauth.fetch_access_token(code) 189 | # 上面的代码完成授权,接下来直接使用tb.xxxx就可以操作任务了。 190 | ``` 191 | 192 | 但实际上,上面这一段代码一定会报错。提示`grant invaild`。要解决这个问题,就必需使用Teambition的HTTP 接口来人工获取access_token。 193 | 194 | ```python 195 | code = 'Pn7ebs4sZh3NYOz2FvVJQ4uu' #前面浏览器中的字符串 196 | fetch_result_dict = session.post('https://account.teambition.com/oauth2/access_token', 197 | data={'client_id': tb_client_id, 198 | 'client_secret': tb_client_secret, 199 | 'code': code, 200 | 'grant_type': 'code'}).json() 201 | tb_access_token = fetch_result_dict.get('access_token', '') 202 | ``` 203 | 此时得到的access_token是一段非常长的字符串。接下来,重新初始化tb变量: 204 | 205 | ```python 206 | tb = Teambition(tb_client_id, 207 | tb_client_secret, 208 | access_token=tb_access_token) 209 | ``` 210 | 211 | 初始化以后,使用tb这个变量,就可以对工程和任务进行各种操作了。 212 | 213 | #### Teambition的简单使用 214 | 215 | 要在某个工程里面创建任务,就需要知道工程的ID。首先在Teambition中手动创建一个工程,在浏览器中打开工程,URL中可以看到工程的ID,如下图所示。 216 | 217 | ![](http://7sbpmp.com1.z0.glb.clouddn.com/2017-10-04-16-45-51.png) 218 | 219 | 有了工程ID以后,就可以使用下面的代码创建任务: 220 | 221 | ```python 222 | def create_task(task_name, sub_task_list): 223 | tasklist = tb.tasklists.get(project_id='59d396ee1013d919f3348675')[0] 224 | tasklist_id = tasklist['_id'] 225 | todo_stage_id = tasklist['stageIds'][0] 226 | task_info = tb.tasks.create(task_name, tasklist_id=tasklist_id, stage_id=todo_stage_id) 227 | if sub_task_list: 228 | task_id = task_info['_id'] 229 | for sub_task_name in sub_task_list: 230 | tb.subtasks.create(sub_task_name, task_id=task_id) 231 | print(f'task: {task_name} with sub tasks: {sub_task_list} added.') 232 | ``` 233 | 234 | 这段代码首先使用`tb.tasklists.get()`根据工程ID获得任务组的ID和`待处理`这个面板的ID,接下来调用`tb.tasks.create()`接口添加任务。从添加任务返回的信息里面拿到任务的ID,再根据任务ID,调用`tb.subtasks.create()`添加子任务ID。 235 | 236 | 237 | ## 效果测试 238 | 239 | 上面的代码实现了TeamFlowy的基本逻辑。运行TeamFlowy脚本以后,[Teambition]这个条目下面的任务被成功的添加到了Teambition中,如下图所示。 240 | 241 | ![](http://7sbpmp.com1.z0.glb.clouddn.com/2017-10-04-16-55-34.png) 242 | 243 | 将代码组合起来并进行完善,让代码更容易使用,完整的代码可以查看[https://github.com/kingname/TeamFlowy](https://github.com/kingname/TeamFlowy)。代码需要使用Python 3运行。完整的代码运行效果如下图所示。 244 | 245 | ![](http://7sbpmp.com1.z0.glb.clouddn.com/2017-10-04-17-01-11.png) -------------------------------------------------------------------------------- /TeamFlowy.py: -------------------------------------------------------------------------------- 1 | import json 2 | import requests 3 | from teambition import Teambition 4 | from json.decoder import JSONDecodeError 5 | 6 | 7 | class TeamFlowy(object): 8 | def __init__(self): 9 | self.workflowy_url = 'https://workflowy.com/get_initialization_data?client_version=18' 10 | self.workflowy_login_url = 'https://workflowy.com/accounts/login/' 11 | self.headers = {'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8', 12 | 'accept-encoding': 'gzip, deflate, br', 13 | 'accept-language': 'zh-CN,zh;q=0.8,en;q=0.6', 14 | 'cache-control': 'max-age=0', 15 | 'content-type': 'application/x-www-form-urlencoded', 16 | 'dnt': '1', 17 | 'origin': 'https://workflowy.com', 18 | 'referer': 'https://workflowy.com/accounts/login/', 19 | 'upgrade-insecure-requests': '1', 20 | 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36'} 21 | self.workflowy_username = '' 22 | self.workflowy_password = '' 23 | self.tb_client_id = '' 24 | self.tb_client_secret = '' 25 | self.tb_access_token = '' 26 | self.tb_callback = '' 27 | self.session = None 28 | self.tb = None 29 | self.read_config() 30 | 31 | def read_config(self): 32 | with open('config.json', encoding='utf-8') as f: 33 | config = f.read() 34 | try: 35 | config_dict = json.loads(config) 36 | except JSONDecodeError: 37 | print('Config is invalid.') 38 | return 39 | 40 | workflowy_config = config_dict.get('Workflowy', {}) 41 | self.workflowy_username = workflowy_config.get('username', '') 42 | self.workflowy_password = workflowy_config.get('password', '') 43 | 44 | teambition_config = config_dict.get('Teambition', {}) 45 | self.tb_callback = teambition_config.get('callback', '') 46 | self.tb_client_id = teambition_config.get('client_id', '') 47 | self.tb_client_secret = teambition_config.get('client_secret', '') 48 | self.tb_access_token = teambition_config.get('access_token', '') 49 | 50 | def login_in_workflowy(self): 51 | print('login to workflowy...') 52 | if not self.session: 53 | self.session = requests.Session() 54 | self.session.post(self.workflowy_login_url, 55 | data={'username': self.workflowy_username, 56 | 'password': self.workflowy_password, 57 | 'next': ''}) 58 | return True 59 | 60 | def login_tb(self): 61 | print('login to teambition...') 62 | if self.tb_access_token: 63 | print('use the exists access token.') 64 | self.tb = Teambition(self.tb_client_id, 65 | self.tb_client_secret, 66 | access_token=self.tb_access_token) 67 | return True 68 | else: 69 | print('refetch the access token.') 70 | self.fetch_access_token() 71 | 72 | def fetch_access_token(self): 73 | self.tb = Teambition(self.tb_client_id, 74 | self.tb_client_secret) 75 | authorize_url = self.tb.oauth.get_authorize_url('https://kingname.info') 76 | print('Please open this url: {authorize_url} in web browser and then copy the `code` and input below: \n'.format(authorize_url=authorize_url)) 77 | code = input('input the `code` here: ') 78 | fetch_result_dict = self.session.post('https://account.teambition.com/oauth2/access_token', 79 | data={'client_id': self.tb_client_id, 80 | 'client_secret': self.tb_client_secret, 81 | 'code': code, 82 | 'grant_type': 'code'}).json() 83 | self.tb_access_token = fetch_result_dict.get('access_token', '') 84 | if self.tb_access_token: 85 | self.login_tb() 86 | print('the latest access token is: {tb_access_token}\n update the config.'.format(tb_access_token=self.tb_access_token)) 87 | self.update_config() 88 | return True 89 | else: 90 | print('can not fetch the access token...') 91 | return False 92 | 93 | def update_config(self): 94 | config = { 95 | 'Workflowy': {'username': self.workflowy_username, 96 | 'password': self.workflowy_password}, 97 | 'Teambition': {'client_id': self.tb_client_id, 98 | 'client_secret': self.tb_client_secret, 99 | 'access_token': self.tb_access_token, 100 | 'callback': self.tb_callback}} 101 | 102 | with open('config.json', 'w', encoding='utf-8') as f: 103 | f.write(json.dumps(config, indent=4)) 104 | 105 | def get_outline(self): 106 | print('start to get the outline...') 107 | outlines_json = self.session.get(self.workflowy_url).text 108 | outlines_dict = json.loads(outlines_json) 109 | project_list = outlines_dict.get('projectTreeData', {})\ 110 | .get('mainProjectTreeInfo', {})\ 111 | .get('rootProjectChildren', []) 112 | task_dict = {} 113 | print('start to extract the task to be added...') 114 | self.extract_task(project_list, task_dict) 115 | print('the tasks to be added are: {task_dict}'.format(task_dict=task_dict)) 116 | return task_dict 117 | 118 | def extract_task(self, sections, task_dict, target_section=False): 119 | for section in sections: 120 | name = section['nm'] 121 | if target_section: 122 | task_dict[name] = [x['nm'] for x in section.get('ch', [])] 123 | continue 124 | 125 | if name == '[Teambition]': 126 | target_section = True 127 | sub_sections = section.get('ch', []) 128 | self.extract_task(sub_sections, task_dict, target_section=target_section) 129 | 130 | def create_task(self, task_name, sub_task_list): 131 | tasklist = self.tb.tasklists.get(project_id='59d396ee1013d919f3348675')[0] 132 | tasklist_id = tasklist['_id'] 133 | todo_stage_id = tasklist['stageIds'][0] 134 | task_info = self.tb.tasks.create(task_name, tasklist_id=tasklist_id, stage_id=todo_stage_id) 135 | if sub_task_list: 136 | task_id = task_info['_id'] 137 | for sub_task_name in sub_task_list: 138 | self.tb.subtasks.create(sub_task_name, task_id=task_id) 139 | print('task: {task_name} with sub tasks: {sub_task_list} added.'.format(task_name=task_name, 140 | sub_task_list=sub_task_list)) 141 | 142 | if __name__ == '__main__': 143 | team_flowy = TeamFlowy() 144 | if team_flowy.login_in_workflowy(): 145 | task_dict = team_flowy.get_outline() 146 | print(task_dict) 147 | if team_flowy.login_tb(): 148 | print('start to create tasks into teambition...') 149 | for task_name, sub_task_list in task_dict.items(): 150 | team_flowy.create_task(task_name, sub_task_list) 151 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "Workflowy": { 3 | "username": "123456@kingname.info", 4 | "password": "8888888" 5 | }, 6 | "Teambition": { 7 | "client_id": "9a423420-4342-11e7-8c84-d95f23424e1b", 8 | "client_secret": "fc23426b-b81b-4e2c-8613-9e534524272d1", 9 | "access_token": "", 10 | "callback": "https://kingname.info" 11 | } 12 | } --------------------------------------------------------------------------------