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.
data/README.md ADDED
@@ -0,0 +1,150 @@
1
+ # iNat Telegram Poster
2
+
3
+ [![GitHub License](https://img.shields.io/github/license/inat-get/inat-channel)](LICENSE)
4
+ [![Gem Version](https://badge.fury.io/rb/inat-channel.svg?icon=si%3Arubygems&d=3)](https://badge.fury.io/rb/inat-channel)
5
+ [![Ruby](https://github.com/inat-get/inat-channel/actions/workflows/ruby.yml/badge.svg)](https://github.com/inat-get/inat-channel/actions/workflows/ruby.yml)
6
+ ![Coverage](coverage-badge.svg)
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
+