inat-channel 0.8.0.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +674 -0
- data/README.md +150 -0
- data/bin/inat-channel +29 -0
- data/lib/inat-channel/api.rb +108 -0
- data/lib/inat-channel/config.rb +87 -0
- data/lib/inat-channel/data.rb +94 -0
- data/lib/inat-channel/icons.rb +58 -0
- data/lib/inat-channel/lock.rb +83 -0
- data/lib/inat-channel/logger.rb +28 -0
- data/lib/inat-channel/message.rb +139 -0
- data/lib/inat-channel/telegram.rb +89 -0
- data/lib/inat-channel/version.rb +6 -0
- data/lib/inat-channel.rb +11 -0
- metadata +181 -0
data/README.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# iNat Telegram Poster
|
|
2
|
+
|
|
3
|
+
[](LICENSE)
|
|
4
|
+
[](https://badge.fury.io/rb/inat-channel)
|
|
5
|
+
[](https://github.com/inat-get/inat-channel/actions/workflows/ruby.yml)
|
|
6
|
+

|
|
7
|
+
|
|
8
|
+
**Автоматический бот**, который ежедневно публикует в Telegram-каналы случайные, популярные наблюдения из iNaturalist, согласно гибким настройкам API-запросов.
|
|
9
|
+
|
|
10
|
+
## Версия
|
|
11
|
+
|
|
12
|
+
+ **0.8.0** — *предварительный* релиз.
|
|
13
|
+
|
|
14
|
+
## Основные возможности
|
|
15
|
+
|
|
16
|
+
- Поддержка гибких запросов iNaturalist API: проекты, таксоны, места, пользователи и др.
|
|
17
|
+
- Очередь публикаций с приоритетом свежих наблюдений, затем пулом и архивом отправленных (без дубликатов).
|
|
18
|
+
- Поддержка до 10 фотографий и геолокационных ссылок в одном посте.
|
|
19
|
+
- Вывод эмодзи и хештегов, соответствующих иерархии таксонов.
|
|
20
|
+
- Автоматическая генерация ссылок на проекты и места из настройки.
|
|
21
|
+
- Механизм блокировки для безопасного параллельного запуска.
|
|
22
|
+
- Автоматические повторы запросов, уведомления администратору, логирование.
|
|
23
|
+
|
|
24
|
+
## Быстрый старт
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# 1. Установка
|
|
28
|
+
bundle install
|
|
29
|
+
|
|
30
|
+
# 2. Создайте конфиг (например, config.yaml)
|
|
31
|
+
cat > config.yaml << EOF
|
|
32
|
+
base_query:
|
|
33
|
+
project_id: 12345
|
|
34
|
+
popular: true
|
|
35
|
+
quality_grade: research
|
|
36
|
+
locale: ru
|
|
37
|
+
days_back: 30
|
|
38
|
+
chat_id: -1001234567890
|
|
39
|
+
retries: 5
|
|
40
|
+
EOF
|
|
41
|
+
|
|
42
|
+
# 3. Установите переменные окружения
|
|
43
|
+
export TELEGRAM_BOT_TOKEN="ваш_токен_бота"
|
|
44
|
+
export ADMIN_TELEGRAM_ID="ID_администратора_в_Telegram"
|
|
45
|
+
|
|
46
|
+
# 4. Запуск
|
|
47
|
+
bin/inat-channel -c config.yaml
|
|
48
|
+
|
|
49
|
+
# 5. Настройте cron для ежедневного запуска
|
|
50
|
+
echo "0 9 * * * cd /путь/к/проекту && bin/inat-channel -c config.yaml >> log/cron.log 2>&1" | crontab -
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Конфигурация (пример)
|
|
54
|
+
|
|
55
|
+
```yaml
|
|
56
|
+
base_query: # Параметры запроса к iNaturalist API (Hash)
|
|
57
|
+
project_id: 12345
|
|
58
|
+
popular: true
|
|
59
|
+
quality_grade: research
|
|
60
|
+
locale: ru
|
|
61
|
+
days_back: 30 # Кол-во дней назад для фильтрации (integer > 0)
|
|
62
|
+
chat_id: -1001234567890 # ID Telegram канала или группы
|
|
63
|
+
retries: 5 # Кол-во повторных попыток запросов (API и Telegram)
|
|
64
|
+
|
|
65
|
+
# Опционально — пути хранения данных (лучше использовать разные для параллельных запусков)
|
|
66
|
+
pool_file: "data/pool.json"
|
|
67
|
+
sent_file: "data/sent.json"
|
|
68
|
+
lock_file: "data/bot.lock"
|
|
69
|
+
|
|
70
|
+
# Автоссылки по place_ids
|
|
71
|
+
places:
|
|
72
|
+
group:
|
|
73
|
+
- place_ids: [1, 2]
|
|
74
|
+
link: "https://inaturalist.org/projects/12345"
|
|
75
|
+
text: "Moscow Region Project"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Запуск нескольких экземпляров
|
|
79
|
+
|
|
80
|
+
Можно использовать несколько конфигураций для различных проектов или регионов одновременно, указав для каждого отдельные `pool_file`, `sent_file` и `lock_file`.
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
config/
|
|
84
|
+
├── moscow.yaml # данные: data/moscow_pool.json + moscow.lock
|
|
85
|
+
└── spb.yaml # данные: data/spb_pool.json + spb.lock
|
|
86
|
+
|
|
87
|
+
bin/inat-channel -c config/moscow.yaml &
|
|
88
|
+
bin/inat-channel -c config/spb.yaml &
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Важно: файлы пула и отправленных должны быть уникальными для каждой конфигурации, чтобы избежать гонок и сбоев.
|
|
92
|
+
|
|
93
|
+
## Структура директорий и данных
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
├── config.yaml # Конфигурация (пример)
|
|
97
|
+
├── data/
|
|
98
|
+
│ ├── pool.json # Кэш новых UUID объектов для публикации
|
|
99
|
+
│ ├── sent.json # UUID уже опубликованных объектов + id сообщений Telegram
|
|
100
|
+
│ └── bot.lock # Лок-файл блокировки работы бота
|
|
101
|
+
├── log/ # Логи запуска бота
|
|
102
|
+
└── bin/inat-channel # Основной исполняемый файл бота
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Защита от параллельных запусков
|
|
106
|
+
|
|
107
|
+
- Lock-файл с TTL 30 минут.
|
|
108
|
+
- Автоудаление устаревших блокировок.
|
|
109
|
+
- Завершение по сигналам INT и TERM (graceful shutdown).
|
|
110
|
+
- Ошибка, если бот уже запущен с тем же конфигом.
|
|
111
|
+
|
|
112
|
+
Пример:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
$ bin/inat-channel -c config.yaml # PID 12345 захватил lock
|
|
116
|
+
$ bin/inat-channel -c config.yaml # Ошибка: процесс с PID 12345 уже запущен
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Пример поста в Telegram
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
🪶 <b>Обыкновенный снегирь</b> <i>(Pyrrhula pyrrhula)</i>
|
|
123
|
+
|
|
124
|
+
📷 #123456 — 👤 <a href="...">Ivan Ivanov</a> @ 📅 2025-11-15
|
|
125
|
+
|
|
126
|
+
🗺️ <a href="...">Moscow Region Project</a>
|
|
127
|
+
|
|
128
|
+
↳ 🗺️ 55.7558°N, 37.6173°E
|
|
129
|
+
|
|
130
|
+
#Animalia • #Aves • #Pyrrhula_pyrrhula
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Опции командной строки
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
bin/inat-channel --help
|
|
137
|
+
|
|
138
|
+
# Доступные параметры:
|
|
139
|
+
# -c, --config FILE Путь к конфигу (по умолчанию inat-channel.yaml)
|
|
140
|
+
# -l, --log-level LEVEL Уровень логирования (debug/info/warn/error)
|
|
141
|
+
# --debug Установить уровень логирования в debug
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Благодарности
|
|
145
|
+
|
|
146
|
+
- [iNaturalist API v2](https://www.inaturalist.org/pages/api+reference)
|
|
147
|
+
- [Faraday HTTP Client](https://github.com/lostisland/faraday)
|
|
148
|
+
|
|
149
|
+
**Лицензия**: [GPLv3](LICENSE)
|
|
150
|
+
|
data/bin/inat-channel
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/ruby
|
|
2
|
+
require_relative '../lib/inat-channel'
|
|
3
|
+
include INatChannel
|
|
4
|
+
|
|
5
|
+
INCh::LOGGER.info "=== iNatChannel Run ==="
|
|
6
|
+
|
|
7
|
+
news = INCh::API::load_news
|
|
8
|
+
INCh::LOGGER.info "Found #{news.size} fresh UUIDs (days_back=#{CONFIG[:days_back]})"
|
|
9
|
+
|
|
10
|
+
uuid = INCh::Data::select_uuid news
|
|
11
|
+
|
|
12
|
+
if uuid
|
|
13
|
+
obs = INCh::API::load_observation(uuid)
|
|
14
|
+
if obs
|
|
15
|
+
msg_id = INCh::Telegram::send_observation(obs)
|
|
16
|
+
INCh::Data::sent[uuid] = { msg_id: msg_id, sent_at: Time.now.to_s }
|
|
17
|
+
INCh::LOGGER.info "Posted #{obs[:id]} (#{INCh::Message::list_photos(obs).size} photos)" # очень плохо, не должно быть тут вызова list_photos
|
|
18
|
+
else
|
|
19
|
+
INCh::LOGGER.warn "Failed to load #{uuid} — returning to pool"
|
|
20
|
+
INCh::Data::pool << uuid
|
|
21
|
+
INCh::Telegram::notify_admin "Failed to load #{uuid} — returning to pool"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
INCh::Data::save
|
|
25
|
+
else
|
|
26
|
+
INCh::Telegram::notify_admin 'No observation to send.'
|
|
27
|
+
INCh::LOGGER.warn 'No observation to send.'
|
|
28
|
+
end
|
|
29
|
+
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
require 'date'
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'faraday'
|
|
4
|
+
require 'faraday/retry'
|
|
5
|
+
|
|
6
|
+
require_relative 'config'
|
|
7
|
+
require_relative 'logger'
|
|
8
|
+
|
|
9
|
+
module INatChannel
|
|
10
|
+
|
|
11
|
+
module API
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
|
|
15
|
+
PER_PAGE = 200
|
|
16
|
+
PAGE_DELAY = 1.0
|
|
17
|
+
API_ENDPOINT = 'https://api.inaturalist.org/v2/observations'
|
|
18
|
+
LIST_FIELDS = 'uuid'
|
|
19
|
+
SINGLE_FIELDS = '(id:!t,uuid:!t,uri:!t,geojson:(all:!t),user:(login:!t,name:!t),taxon:(ancestor_ids:!t,preferred_common_name:!t,name:!t),' +
|
|
20
|
+
'place_ids:!t,place_guess:!t,observed_on_string:!t,description:!t,photos:(url:!t),identifications:(taxon:(ancestors:(name:!t))))'
|
|
21
|
+
|
|
22
|
+
private_constant :PER_PAGE, :PAGE_DELAY, :API_ENDPOINT, :LIST_FIELDS, :SINGLE_FIELDS
|
|
23
|
+
|
|
24
|
+
def load_news
|
|
25
|
+
result = []
|
|
26
|
+
page = 1
|
|
27
|
+
|
|
28
|
+
loop do
|
|
29
|
+
INatChannel::LOGGER.debug "Fetch page #{page} with per_page=#{PER_PAGE}"
|
|
30
|
+
|
|
31
|
+
response = faraday.get API_ENDPOINT do |req|
|
|
32
|
+
req.params['page'] = page
|
|
33
|
+
req.params['per_page'] = PER_PAGE
|
|
34
|
+
req.params['fields'] = LIST_FIELDS
|
|
35
|
+
req.params.merge! INatChannel::CONFIG[:base_query]
|
|
36
|
+
req.params['created_d1'] = (Date.today - INatChannel::CONFIG[:days_back]).to_s
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
unless response.success?
|
|
40
|
+
INatChannel::Telegram::notify_admin "Failed to fetch observations page #{page}: HTTP #{response.status}"
|
|
41
|
+
INatChannel::LOGGER.error "HTTP #{response.status} on page #{page}"
|
|
42
|
+
break
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
data = JSON.parse response.body, symbolize_names: true
|
|
46
|
+
uuids = data[:results].map { |o| o[:uuid] }
|
|
47
|
+
result += uuids
|
|
48
|
+
|
|
49
|
+
total = data[:total_results] || 0
|
|
50
|
+
INatChannel::LOGGER.debug "Page #{page}: fetched #{uuids.size} UUIDs, total expected #{total}"
|
|
51
|
+
|
|
52
|
+
break if uuids.empty? || result.size >= total
|
|
53
|
+
page += 1
|
|
54
|
+
sleep PAGE_DELAY
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
INatChannel::LOGGER.debug "Loaded total #{result.uniq.size} unique UUIDs"
|
|
58
|
+
result.uniq
|
|
59
|
+
rescue => e
|
|
60
|
+
INatChannel::Telegram::notify_admin "Exception while loading news: #{e.message}"
|
|
61
|
+
INatChannel::LOGGER.error e.full_message
|
|
62
|
+
[]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def load_observation uuid
|
|
66
|
+
response = faraday.get API_ENDPOINT do |req|
|
|
67
|
+
req.params['uuid'] = uuid
|
|
68
|
+
req.params['locale'] = INatChannel::CONFIG[:base_query][:locale] if INatChannel::CONFIG[:base_query][:locale]
|
|
69
|
+
req.params['fields'] = SINGLE_FIELDS
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
if response.success?
|
|
73
|
+
data = JSON.parse response.body, symbolize_names: true
|
|
74
|
+
obs = data[:results]&.first
|
|
75
|
+
INatChannel::LOGGER.debug "Loaded observation: #{uuid}"
|
|
76
|
+
obs
|
|
77
|
+
else
|
|
78
|
+
INatChannel::LOGGER.error "Error loading observation #{uuid}: HTTP #{response.status}"
|
|
79
|
+
INatChannel::Telegram::notify_admin "Error loading observation #{uuid}: HTTP #{response.status}"
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
rescue => e
|
|
83
|
+
INatChannel::Telegram::notify_admin "Exception while loading observation #{uuid}: #{e.message}"
|
|
84
|
+
INatChannel::LOGGER.error e.full_message
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def faraday
|
|
91
|
+
@faraday ||= Faraday::new do |f|
|
|
92
|
+
f.request :retry, max: INatChannel::CONFIG[:retries], interval: 2.0, interval_randomness: 0.5,
|
|
93
|
+
exceptions: [ Faraday::TimeoutError, Faraday::ConnectionFailed, Faraday::SSLError, Faraday::ClientError ]
|
|
94
|
+
f.request :url_encoded
|
|
95
|
+
|
|
96
|
+
if INatChannel::LOGGER.level == ::Logger::DEBUG
|
|
97
|
+
f.response :logger, INatChannel::LOGGER, bodies: true, headers: true
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
f.adapter Faraday::default_adapter
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
require 'optparse'
|
|
2
|
+
require 'yaml'
|
|
3
|
+
require 'logger'
|
|
4
|
+
|
|
5
|
+
require_relative 'version'
|
|
6
|
+
|
|
7
|
+
module INatChannel
|
|
8
|
+
|
|
9
|
+
module Config
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
|
|
13
|
+
def config
|
|
14
|
+
@config ||= get_config.freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def get_config
|
|
20
|
+
options = parse_options
|
|
21
|
+
options[:config] ||= './inat-channel.yml'
|
|
22
|
+
cfg = load_config options[:config]
|
|
23
|
+
cfg.merge! options
|
|
24
|
+
cfg[:log_level] ||= :warn
|
|
25
|
+
env = load_env
|
|
26
|
+
cfg.merge! env
|
|
27
|
+
validate_and_fix_config! cfg
|
|
28
|
+
cfg
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def parse_options
|
|
32
|
+
options = {}
|
|
33
|
+
OptionParser.new do |opts|
|
|
34
|
+
opts.banner = 'Usage: inat-channel [options]'
|
|
35
|
+
opts.on '-c', '--config FILE', 'Config file (default: inat-channel.yml)' do |v|
|
|
36
|
+
raise ArgumentError, "Config file not found: #{v}" unless File.exist?(v)
|
|
37
|
+
options[:config] = v
|
|
38
|
+
end
|
|
39
|
+
opts.on '-l', '--log-level LEVEL', [:debug, :info, :warn, :error], 'Log level (default: warn)' do |v|
|
|
40
|
+
options[:log_level] = v
|
|
41
|
+
end
|
|
42
|
+
opts.on '--debug', 'Set log level to debug' do
|
|
43
|
+
options[:log_level] = :debug
|
|
44
|
+
end
|
|
45
|
+
opts.on '--version', 'Show version info and exit' do
|
|
46
|
+
puts INatChannel::VERSION
|
|
47
|
+
exit
|
|
48
|
+
end
|
|
49
|
+
opts.on '-h', '--help', 'Show help and exit' do
|
|
50
|
+
puts opts
|
|
51
|
+
exit
|
|
52
|
+
end
|
|
53
|
+
end.parse!
|
|
54
|
+
options
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def load_config path
|
|
58
|
+
raise "Config file not found: #{path}" unless File.exist?(path)
|
|
59
|
+
YAML.safe_load_file(path, symbolize_names: true)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def load_env
|
|
63
|
+
{
|
|
64
|
+
telegram_bot_token: (ENV['TELEGRAM_BOT_TOKEN'] or raise 'TELEGRAM_BOT_TOKEN required'),
|
|
65
|
+
admin_telegram_id: (ENV['ADMIN_TELEGRAM_ID'] or raise 'ADMIN_TELEGRAM_ID required')
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def validate_and_fix_config! cfg
|
|
70
|
+
raise 'Missing or invalid base_query' unless Hash === cfg[:base_query]
|
|
71
|
+
raise 'Missing or invalid days_back' unless Integer === cfg[:days_back] && cfg[:days_back] > 0
|
|
72
|
+
raise 'Missing chat_id' unless cfg[:chat_id]
|
|
73
|
+
|
|
74
|
+
basename = File.basename cfg[:config], '.*'
|
|
75
|
+
cfg[:pool_file] ||= "./data/#{basename}_pool.json"
|
|
76
|
+
cfg[:sent_file] ||= "./data/#{basename}_sent.json"
|
|
77
|
+
cfg[:lock_file] ||= "./data/#{basename}__bot.lock"
|
|
78
|
+
cfg[:retries] ||= 5
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
CONFIG = Config::config
|
|
86
|
+
|
|
87
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
require 'time'
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'set'
|
|
5
|
+
|
|
6
|
+
require_relative 'config'
|
|
7
|
+
require_relative 'logger'
|
|
8
|
+
require_relative 'lock'
|
|
9
|
+
|
|
10
|
+
module INatChannel
|
|
11
|
+
|
|
12
|
+
module Data
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
|
|
16
|
+
def select_uuid fresh
|
|
17
|
+
INatChannel::LOGGER.info "Received #{fresh.size} uuids"
|
|
18
|
+
|
|
19
|
+
fresh.reject! { |uuid| sent?(uuid) }
|
|
20
|
+
unless fresh.empty?
|
|
21
|
+
result = fresh.sample
|
|
22
|
+
fresh.delete result
|
|
23
|
+
pool.merge fresh
|
|
24
|
+
INatChannel::LOGGER.info "Fresh uuid selected, #{fresh.size} uuids added to pool"
|
|
25
|
+
return result
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
pool.reject! { |uuid| sent?(uuid) }
|
|
29
|
+
unless pool.empty?
|
|
30
|
+
result = pool.to_a.sample
|
|
31
|
+
pool.delete result
|
|
32
|
+
INatChannel::LOGGER.info "Pool uuid selected, #{pool.size} uuids remain in pool"
|
|
33
|
+
return result
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def pool
|
|
40
|
+
@pool ||= load_pool
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def sent
|
|
44
|
+
@sent ||= load_sent
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def save
|
|
48
|
+
save_pool
|
|
49
|
+
save_sent
|
|
50
|
+
INatChannel::LOGGER.info "Saved pool=#{pool.size}, sent=#{sent.size}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def sent? uuid
|
|
56
|
+
sent.has_key? uuid
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def load_pool
|
|
60
|
+
file = INatChannel::CONFIG[:pool_file]
|
|
61
|
+
data = JSON.parse File.read(file), symbolize_names: false
|
|
62
|
+
raise "Invalid format of pool file" unless Array === data
|
|
63
|
+
Set[*data]
|
|
64
|
+
rescue
|
|
65
|
+
Set::new
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def load_sent
|
|
69
|
+
file = INatChannel::CONFIG[:sent_file]
|
|
70
|
+
data = JSON.parse File.read(file), symbolize_names: false
|
|
71
|
+
raise "Invalid format of sent file" unless Hash === data
|
|
72
|
+
data
|
|
73
|
+
rescue
|
|
74
|
+
{}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def save_pool
|
|
78
|
+
pool.reject! { |uuid| sent?(uuid) }
|
|
79
|
+
file = INatChannel::CONFIG[:pool_file]
|
|
80
|
+
FileUtils.mkdir_p File.dirname(file)
|
|
81
|
+
File.write file, JSON.pretty_generate(pool.to_a)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def save_sent
|
|
85
|
+
file = INatChannel::CONFIG[:sent_file]
|
|
86
|
+
FileUtils.mkdir_p File.dirname(file)
|
|
87
|
+
File.write file, JSON.pretty_generate(sent)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
|
|
2
|
+
module INatChannel
|
|
3
|
+
|
|
4
|
+
module Icons
|
|
5
|
+
|
|
6
|
+
TAXA_ICONS = {
|
|
7
|
+
48460 => '🧬',
|
|
8
|
+
47126 => '🌿',
|
|
9
|
+
47170 => '🍄',
|
|
10
|
+
47686 => '🦠',
|
|
11
|
+
151817 => '🦠',
|
|
12
|
+
67333 => '🦠',
|
|
13
|
+
1 => '🐾',
|
|
14
|
+
136329 => '🌲',
|
|
15
|
+
47124 => '🌸',
|
|
16
|
+
47163 => '🍃',
|
|
17
|
+
47178 => '🐟',
|
|
18
|
+
196614 => '🦈',
|
|
19
|
+
47187 => '🦀',
|
|
20
|
+
47158 => '🪲',
|
|
21
|
+
47119 => '🕷️',
|
|
22
|
+
71261 => '🦅',
|
|
23
|
+
18874 => '🦜',
|
|
24
|
+
48222 => '🌊',
|
|
25
|
+
47115 => '🐚',
|
|
26
|
+
3 => '🐦',
|
|
27
|
+
40151 => '🦌',
|
|
28
|
+
26036 => '🐍',
|
|
29
|
+
20978 => '🐸'
|
|
30
|
+
|
|
31
|
+
# TODO: add ALL taxa with iNat icons and some other large group
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
ICONS = {
|
|
35
|
+
:user => '👤',
|
|
36
|
+
:place => '🗺️',
|
|
37
|
+
:calendar => '📅',
|
|
38
|
+
:location => '📍',
|
|
39
|
+
:observation => '📷',
|
|
40
|
+
:description => '📝',
|
|
41
|
+
:default_taxon => '🧬'
|
|
42
|
+
# TODO: add other icons like calendar, place, etc.
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
class << self
|
|
46
|
+
|
|
47
|
+
def taxon_icon taxon
|
|
48
|
+
taxon[:ancestor_ids].reverse_each do |ancestor_id|
|
|
49
|
+
return TAXA_ICONS[ancestor_id] if TAXA_ICONS[ancestor_id]
|
|
50
|
+
end
|
|
51
|
+
return ICONS[:default_taxon]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
require 'time'
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
5
|
+
require_relative 'config'
|
|
6
|
+
require_relative 'logger'
|
|
7
|
+
|
|
8
|
+
module INatChannel
|
|
9
|
+
|
|
10
|
+
module Lock
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
|
|
14
|
+
def acquire!
|
|
15
|
+
file = INatChannel::CONFIG[:lock_file]
|
|
16
|
+
FileUtils.mkdir_p File.dirname(file)
|
|
17
|
+
|
|
18
|
+
if File.exist?(file)
|
|
19
|
+
data = load_data file
|
|
20
|
+
if stale?(data)
|
|
21
|
+
INatChannel::LOGGER.info "Remove stale lock: #{file}"
|
|
22
|
+
File.delete file
|
|
23
|
+
else
|
|
24
|
+
raise "Another instance is already running (PID: #{data[:pid]})"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
data = {
|
|
29
|
+
pid: Process.pid,
|
|
30
|
+
started_at: Time.now.utc.iso8601
|
|
31
|
+
}
|
|
32
|
+
File.write file, JSON.pretty_generate(data)
|
|
33
|
+
INatChannel::LOGGER.info "Lock acquired: #{file}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def release!
|
|
37
|
+
file = INatChannel::CONFIG[:lock_file]
|
|
38
|
+
return nil unless File.exist?(file)
|
|
39
|
+
|
|
40
|
+
File.delete file
|
|
41
|
+
INatChannel::LOGGER.info "Lock release: #{file}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def load_data file
|
|
47
|
+
JSON.parse File.read(file), symbolize_names: true
|
|
48
|
+
rescue
|
|
49
|
+
{}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
LOCK_TTL = 1800 # 30 min
|
|
53
|
+
|
|
54
|
+
def stale? data
|
|
55
|
+
if data[:started_at]
|
|
56
|
+
started_at = Time.parse data[:started_at]
|
|
57
|
+
Time.now - started_at > LOCK_TTL
|
|
58
|
+
else
|
|
59
|
+
true
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
trap 'INT' do
|
|
66
|
+
self.release!
|
|
67
|
+
exit 130
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
trap 'TERM' do
|
|
71
|
+
self.release!
|
|
72
|
+
exit 143
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
at_exit do
|
|
76
|
+
self.release!
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
acquire!
|
|
80
|
+
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
require_relative 'config'
|
|
2
|
+
|
|
3
|
+
module INatChannel
|
|
4
|
+
|
|
5
|
+
module Logger
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
|
|
9
|
+
def logger
|
|
10
|
+
@logger ||= get_logger
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def get_logger
|
|
16
|
+
lgr = ::Logger::new $stderr
|
|
17
|
+
lgr.level = INatChannel::CONFIG[:log_level]
|
|
18
|
+
lgr
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
LOGGER = Logger::logger
|
|
26
|
+
|
|
27
|
+
end
|
|
28
|
+
|