├── .gitignore
├── LICENSE
├── README.md
├── app.yaml
├── appengine_config.py
├── config.py
├── cron.yaml
├── index.yaml
├── lib
├── __init__.py
├── batch.py
├── import_zip_log.py
├── models.py
├── search_api.py
└── slack_dumper.py
├── main.py
├── main_test.py
├── requirements.txt
├── static
├── css
│ └── slack.css
├── img
│ └── icons
│ │ ├── android-icon-144x144.png
│ │ ├── android-icon-192x192.png
│ │ ├── android-icon-36x36.png
│ │ ├── android-icon-48x48.png
│ │ ├── android-icon-72x72.png
│ │ ├── android-icon-96x96.png
│ │ ├── apple-icon-114x114.png
│ │ ├── apple-icon-120x120.png
│ │ ├── apple-icon-144x144.png
│ │ ├── apple-icon-152x152.png
│ │ ├── apple-icon-180x180.png
│ │ ├── apple-icon-57x57.png
│ │ ├── apple-icon-60x60.png
│ │ ├── apple-icon-72x72.png
│ │ ├── apple-icon-76x76.png
│ │ ├── apple-icon-precomposed.png
│ │ ├── apple-icon.png
│ │ ├── browserconfig.xml
│ │ ├── favicon-16x16.png
│ │ ├── favicon-32x32.png
│ │ ├── favicon-96x96.png
│ │ ├── favicon.ico
│ │ ├── manifest.json
│ │ ├── ms-icon-144x144.png
│ │ ├── ms-icon-150x150.png
│ │ ├── ms-icon-310x310.png
│ │ └── ms-icon-70x70.png
└── robots.txt
└── templates
├── index.html
└── search.html
/.gitignore:
--------------------------------------------------------------------------------
1 | *~
2 | *.pyc
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Takami Sato
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 | # Kaggler-ja Slack log gatheing system and viwer on GAE
2 |
3 | I develop this tool to dump and view log in [Kaggler-ja slack](https://kaggler-ja.herokuapp.com/).
4 |
5 | This tool works on Google App Engine (GAE).
6 |
7 | Maybe, you can use this on your slack with small change.
8 |
9 | Our demo is here: https://kaggler-ja-slack-archive.appspot.com/
10 |
11 | # Requirements
12 |
13 | * Python 2.7
14 |
15 | # Usage
16 |
17 | 1. Set up your gcp account and install Google Cloud SDK.
18 | 1. Download third-party libraries to pylib directory `pip install -t pylib -r requirements.txt`
19 | 1. change `config.py`
20 | * APP_NAME: set your app name. (it changes html title and left top workspace name)
21 | 1. Set your Slack API Key as environment variable: SLACK_API_KEY or set the key as Settings.api_key entity on datastore.
22 | * On local development server, you can set environment variable like `dev_appserver.py --env_var SLACK_API_KEY=xxxx app.yaml`
23 | 1. Deploy your app. `gcloud app deploy app.yaml index.yaml cron.yaml`. If you want to try on local, run `dev_appserver.py app.yaml` instead.
24 | 1. Kick first log downloading by accessing http://your_url/cron/job
25 | 1. See your top page http://your_url/
26 |
27 | ### (optional) Import all messages of public channel (over 10,000 messages)
28 |
29 | 1. Export your slack data. see https://get.slack.help/hc/en-us/articles/201658943-Export-your-workspace-data.
30 | 1. Upload it somewhere
31 | 1. set your file's url to `SLACK_DUMPED_LOG_URL`, and set `ROBUST_IMPORTING_MODE
32 |
--------------------------------------------------------------------------------
/app.yaml:
--------------------------------------------------------------------------------
1 | runtime: python27
2 | api_version: 1
3 | threadsafe: true
4 |
5 |
6 | # [START handlers]
7 | handlers:
8 | - url: /static
9 | static_dir: static
10 |
11 | - url: /robots.txt
12 | static_files: static/robots.txt
13 | upload: static/robots.txt
14 |
15 | - url: /.*
16 | script: main.app
17 |
18 |
--------------------------------------------------------------------------------
/appengine_config.py:
--------------------------------------------------------------------------------
1 | # Copyright 2016 Google Inc.
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | # [START vendor]
16 | from google.appengine.ext import vendor
17 |
18 | # Add any libraries installed in the "lib" folder.
19 | vendor.add('pylib')
20 | # [END vendor]
21 |
--------------------------------------------------------------------------------
/config.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | """ Application name """
4 | APP_NAME = 'kaggler-ja'
5 |
6 | """ the number of message """
7 | NUM_MASSAGES_PER_PAGE = 50
8 |
9 | """ request how old message to get """
10 | DAYS_REQUEST_PAST_LOG = 1
11 |
12 | """ Slack exported zip file (https://get.slack.help/hc/en-us/articles/201658943-Export-your-workspace-data) """
13 | SLACK_DUMPED_LOG_URL = 'https://storage.googleapis.com/kaggler-ja-slack-archive.appspot.com/slack_log.zip'
14 |
15 | """ set True when your messages are imported dually"""
16 | ROBUST_IMPORTING_MODE = False
17 |
--------------------------------------------------------------------------------
/cron.yaml:
--------------------------------------------------------------------------------
1 | cron:
2 | - description: "daily get item job"
3 | url: /cron/job
4 | schedule: every 1 hours
5 |
6 | - description: "creating search index for all messages"
7 | url: /cron/create_search_index
8 | schedule: 1 of jan,april,july,oct 00:00
9 |
10 | #- description: "loading log job"
11 | # url: /upload/log
12 | # schedule: every 1 hours
--------------------------------------------------------------------------------
/index.yaml:
--------------------------------------------------------------------------------
1 | indexes:
2 | - kind: Meassage
3 | properties:
4 | - name: ts
5 | direction: desc
6 | - name: channel_id
7 | direction: asc
8 | - name: user
9 | direction: asc
10 |
11 | - kind: Message
12 | properties:
13 | - name: channel_id
14 | - name: ts
15 | direction: desc
16 |
17 | - kind: Message
18 | properties:
19 | - name: channel_id
20 | - name: ts
21 |
22 |
23 | - kind: Message
24 | properties:
25 | - name: user
26 | - name: ts
--------------------------------------------------------------------------------
/lib/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tkm2261/kaggler_ja_slack_archiver/5bb66e5068fe596e289485b5fc91b7d47f50c84f/lib/__init__.py
--------------------------------------------------------------------------------
/lib/batch.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | import os
4 |
5 | from logging import getLogger
6 | logger = getLogger()
7 |
8 | from lib.slack_dumper import SlackDumper
9 | from lib.models import User, Channel, Message, Settings
10 |
11 |
12 | from config import ROBUST_IMPORTING_MODE
13 |
14 |
15 | def get_slack_data(days=None):
16 |
17 | try:
18 | _get_slack_data(days)
19 | except Exception as e:
20 | import traceback
21 | trc = traceback.format_exc()
22 | logger.error(trc)
23 | print(trc)
24 | raise e
25 |
26 |
27 | def _get_slack_data(days):
28 | settings = Settings.query().get()
29 | try:
30 | api_key = settings.api_key
31 | except AttributeError:
32 | api_key = os.environ.get('SLACK_API_KEY')
33 | if api_key is None:
34 | raise Exception('please set environment variable SLACK_API_KEY')
35 | else:
36 | Settings(api_key=api_key).put()
37 |
38 | sd = SlackDumper(api_key)
39 | ents = set(dir(User))
40 | for user_data in sd.get_user_list():
41 | user_id = user_data['id']
42 | user_data = {k: v for k, v in user_data.items() if k in ents}
43 | user = User.query(User.id == user_id).get()
44 | if user is None:
45 | User(**user_data).put()
46 | else:
47 | for k, v in user_data.items():
48 | setattr(user, k, v)
49 | user.put()
50 |
51 | ents = set(dir(Channel))
52 | for channel_data in sd.get_channel_list():
53 | channel_id = channel_data['id']
54 | channel_data = {k: v for k, v in channel_data.items() if k in ents}
55 | channel = Channel.query(Channel.id == channel_id).get()
56 | if channel is None:
57 | Channel(**channel_data).put()
58 | else:
59 | for k, v in channel_data.items():
60 | setattr(channel, k, v)
61 | channel.put()
62 |
63 | ents = set(dir(Message))
64 | for channel_id, messages in sd.get_channels_histoey(days=days).items():
65 | for message in messages:
66 | message['channel_id'] = channel_id
67 | user = message.get('user', '')
68 | ts_raw = message['ts']
69 | ts = float(ts_raw)
70 | message['ts'] = float(ts_raw)
71 | message['ts_raw'] = ts_raw
72 |
73 | message = {k: v for k, v in message.items() if k in ents}
74 | if not ROBUST_IMPORTING_MODE:
75 | msg = Message.query().filter(Message.channel_id == channel_id, Message.user == user, Message.ts == ts).get()
76 | else:
77 | msg = Message.query().filter(Message.channel_id == channel_id, Message.user ==
78 | user, Message.ts > ts - 1, Message.ts < ts + 1).get()
79 | if msg is None:
80 | Message(**message).put_with_search_index()
81 | else:
82 | for k, v in message.items():
83 | setattr(msg, k, v)
84 | msg.put_with_search_index()
85 |
86 |
87 | if __name__ == '__main__':
88 | from logging import StreamHandler, Formatter, FileHandler
89 | log_fmt = Formatter('%(asctime)s %(name)s %(lineno)d [%(levelname)s][%(funcName)s] %(message)s ')
90 |
91 | handler = StreamHandler()
92 | handler.setLevel('INFO')
93 | handler.setFormatter(log_fmt)
94 | logger.setLevel('INFO')
95 | logger.addHandler(handler)
96 |
97 | get_slack_data()
98 |
--------------------------------------------------------------------------------
/lib/import_zip_log.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | import re
4 | import json
5 | import zipfile
6 | from io import BytesIO
7 | from logging import getLogger
8 | logger = getLogger()
9 |
10 | from lib.models import User, Channel, Message, Settings
11 |
12 |
13 | def import_zip_log(io_buffer):
14 |
15 | try:
16 | _import_zip_log(io_buffer)
17 | except Exception as e:
18 | import traceback
19 | trc = traceback.format_exc()
20 | logger.error(trc)
21 | print(trc)
22 | raise e
23 |
24 |
25 | def _import_zip_log(io_buffer):
26 | logger.debug('start')
27 |
28 | szd = SlackZipDumpedLog(zipfile.ZipFile(BytesIO(io_buffer.read())))
29 |
30 | ents = set(dir(User))
31 | for user_data in szd.users_data:
32 | user_id = user_data['id']
33 | user_data = {k: v for k, v in user_data.items() if k in ents}
34 | user = User.query(User.id == user_id).get()
35 | if user is None:
36 | User(**user_data).put()
37 | else:
38 | for k, v in user_data.items():
39 | setattr(user, k, v)
40 | user.put()
41 | logger.debug('user end')
42 |
43 | ents = set(dir(Channel))
44 | for channel_data in szd.channels_data:
45 | channel_id = channel_data['id']
46 | channel_data['created'] = int(channel_data['created'])
47 |
48 | channel_data = {k: v for k, v in channel_data.items() if k in ents}
49 | channel = Channel.query(Channel.id == channel_id).get()
50 | if channel is None:
51 | Channel(**channel_data).put()
52 | else:
53 | for k, v in channel_data.items():
54 | setattr(channel, k, v)
55 | channel.put()
56 |
57 | logger.debug('channel end')
58 | ents = set(dir(Message))
59 | for channel_id, messages in szd.map_message_data.items():
60 | for message in messages.values():
61 | message['channel_id'] = channel_id
62 | user = message.get('user', '')
63 | ts_raw = str(message['ts'])
64 | ts = float(ts_raw)
65 | message['ts'] = ts
66 | message['ts_raw'] = ts_raw
67 |
68 | message = {k: v for k, v in message.items() if k in ents}
69 | msg = Message.query().filter(Message.channel_id == channel_id, Message.user == user, Message.ts == ts).get()
70 | if msg is None:
71 | Message(**message).put_with_search_index()
72 | else:
73 | for k, v in message.items():
74 | setattr(msg, k, v)
75 | msg.put_with_search_index()
76 | logger.debug('exit')
77 |
78 |
79 | class SlackZipDumpedLog(object):
80 |
81 | CHANNELS_DATA_NAME = 'channels.json'
82 | USERS_DATA_NAME = 'users.json'
83 |
84 | def __init__(self, zipfile_buffer):
85 | self.zipfile_buffer = zipfile_buffer
86 |
87 | # print(self.zipfile_buffer.namelist())
88 | self.channels_data = self._extract_channels_data()
89 | self.users_data = self._extract_users_data()
90 | self.map_message_data = self._extract_message_data()
91 |
92 | def _extract_channels_data(self):
93 | data = json.loads(self.zipfile_buffer.read(self.CHANNELS_DATA_NAME))
94 | logger.info('channel num: %s' % len(data))
95 | return data
96 |
97 | def _extract_users_data(self):
98 | data = json.loads(self.zipfile_buffer.read(self.USERS_DATA_NAME))
99 | logger.info('user num: %s' % len(data))
100 | return data
101 |
102 | def _extract_message_data(self):
103 | map_message_data = {}
104 | cnt = 0
105 | for ch_data in self.channels_data:
106 | map_messages = {}
107 | ch_name = ch_data['name']
108 | channel_id = ch_data['id']
109 |
110 | list_ch_logs = sorted([p for p in self.zipfile_buffer.namelist()
111 | if re.match('^' + ch_name + '/.*.json', p) is not None])
112 | for path in list_ch_logs:
113 | msgs = json.loads(self.zipfile_buffer.read(path))
114 | for msg in msgs:
115 | msg['channel_id'] = channel_id
116 | ts_raw = str(msg['ts'])
117 | ts = float(ts_raw)
118 | msg['ts'] = ts
119 | msg['ts_raw'] = ts_raw
120 | user = msg.get('user', '')
121 |
122 | map_messages[ts_raw, user] = msg # overwrite by newdata
123 | cnt += len(map_messages)
124 | map_message_data[channel_id] = map_messages
125 | logger.info('message channel num: %s, total messeage num: %s' % (len(map_message_data), cnt))
126 | return map_message_data
127 |
128 |
129 | if __name__ == '__main__':
130 | from logging import StreamHandler, Formatter, FileHandler
131 | log_fmt = Formatter('%(asctime)s %(name)s %(lineno)d [%(levelname)s][%(funcName)s] %(message)s ')
132 |
133 | logger = getLogger(None)
134 | handler = StreamHandler()
135 | handler.setLevel('DEBUG')
136 | handler.setFormatter(log_fmt)
137 | logger.setLevel('DEBUG')
138 | logger.addHandler(handler)
139 |
140 | import zipfile
141 | with zipfile.ZipFile('test.zip') as zipfile_buffer:
142 | szd = SlackZipDumpedLog(zipfile_buffer)
143 |
--------------------------------------------------------------------------------
/lib/models.py:
--------------------------------------------------------------------------------
1 | import re
2 | import datetime
3 | import emoji
4 |
5 | from google.appengine.ext import ndb
6 |
7 | MAP_EMOJI = {k.lower(): v for k, v in emoji.EMOJI_ALIAS_UNICODE.items()}
8 |
9 |
10 | class Settings(ndb.Model):
11 | api_key = ndb.StringProperty()
12 |
13 |
14 | class User(ndb.Model):
15 |
16 | id = ndb.StringProperty()
17 | team_id = ndb.StringProperty()
18 |
19 | name = ndb.StringProperty()
20 | deleted = ndb.BooleanProperty()
21 |
22 | color = ndb.StringProperty()
23 |
24 | real_name = ndb.StringProperty()
25 |
26 | tz = ndb.StringProperty()
27 |
28 | tz_label = ndb.StringProperty()
29 | tz_offset = ndb.IntegerProperty()
30 |
31 | profile = ndb.JsonProperty(indexed=False)
32 | is_admin = ndb.BooleanProperty()
33 |
34 | is_owner = ndb.BooleanProperty()
35 |
36 | is_primary_owner = ndb.BooleanProperty()
37 |
38 | is_restricted = ndb.BooleanProperty()
39 | is_ultra_restricted = ndb.BooleanProperty()
40 |
41 | is_bot = ndb.BooleanProperty()
42 | updated = ndb.IntegerProperty()
43 | is_app_user = ndb.BooleanProperty()
44 |
45 | def get_display_name(self):
46 | try:
47 | user_name = self.profile['display_name'] if self.profile['display_name'] != '' else self.profile['real_name']
48 | except (TypeError, KeyError):
49 | user_name = ''
50 | if user_name == '':
51 | user_name = self.name
52 | return user_name
53 |
54 |
55 | class Channel(ndb.Model):
56 |
57 | id = ndb.StringProperty()
58 | name = ndb.StringProperty()
59 | is_channle = ndb.BooleanProperty()
60 | created = ndb.IntegerProperty()
61 |
62 | creator = ndb.StringProperty()
63 | is_archived = ndb.BooleanProperty()
64 | is_general = ndb.BooleanProperty()
65 | name_normalized = ndb.StringProperty()
66 |
67 | is_shared = ndb.BooleanProperty()
68 | is_org_shared = ndb.BooleanProperty()
69 | is_member = ndb.BooleanProperty()
70 | is_private = ndb.BooleanProperty()
71 | is_mpim = ndb.BooleanProperty()
72 | members = ndb.StringProperty(repeated=True)
73 | topic = ndb.JsonProperty(indexed=False)
74 | purpose = ndb.JsonProperty(indexed=False)
75 | previous_names = ndb.JsonProperty(indexed=False)
76 | num_members = ndb.IntegerProperty()
77 |
78 |
79 | URL_PATTERN = re.compile(r'<(https?|ftp)(://[\w:;/.?%#&=+-]+)>')
80 | IMG_PATTERN = re.compile(r'<(https?|ftp)(://[\w:;/.?%#&=+-]+)\|(.+?)>')
81 | CHANNEL_PATTERN = re.compile(r'<#([A-Z0-9]+)\|([\w;/.?%#&=+-]+)>')
82 | USER_PATTERN = re.compile(r'<@([A-Z0-9]+)>')
83 |
84 |
85 | class Message(ndb.Model):
86 | channel_id = ndb.StringProperty()
87 | type = ndb.StringProperty()
88 | user = ndb.StringProperty()
89 | text = ndb.StringProperty(indexed=False)
90 | ts = ndb.FloatProperty()
91 | ts_raw = ndb.StringProperty()
92 | reactions = ndb.JsonProperty()
93 |
94 | _user_data = None
95 |
96 | @property
97 | def user_data(self):
98 | if self._user_data is None:
99 | self._user_data = User.query(User.id == self.user).get()
100 | return self._user_data
101 |
102 | def get_channel_name(self):
103 | try:
104 | return Channel.query(Channel.id == self.channel_id).get().name
105 | except Exception:
106 | return ''
107 |
108 | def get_ts_timestamp(self):
109 | try:
110 | return datetime.datetime.fromtimestamp(self.ts).strftime('%Y/%m/%d %H:%M')
111 | except Exception:
112 | return None
113 |
114 | def get_datetime(self):
115 | try:
116 | return datetime.datetime.fromtimestamp(self.ts)
117 | except Exception:
118 | return None
119 |
120 | def get_user_name(self):
121 | if self.user_data is None:
122 | return self.user
123 |
124 | return self.user_data.get_display_name()
125 |
126 | def get_user_img_url(self):
127 | try:
128 | return self.user_data.profile['image_48']
129 | except (TypeError, KeyError, AttributeError):
130 | return ''
131 |
132 | def get_reactions(self):
133 | ret = []
134 | for react in self.reactions:
135 | text = u':%s:' % react['name'].replace('-', '_')
136 | text = MAP_EMOJI.get(text, text)
137 | react['name'] = text
138 | ret.append(react)
139 | return ret
140 |
141 | def _conv_url(self, text):
142 | text = URL_PATTERN.sub(r'\1\2', text)
143 | text = IMG_PATTERN.sub(r'\1\2', text)
144 | return text
145 |
146 | def _conv_channel_url(self, text):
147 | return CHANNEL_PATTERN.sub(r'#\2', text)
148 |
149 | def _conv_user_name(self, text):
150 | def _get_name(x):
151 | user_data = User.query(User.id == x.group(1)).get()
152 | if user_data is not None:
153 | user_name = user_data.get_display_name()
154 | else:
155 | user_name = x.group(1)
156 | return user_name
157 |
158 | return USER_PATTERN.sub(lambda x: r'@' + _get_name(x) + '', text)
159 |
160 | def _conv_emoji(self, text):
161 | return emoji.emojize(text, use_aliases=True)
162 |
163 | def get_conved_text(self):
164 | text = self.text
165 | text = self._conv_url(text)
166 | text = self._conv_channel_url(text)
167 | text = self._conv_user_name(text)
168 | text = self._conv_emoji(text)
169 |
170 | text = text.replace('\n', '
')
171 | return text
172 |
173 | def put_with_search_index(self):
174 | from lib.search_api import SearchApiHandler
175 | self.put()
176 | sah = SearchApiHandler()
177 | sah.put_one_document(self)
178 |
--------------------------------------------------------------------------------
/lib/search_api.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | from google.appengine.api import search
4 | from lib.models import Message
5 |
6 | from logging import getLogger
7 | logger = getLogger(__name__)
8 |
9 |
10 | class SearchApiHandler(object):
11 |
12 | INDEX_NAME = 'message_index'
13 | MSG_PER_PAGE_NUM = 100
14 |
15 | def __init__(self):
16 | self.index = search.Index(name=self.INDEX_NAME)
17 |
18 | def put_all_documents(self):
19 | logger.debug('enter')
20 | for msg in Message.query():
21 | self.put_one_document(msg)
22 | logger.debug('exit')
23 |
24 | def put_one_document(self, msg):
25 | doc_id = '{channel_id}_{user}_{ts}'.format(channel_id=msg.channel_id, user=msg.user, ts=int(msg.ts))
26 |
27 | doc = search.Document(
28 | doc_id=doc_id,
29 | fields=[search.TextField(name='text', value=msg.text),
30 | search.AtomField(name='user_name', value=msg.get_user_name()),
31 | search.AtomField(name='channel_id', value=msg.channel_id),
32 | search.AtomField(name='msg_key', value=str(msg.key.id())),
33 | search.DateField(name='ts', value=msg.get_datetime()),
34 | ]
35 | )
36 | # Index the document.
37 | try:
38 | self.index.put(doc)
39 | except search.PutError, e:
40 | result = e.results[0]
41 | if result.code == search.OperationResult.TRANSIENT_ERROR:
42 | # possibly retry indexing result.object_id
43 | return self.put_one_document(msg)
44 | except search.Error, e:
45 | # possibly log the failure
46 | logger.error('%s' % e.msg)
47 | raise e
48 |
49 | def search_query(self, query_string, page=0):
50 |
51 | # Create sort options to sort on price and brand.
52 | sort_ts = search.SortExpression(
53 | expression='ts',
54 | direction=search.SortExpression.DESCENDING,
55 | default_value=0)
56 | sort_options = search.SortOptions(expressions=[sort_ts])
57 |
58 | # Create query options using the sort options and expressions created
59 | # above.
60 | query_options = search.QueryOptions(
61 | limit=self.MSG_PER_PAGE_NUM,
62 | offset=page * self.MSG_PER_PAGE_NUM,
63 | returned_fields=['msg_key'],
64 | sort_options=sort_options)
65 |
66 | # Build the Query and run the search
67 | try:
68 | query = search.Query(query_string=query_string, options=query_options)
69 | except search.QueryError:
70 | return []
71 | results = self.index.search(query)
72 |
73 | return results
74 |
75 |
76 | if __name__ == '__main__':
77 | pass
78 |
--------------------------------------------------------------------------------
/lib/slack_dumper.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | import time
4 | import json
5 |
6 | import urllib2
7 |
8 | from logging import getLogger
9 | logger = getLogger(__name__)
10 |
11 |
12 | class SlackDumper(object):
13 | USERS_LIST_URL_FORMAT = 'https://slack.com/api/users.list?token={token}&cursor={cursor}&limit=1000'
14 | CHANNELS_LIST_URL_FORMAT = 'https://slack.com/api/channels.list?token={token}&cursor={cursor}&limit=1000'
15 | CHANNELS_HISTORY_URL_FORMAT = ('https://slack.com/api/channels.history?' +
16 | 'token={token}&channel={channel}&latest={latest}&oldest={oldest}&count=100')
17 |
18 | def __init__(self,
19 | api_key):
20 | self.api_key = api_key
21 |
22 | self.list_users = None
23 | self.list_channels = None
24 |
25 | def get_user_list(self):
26 | logger.debug('enter')
27 | if self.list_users is not None:
28 | logger.debug('return cached user list')
29 | return self.list_users
30 |
31 | list_users = []
32 | cursor = ''
33 | while 1:
34 | url = self.USERS_LIST_URL_FORMAT.format(token=self.api_key, cursor=cursor)
35 |
36 | r = urllib2.urlopen(url)
37 | data = json.loads(r.read())
38 |
39 | if not data['ok']:
40 | raise Exception('fail to get users.list data')
41 | list_users += data['members']
42 | cursor = data['response_metadata']['next_cursor']
43 | if cursor == '':
44 | break
45 |
46 | self.list_users = list_users
47 |
48 | logger.info('get %s members' % len(list_users))
49 | logger.debug('exit')
50 | return list_users
51 |
52 | def get_channel_list(self):
53 | logger.debug('enter')
54 | if self.list_channels is not None:
55 | logger.debug('return cached channel list')
56 | return self.list_channels
57 |
58 | list_channels = []
59 | cursor = ''
60 | while 1:
61 | url = self.CHANNELS_LIST_URL_FORMAT.format(token=self.api_key, cursor=cursor)
62 |
63 | r = urllib2.urlopen(url)
64 | data = json.loads(r.read())
65 |
66 | if not data['ok']:
67 | raise Exception('fail to get channels.list data')
68 | list_channels += data['channels']
69 | cursor = data['response_metadata']['next_cursor']
70 | if cursor == '':
71 | break
72 |
73 | self.list_channels = list_channels
74 | logger.info('get %s channels' % len(list_channels))
75 | logger.debug('exit')
76 | return list_channels
77 |
78 | def get_channels_histoey(self, days=None):
79 | logger.debug('enter')
80 | latest = time.time()
81 | oldest = time.time() - (3600 * 24 * days) if days is not None else 0
82 |
83 | map_channles_hist = {}
84 | for ch_data in self.get_channel_list():
85 | channel_id = ch_data['id']
86 | list_channel_hist = self.get_channel_hist(channel_id, latest, oldest)
87 | map_channles_hist[channel_id] = list_channel_hist
88 | logger.debug('exit')
89 |
90 | return map_channles_hist
91 |
92 | def get_channel_hist(self, channel_id, latest, oldest):
93 | list_channel_hist = []
94 | while 1:
95 | url = self.CHANNELS_HISTORY_URL_FORMAT.format(token=self.api_key,
96 | channel=channel_id,
97 | latest=latest,
98 | oldest=oldest)
99 |
100 | r = urllib2.urlopen(url)
101 | data = json.loads(r.read())
102 |
103 | if not data['ok']:
104 | raise Exception('fail to get %s channel history data' % channel_id)
105 | list_channel_hist += data['messages']
106 | if data['has_more']:
107 | latest = data['messages'][-1]['ts']
108 | else:
109 | break
110 | logger.info('get %s messages in %s' % (len(list_channel_hist), channel_id))
111 | logger.debug('exit')
112 | return list_channel_hist
113 |
114 |
115 | if __name__ == '__main__':
116 | import os
117 | api_key = os.environ.get('KAGGLER_SLACK_API_KEY')
118 | if api_key is None:
119 | raise Exception('please set environment variable KAGGLER_SLACK_API_KEY')
120 |
121 | sd = SlackDumper(api_key)
122 | # sd.get_user_list()
123 | sd.get_channel_list()
124 | ttt = sd.get_channels_histoey()
125 |
--------------------------------------------------------------------------------
/main.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import time
3 | import urllib2
4 | import traceback
5 | import logging
6 | logging.getLogger().setLevel(logging.DEBUG)
7 |
8 | from google.appengine.ext import ndb
9 | from flask import Flask, render_template, request, redirect
10 |
11 | from lib.models import User, Channel, Message
12 | from lib.batch import get_slack_data
13 | from lib.import_zip_log import import_zip_log
14 | from lib.search_api import SearchApiHandler
15 |
16 | app = Flask(__name__)
17 |
18 | from config import APP_NAME, NUM_MASSAGES_PER_PAGE, DAYS_REQUEST_PAST_LOG, SLACK_DUMPED_LOG_URL
19 |
20 |
21 | @app.route('/')
22 | def index():
23 | """ Top Page
24 | """
25 |
26 | ch = request.args.get('ch')
27 | ch_data = Channel.query().filter(Channel.id == ch).get()
28 | if ch_data is None:
29 | ch_data = Channel.query().order(Channel.created).get()
30 |
31 | try:
32 | ch_name = ch_data.name
33 | ch = ch_data.id
34 | except AttributeError:
35 | # maybe there is no data. try to get log data.
36 | return redirect('/cron/job')
37 |
38 | ts = request.args.get('ts')
39 | try:
40 | ts = float(ts)
41 | except (ValueError, TypeError):
42 | ts = time.time()
43 |
44 | is_forward = request.args.get('type')
45 | try:
46 | is_forward = int(is_forward)
47 | except (ValueError, TypeError):
48 | is_forward = 1
49 |
50 | channels = Channel.query().order(Channel.created).iter()
51 | if is_forward:
52 | messages = Message.query().filter(Message.channel_id == ch,
53 | Message.ts < ts).order(-Message.ts).fetch(NUM_MASSAGES_PER_PAGE)
54 | else:
55 | messages = Message.query().filter(Message.channel_id == ch,
56 | Message.ts > ts).order(Message.ts).fetch(NUM_MASSAGES_PER_PAGE)
57 | messages = [m for m in sorted(messages, key=lambda x: x.ts, reverse=True)]
58 |
59 | if len(messages) > 0:
60 | current_ts = messages[0].ts + 0.01
61 | next_ts = messages[-1].ts
62 | else:
63 | current_ts = ts
64 | next_ts = ts
65 |
66 | return render_template('index.html',
67 | app_name=APP_NAME,
68 | current_ch_name=ch_name,
69 | channels=channels,
70 | messages=messages,
71 | current_ch=ch,
72 | current_ts=current_ts,
73 | next_ts=next_ts,
74 | )
75 |
76 |
77 | @app.route('/search/')
78 | def search():
79 | """ search Page
80 | """
81 | query_string = request.args.get('q')
82 |
83 | channels = Channel.query().order(Channel.created).iter()
84 | page = request.args.get('p')
85 | try:
86 | page = int(page)
87 | except (ValueError, TypeError):
88 | page = 0
89 |
90 | sah = SearchApiHandler()
91 | list_msg = sah.search_query(query_string, page)
92 |
93 | #messages = [Message.query(ndb.Key('message', int(m['msg_key'][0].value))) for m in list_msg]
94 | messages = [ndb.Key(Message, int(m['msg_key'][0].value)).get() for m in list_msg]
95 | return render_template('search.html',
96 | app_name=APP_NAME,
97 | channels=channels,
98 | messages=messages,
99 | query_string=query_string,
100 | page=page,
101 | )
102 |
103 |
104 | @app.route('/cron/job')
105 | def batch():
106 | """ Get new messages from API
107 | """
108 | get_slack_data(days=DAYS_REQUEST_PAST_LOG)
109 | return 'successfully end.', 200
110 |
111 |
112 | @app.route('/cron/create_search_index')
113 | def create_search_index():
114 | """ Get new messages from API
115 | """
116 | sah = SearchApiHandler()
117 | sah.put_all_documents()
118 | return 'successfully end.', 200
119 |
120 |
121 | @app.route('/upload/log')
122 | def upload_log():
123 | """ Import messages from exported zip file.
124 |
125 | set your exported zip file to SLACK_DUMPED_LOG_URL
126 | """
127 | r = urllib2.urlopen(SLACK_DUMPED_LOG_URL)
128 | import_zip_log(r)
129 |
130 | return 'successfully end.', 200
131 |
132 |
133 | @app.errorhandler(500)
134 | def server_error(e):
135 | # Log the error and stacktrace.
136 | logging.error('An internal error occurred.' + traceback.format_exc())
137 | return 'An internal error occurred.', 500
138 |
--------------------------------------------------------------------------------
/main_test.py:
--------------------------------------------------------------------------------
1 | # Copyright 2016 Google Inc. All Rights Reserved.
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | import pytest
16 |
17 | from logging import StreamHandler, Formatter, FileHandler, getLogger
18 | import os
19 | from lib.slack_dumper import SlackDumper
20 |
21 |
22 | logger = getLogger(None)
23 |
24 |
25 | log_fmt = Formatter('%(asctime)s %(name)s %(lineno)d [%(levelname)s][%(funcName)s] %(message)s ')
26 |
27 | handler = StreamHandler()
28 | handler.setLevel('INFO')
29 | handler.setFormatter(log_fmt)
30 | logger.setLevel('INFO')
31 | logger.addHandler(handler)
32 |
33 |
34 | @pytest.fixture
35 | def app():
36 | import main
37 | main.app.testing = True
38 | return main.app.test_client()
39 |
40 |
41 | def test_get_slack_api():
42 | api_key = os.environ.get('KAGGLER_SLACK_API_KEY')
43 | if api_key is None:
44 | raise Exception('please set environment variable KAGGLER_SLACK_API_KEY')
45 |
46 | sd = SlackDumper(api_key)
47 | # sd.get_user_list()
48 | a = sd.get_channel_list()
49 | import pdb
50 | pdb.set_trace()
51 | # sd.get_channels_histoey()
52 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | Flask==0.12.3
2 | requests==2.20.0
3 | Werkzeug<0.13.0,>=0.12.0
4 | emoji==0.5.0
5 |
--------------------------------------------------------------------------------
/static/css/slack.css:
--------------------------------------------------------------------------------
1 | body{
2 |
3 | padding-top: 0px;
4 | background-color:rgba(99, 74, 96, 0.9);
5 | background-size: cover;
6 |
7 | }
8 |
9 | #square {
10 | position: relative;
11 | #min-width: 400px;
12 | height: 100vh;
13 | display: block;
14 | margin: auto;
15 | background: white;
16 | overflow: hidden;
17 | z-index: 1;
18 | }
19 |
20 | #leftSquare {
21 | height: 100vh;
22 | position: relative;
23 | background:#4D394B;
24 | overflow-y: scroll;
25 | overflow-x: hidden;
26 | padding: 10px 10px 0 20px;
27 | }
28 |
29 | #rightSquare {
30 | height: 100vh;
31 | overflow: scroll;
32 | position: relative;
33 | padding: 10px 10px 0 10px;
34 | }
35 |
36 | .light{
37 | margin-left: 5px;
38 | opacity: 0.5;
39 | }
40 |
41 | .highlight-channel{
42 | background-color: #4C9689;
43 | }
44 |
45 | #header {
46 | display: block;
47 | background-size: cover;
48 | }
49 |
50 | #footer {
51 | display: block;
52 | background-size: cover;
53 | text-align: center;
54 | }
55 |
56 |
57 | .title{
58 | color: white;
59 | font-family:"lato";
60 | font-size: 20px;
61 | font-weight: 800;
62 | padding-left: 10%;
63 | text-align: left;
64 | #z-index: 3;
65 | }
66 |
67 | .subtitle{
68 | position: absolute;
69 | left: 19%;
70 | top: 6%;
71 | color: white;
72 | font-family:"lato";
73 | font-size: 13px;
74 | opacity: 0.5;
75 | font-weight: 100;
76 | text-align: center;
77 | z-index: 3;
78 | }
79 |
80 | #channel-title{
81 | color: #2C2D30;
82 | font-family:"lato";
83 | font-size: 18px;
84 | font-weight: 800;
85 | text-align: left;
86 | z-index: 3;
87 | }
88 |
89 | .channels{
90 | #position: absolute;
91 | #left: 13%;
92 | #top: 14%;
93 | width: 100%;
94 | color: white;
95 | font-family:"lato";
96 | font-size: 13px;
97 | opacity: 0.6;
98 | font-weight: 400;
99 | padding-left: 10%;
100 | #text-align: center;
101 | z-index: 3;
102 | }
103 |
104 | .channel{
105 | color: white;
106 | white-space: nowrap;
107 | font-family:"lato";
108 | font-size: 13px;
109 | opacity: 1.0;
110 | font-weight: 100;
111 | padding-left: 15%;
112 | text-align: left;
113 | margin-top: 5px;
114 | margin-bottom: 5px;
115 | }
116 |
117 |
118 | .add{
119 | position: absolute;
120 | right: -72%;
121 | opacity: 0.6;
122 | top: 5%;
123 |
124 | }
125 |
126 | .add-two{
127 | position: absolute;
128 | right: -15%;
129 | opacity: 0.6;
130 | top: 5%;
131 | z-index: 3;
132 |
133 | }
134 |
135 | .star{
136 | padding-left: 3%;
137 | opacity: 0.6;
138 | z-index: 3;
139 |
140 | }
141 | .user{
142 | padding-left: 4%;
143 | opacity: 0.6;
144 | z-index: 3;
145 |
146 | }
147 | .pin{
148 | padding-left: 4%;
149 | opacity: 0.4;
150 | z-index: 3;
151 |
152 | }
153 | .phone{
154 | position: absolute;
155 | top: 45%;
156 | right: 48%;
157 | opacity: 0.6;
158 | z-index: 3;
159 | }
160 |
161 | .cog{
162 | position: absolute;
163 | top: 45%;
164 | right: 42%;
165 | opacity: 0.6;
166 | z-index: 3;
167 |
168 | }
169 |
170 | .details{
171 | position: absolute;
172 | top: 45%;
173 | right: 36%;
174 | opacity: 0.6;
175 | z-index: 3;
176 |
177 | }
178 |
179 | .search-glass{
180 | position: absolute;
181 | top: 43%;
182 | right: 28%;
183 | opacity: 0.5;
184 | z-index: 3;
185 |
186 | }
187 |
188 | .at{
189 |
190 | position: absolute;
191 | top: 45%;
192 | right: 12%;
193 | opacity: 0.6;
194 | z-index: 3;
195 | }
196 |
197 | .star-o{
198 |
199 | position: absolute;
200 | top: 45%;
201 | right: 7%;
202 | opacity: 0.6;
203 | z-index: 3;
204 |
205 | }
206 |
207 | .menu{
208 |
209 | position: absolute;
210 | top: 45%;
211 | right: 3%;
212 | opacity: 0.6;
213 | z-index: 3;
214 | }
215 |
216 | .search{
217 | position: absolute;
218 | right: 17%;
219 | top: 38%;
220 | height: 30%;
221 | width: 14%;
222 | font-weight: 100;
223 | padding-left: 20px;
224 | font-family:"lato";
225 | font-size: 12px;
226 | opacity: 0.6;
227 | color: #2C2D30;
228 | }
229 |
230 | .line{
231 |
232 | position: absolute;
233 | left: 7%;
234 | top: 46.5%;
235 | font-family:"lato";
236 | font-size: 13px;
237 | opacity: 0.5;
238 | font-weight: 100;
239 | color: #2C2D30;
240 |
241 | }
242 |
243 | .bottom-header-line{
244 | margin-left: 8px;
245 | width: 98%;
246 | margin-top: 0;
247 |
248 | }
249 |
250 |
251 | .message{
252 | padding-bottom: 20px;
253 | margin: 0px;
254 | }
255 |
256 |
257 | .bell{
258 | position: absolute;
259 | color: #D0CDD0;
260 | opacity: 0.6;
261 | top: 5%;
262 | left: 89%;
263 |
264 | }
265 |
266 | .down{
267 | position: absolute;
268 | color: #D0CDD0;
269 | opacity: 0.3;
270 | top: 4.25%;
271 | left: 80%;
272 |
273 | }
274 |
275 | #message-area{
276 | padding: 0 20px 0 5px;
277 | }
278 |
279 | .person{
280 |
281 | }
282 |
283 | .person-img{
284 | border-radius: 10%;
285 | }
286 |
287 | .person-img-container{
288 | min-width: 48px;
289 | padding-right: 5px;
290 | padding-left: 0px;
291 | text-align: right;
292 | }
293 |
294 | .person-name{
295 | color: #2C2D30;
296 | font-family:"lato";
297 | font-size: 15px;
298 | font-weight: 800;
299 | text-align: left;
300 | }
301 |
302 |
303 |
304 | .time{
305 | color: #2C2D30;
306 | padding-left: 5px;
307 | font-family:"lato";
308 | font-size: 12px;
309 | opacity: 0.6;
310 | font-weight: 400;
311 | }
312 |
313 | .message-link{
314 | padding-left: 5px;
315 | font-family:"lato";
316 | font-size: 12px;
317 | opacity: 0.7;
318 | font-weight: 400;
319 | }
320 |
321 | .person-text{
322 | color: #2C2D30;
323 | font-family:"lato";
324 | font-size: 15px;
325 | margin-bottom:0;
326 | font-weight: 400;
327 | text-align: left;
328 | word-wrap: break-word;
329 | }
330 |
331 | .reaction{
332 | font-size: 15px;
333 | background-color: #EAF6FD;
334 | color: #00A7EA;
335 | padding: 3px;
336 | margin-right: 5px;
337 | border-radius: 5px;
338 | }
339 |
340 | .link-user{
341 | background-color: #EAF6FD;
342 | color: #00A7EA;
343 |
344 | }
345 |
346 | .search-bottom{
347 | position: absolute;
348 | display: block;
349 | margin: auto;
350 | left: 28%;
351 | bottom: 3%;
352 | height: 5%;
353 | width: 70%;
354 | font-weight: 100;
355 | padding-left: 28px;
356 | font-family:"lato";
357 | font-size: 12px;
358 | opacity: 0.6;
359 | color: #2C2D30;
360 | }
361 |
362 | .smile{
363 |
364 | position: absolute;
365 | display: block;
366 | margin: auto;
367 | bottom: 4%;
368 | right: 3%;
369 | opacity: 0.6;
370 | color: #2C2D30;
371 | }
372 |
373 | .plus{
374 |
375 | position: absolute;
376 | display: block;
377 | margin: auto;
378 | bottom: 3.75%;
379 | left: 28.75%;
380 | opacity: 0.3;
381 | color: #2C2D30;
382 | }
383 |
384 |
--------------------------------------------------------------------------------
/static/img/icons/android-icon-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tkm2261/kaggler_ja_slack_archiver/5bb66e5068fe596e289485b5fc91b7d47f50c84f/static/img/icons/android-icon-144x144.png
--------------------------------------------------------------------------------
/static/img/icons/android-icon-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tkm2261/kaggler_ja_slack_archiver/5bb66e5068fe596e289485b5fc91b7d47f50c84f/static/img/icons/android-icon-192x192.png
--------------------------------------------------------------------------------
/static/img/icons/android-icon-36x36.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tkm2261/kaggler_ja_slack_archiver/5bb66e5068fe596e289485b5fc91b7d47f50c84f/static/img/icons/android-icon-36x36.png
--------------------------------------------------------------------------------
/static/img/icons/android-icon-48x48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tkm2261/kaggler_ja_slack_archiver/5bb66e5068fe596e289485b5fc91b7d47f50c84f/static/img/icons/android-icon-48x48.png
--------------------------------------------------------------------------------
/static/img/icons/android-icon-72x72.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tkm2261/kaggler_ja_slack_archiver/5bb66e5068fe596e289485b5fc91b7d47f50c84f/static/img/icons/android-icon-72x72.png
--------------------------------------------------------------------------------
/static/img/icons/android-icon-96x96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tkm2261/kaggler_ja_slack_archiver/5bb66e5068fe596e289485b5fc91b7d47f50c84f/static/img/icons/android-icon-96x96.png
--------------------------------------------------------------------------------
/static/img/icons/apple-icon-114x114.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tkm2261/kaggler_ja_slack_archiver/5bb66e5068fe596e289485b5fc91b7d47f50c84f/static/img/icons/apple-icon-114x114.png
--------------------------------------------------------------------------------
/static/img/icons/apple-icon-120x120.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tkm2261/kaggler_ja_slack_archiver/5bb66e5068fe596e289485b5fc91b7d47f50c84f/static/img/icons/apple-icon-120x120.png
--------------------------------------------------------------------------------
/static/img/icons/apple-icon-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tkm2261/kaggler_ja_slack_archiver/5bb66e5068fe596e289485b5fc91b7d47f50c84f/static/img/icons/apple-icon-144x144.png
--------------------------------------------------------------------------------
/static/img/icons/apple-icon-152x152.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tkm2261/kaggler_ja_slack_archiver/5bb66e5068fe596e289485b5fc91b7d47f50c84f/static/img/icons/apple-icon-152x152.png
--------------------------------------------------------------------------------
/static/img/icons/apple-icon-180x180.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tkm2261/kaggler_ja_slack_archiver/5bb66e5068fe596e289485b5fc91b7d47f50c84f/static/img/icons/apple-icon-180x180.png
--------------------------------------------------------------------------------
/static/img/icons/apple-icon-57x57.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tkm2261/kaggler_ja_slack_archiver/5bb66e5068fe596e289485b5fc91b7d47f50c84f/static/img/icons/apple-icon-57x57.png
--------------------------------------------------------------------------------
/static/img/icons/apple-icon-60x60.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tkm2261/kaggler_ja_slack_archiver/5bb66e5068fe596e289485b5fc91b7d47f50c84f/static/img/icons/apple-icon-60x60.png
--------------------------------------------------------------------------------
/static/img/icons/apple-icon-72x72.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tkm2261/kaggler_ja_slack_archiver/5bb66e5068fe596e289485b5fc91b7d47f50c84f/static/img/icons/apple-icon-72x72.png
--------------------------------------------------------------------------------
/static/img/icons/apple-icon-76x76.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tkm2261/kaggler_ja_slack_archiver/5bb66e5068fe596e289485b5fc91b7d47f50c84f/static/img/icons/apple-icon-76x76.png
--------------------------------------------------------------------------------
/static/img/icons/apple-icon-precomposed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tkm2261/kaggler_ja_slack_archiver/5bb66e5068fe596e289485b5fc91b7d47f50c84f/static/img/icons/apple-icon-precomposed.png
--------------------------------------------------------------------------------
/static/img/icons/apple-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tkm2261/kaggler_ja_slack_archiver/5bb66e5068fe596e289485b5fc91b7d47f50c84f/static/img/icons/apple-icon.png
--------------------------------------------------------------------------------
/static/img/icons/browserconfig.xml:
--------------------------------------------------------------------------------
1 |
2 |