Compare commits
No commits in common. '60485ab44068143dcc3e772b7f8ed50e2ece2d15' and '1dcc9005131835c016e84414fa637dde37118e61' have entirely different histories.
60485ab440
...
1dcc900513
4 changed files with 272 additions and 387 deletions
@ -1,103 +1 @@ |
|||||||
# RSSCollector |
# RSSCollector |
||||||
|
|
||||||
## О проекте |
|
||||||
Данный проект предназначен для сбора информации из RSS о публикациях на сайтах. Найденные новые публикации пересылаются в канал в Telegram. |
|
||||||
|
|
||||||
## Какие технологии используются |
|
||||||
|
|
||||||
Для сборки проекта необходимы: |
|
||||||
|
|
||||||
1. Компилятор [GO](https://go.dev/dl/) |
|
||||||
2. Дополнительный модуль обработки параметров командной строки https://github.com/jessevdk/go-flags |
|
||||||
3. Дополнительный модуль для работы с Telegram Bot API https://github.com/go-telegram-bot-api/telegram-bot-api |
|
||||||
4. Встраиваемая [БД BoltDB](https://github.com/boltdb/bolt) |
|
||||||
|
|
||||||
## Конфигурация |
|
||||||
|
|
||||||
По-умолчанию конфигурационный файл ищется в том же каталоге, где запускается исполняемый файл, с именем `config.yml`. |
|
||||||
|
|
||||||
Формат данного файла - YAML |
|
||||||
|
|
||||||
Выглядит конфиг так: |
|
||||||
|
|
||||||
```yaml |
|
||||||
dbpath: "/var/cache/rsscollector/rss.db" |
|
||||||
telegram: |
|
||||||
senddebug: true |
|
||||||
chatid: <CHATID> |
|
||||||
token: "<TOKEN>" |
|
||||||
rsslist: |
|
||||||
- name: "<RSS1>" |
|
||||||
url: "https://<URL1>" |
|
||||||
- name: "<RSS2>" |
|
||||||
url: "https://<URL2>" |
|
||||||
|
|
||||||
``` |
|
||||||
|
|
||||||
где |
|
||||||
|
|
||||||
- `dbpath` - путь до файла БД |
|
||||||
- `telegram` - блок описания парабетров для telegram-бота |
|
||||||
- `senddebug` - выводить ли debug-информацию при отправке в телеграм |
|
||||||
- \<CHATID\> - идентификатор канала, в который будет присылаться информация. Получить можно например из робота [Json Dump Bot](https://t.me/JsonDumpBot), переслав ему любое сообщение из требующегося канала и взяв идентификатор (вместе с -) из параметра `forward_from_chat` |
|
||||||
- \<TOKEN\> - токен бота, от имени которого будут присылаться сообщения и которого необходимо добавить в канал администратором (достаточно оставить ему права отсылать сообщения в канал). Бота заводим, как обычно, через [BotFather](https://t.me/BotFather) |
|
||||||
- `rsslist` - блок описания лент, с которых будет собираться информация |
|
||||||
- \<RSS1\>, \<RSS2\> - наименования лент. Используются как идентификаторы в БД и в логах для вывода информации, откуда инфа, а также при отправке в канал в заголовке |
|
||||||
- \<URL1\>, \<URL2\> - веб-адреса RSS-лент |
|
||||||
|
|
||||||
## Сборка |
|
||||||
|
|
||||||
`go build -o rsscollector` в каталоге проекта. Перед этим имеет смысл получить все зависимости, перечисленные в `go.mod` |
|
||||||
|
|
||||||
## Запуск |
|
||||||
|
|
||||||
При запуске проверяется один параметр командной строки: |
|
||||||
|
|
||||||
`-c` или `--configpath` - в нем необходимо указать, где находится конфиг. Как было уже сказано выше - это необязательно и конфиг можно разместить прямо в каталоге запуска. |
|
||||||
|
|
||||||
RSS - ленты парсятся по-очереди, новые сообщения отсылаются в канал и записываются в БД, чтобы при следующем запуске не отсылались. Соответственно, при первом запуске надо быть готовым, что в канал прилетит очень много сообщений. |
|
||||||
|
|
||||||
Я использую для запуска данного бинарника systemd и его возможности для запуска сервисов по расписанию. |
|
||||||
|
|
||||||
Для этого в /etc/systemd/system созданы два файла: |
|
||||||
|
|
||||||
- rsscollector.service |
|
||||||
- rsscollector.timer |
|
||||||
|
|
||||||
rsscollector.service: |
|
||||||
|
|
||||||
```ini |
|
||||||
[Unit] |
|
||||||
Description=Collect RSS from sites and send to telegram |
|
||||||
Wants=rsscollector.timer |
|
||||||
|
|
||||||
[Service] |
|
||||||
Type=oneshot |
|
||||||
ExecStart=/usr/local/bin/rsscollector -c /etc/rsscollector/config.yml |
|
||||||
User=root |
|
||||||
Group=root |
|
||||||
|
|
||||||
[Install] |
|
||||||
WantedBy=multi-user.target |
|
||||||
``` |
|
||||||
|
|
||||||
rsscollector.timer |
|
||||||
|
|
||||||
```ini |
|
||||||
[Unit] |
|
||||||
Description=Collect RSS from sites and send to telegram |
|
||||||
Requires=rsscollector.service |
|
||||||
|
|
||||||
[Timer] |
|
||||||
Unit=rsscollector.service |
|
||||||
OnCalendar=*:0 |
|
||||||
|
|
||||||
[Install] |
|
||||||
WantedBy=timers.target |
|
||||||
|
|
||||||
``` |
|
||||||
|
|
||||||
После создания файлов необходимо включить выполнение `rsscollector.timer` командой: |
|
||||||
`systemctl enable rsscollector.timer` |
|
||||||
|
|
||||||
В начале каждого часа будет вызываться на выполнение `rsscollector.service` |
|
||||||
@ -1,139 +0,0 @@ |
|||||||
package main |
|
||||||
|
|
||||||
import ( |
|
||||||
"crypto/tls" |
|
||||||
"encoding/xml" |
|
||||||
"html/template" |
|
||||||
"io" |
|
||||||
"log" |
|
||||||
"net/http" |
|
||||||
"sort" |
|
||||||
"time" |
|
||||||
|
|
||||||
"github.com/boltdb/bolt" |
|
||||||
) |
|
||||||
|
|
||||||
type Rss2 struct { |
|
||||||
XMLName xml.Name `xml:"rss"` |
|
||||||
Version string `xml:"version,attr"` |
|
||||||
// Required
|
|
||||||
Title string `xml:"channel>title"` |
|
||||||
Link string `xml:"channel>link"` |
|
||||||
Description string `xml:"channel>description"` |
|
||||||
// Optional
|
|
||||||
PubDate string `xml:"channel>pubDate"` |
|
||||||
ItemList []Item `xml:"channel>item"` |
|
||||||
} |
|
||||||
|
|
||||||
type Item struct { |
|
||||||
// Required
|
|
||||||
Title string `xml:"title"` |
|
||||||
Link string `xml:"link"` |
|
||||||
Description template.HTML `xml:"description"` |
|
||||||
// Optional
|
|
||||||
Content template.HTML `xml:"encoded"` |
|
||||||
PubDate string `xml:"pubDate"` |
|
||||||
Comments string `xml:"comments"` |
|
||||||
} |
|
||||||
|
|
||||||
type SendItems struct { |
|
||||||
ItemList []Item |
|
||||||
} |
|
||||||
|
|
||||||
type ByPubDate []Item |
|
||||||
|
|
||||||
func (a ByPubDate) Len() int { return len(a) } |
|
||||||
func (a ByPubDate) Less(i, j int) bool { |
|
||||||
timeone, _ := time.Parse("Mon, 02 Jan 2006 15:04:05 -0700", a[i].PubDate) |
|
||||||
timetwo, _ := time.Parse("Mon, 02 Jan 2006 15:04:05 -0700", a[j].PubDate) |
|
||||||
return timeone.Unix() > timetwo.Unix() |
|
||||||
} |
|
||||||
func (a ByPubDate) Swap(i, j int) { a[i], a[j] = a[j], a[i] } |
|
||||||
|
|
||||||
func GetRSS(name string, url string) (*Rss2, error) { |
|
||||||
rss := &Rss2{} |
|
||||||
|
|
||||||
var netClient = &http.Client{} |
|
||||||
|
|
||||||
customTransport := &(*http.DefaultTransport.(*http.Transport)) // make shallow copy
|
|
||||||
timeout := time.Duration(240 * time.Second) |
|
||||||
customTransport = &http.Transport{ |
|
||||||
IdleConnTimeout: timeout, |
|
||||||
ResponseHeaderTimeout: timeout, |
|
||||||
DisableKeepAlives: false, |
|
||||||
DisableCompression: false, |
|
||||||
ForceAttemptHTTP2: true, |
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, |
|
||||||
TLSHandshakeTimeout: timeout, |
|
||||||
MaxIdleConns: 20, |
|
||||||
MaxIdleConnsPerHost: 100, |
|
||||||
MaxConnsPerHost: 100, |
|
||||||
} |
|
||||||
netClient = &http.Client{Transport: customTransport, Timeout: timeout} |
|
||||||
request, err := http.NewRequest("GET", url, nil) |
|
||||||
if err != nil { |
|
||||||
return nil, err |
|
||||||
} |
|
||||||
request.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:97.0) Gecko/20100101 Firefox/97.0") |
|
||||||
|
|
||||||
resp, err := netClient.Do(request) |
|
||||||
if err != nil { |
|
||||||
return nil, err |
|
||||||
} |
|
||||||
|
|
||||||
defer resp.Body.Close() |
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body) |
|
||||||
if err != nil { |
|
||||||
return nil, err |
|
||||||
} |
|
||||||
|
|
||||||
// Start RSS decoding from file
|
|
||||||
if err := xml.Unmarshal(body, rss); err != nil { |
|
||||||
return nil, err |
|
||||||
} |
|
||||||
|
|
||||||
return rss, nil |
|
||||||
} |
|
||||||
|
|
||||||
func ProcessRss(rss Rss2, dbpath string, rssname string) (*SendItems, error) { |
|
||||||
var si SendItems |
|
||||||
db, err := bolt.Open(dbpath, 0600, nil) |
|
||||||
if err != nil { |
|
||||||
log.Fatal(err) |
|
||||||
} |
|
||||||
defer db.Close() |
|
||||||
db.Update(func(tx *bolt.Tx) error { |
|
||||||
_, err := tx.CreateBucketIfNotExists([]byte(rssname)) |
|
||||||
if err != nil { |
|
||||||
return err |
|
||||||
} |
|
||||||
return nil |
|
||||||
}) |
|
||||||
|
|
||||||
for _, v := range rss.ItemList { |
|
||||||
db.View(func(tx *bolt.Tx) error { |
|
||||||
// Assume bucket exists and has keys
|
|
||||||
b := tx.Bucket([]byte(rssname)) |
|
||||||
c := b.Cursor() |
|
||||||
flag := false |
|
||||||
for key, _ := c.First(); key != nil; key, _ = c.Next() { |
|
||||||
if v.Link == string(key) { |
|
||||||
flag = true |
|
||||||
break |
|
||||||
} |
|
||||||
} |
|
||||||
if !flag { |
|
||||||
si.ItemList = append(si.ItemList, v) |
|
||||||
} |
|
||||||
return nil |
|
||||||
}) |
|
||||||
} |
|
||||||
|
|
||||||
sort.Sort(ByPubDate(si.ItemList)) |
|
||||||
if len(si.ItemList) > 0 { |
|
||||||
return &si, nil |
|
||||||
} else { |
|
||||||
return nil, nil |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,145 +0,0 @@ |
|||||||
package main |
|
||||||
|
|
||||||
import ( |
|
||||||
"encoding/json" |
|
||||||
"fmt" |
|
||||||
"log" |
|
||||||
"reflect" |
|
||||||
"strings" |
|
||||||
"time" |
|
||||||
|
|
||||||
"github.com/boltdb/bolt" |
|
||||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" |
|
||||||
"golang.org/x/net/html" |
|
||||||
) |
|
||||||
|
|
||||||
func in_array(val interface{}, array interface{}) (exists bool) { |
|
||||||
exists = false |
|
||||||
|
|
||||||
switch reflect.TypeOf(array).Kind() { |
|
||||||
case reflect.Slice: |
|
||||||
s := reflect.ValueOf(array) |
|
||||||
|
|
||||||
for i := 0; i < s.Len(); i++ { |
|
||||||
if reflect.DeepEqual(val, s.Index(i).Interface()) == true { |
|
||||||
exists = true |
|
||||||
return |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
func NormalizeHTMLforTelegram(s string) (out string) { |
|
||||||
|
|
||||||
tags := []string{"br", "img", "b", "strong", "i", "em", "code", "s", "strike", "del", "u", "pre"} |
|
||||||
|
|
||||||
domDocTest := html.NewTokenizer(strings.NewReader(s)) |
|
||||||
previousStartTokenTest := domDocTest.Token() |
|
||||||
flagendtag := false |
|
||||||
for { |
|
||||||
tt := domDocTest.Next() |
|
||||||
if len(out) > 2500 { |
|
||||||
if e := in_array(previousStartTokenTest.Data, tags) && previousStartTokenTest.Data != "img" && previousStartTokenTest.Data != "br" && !flagendtag; e { |
|
||||||
out += fmt.Sprintf("</%s> ...", previousStartTokenTest.Data) |
|
||||||
} else { |
|
||||||
out += " ..." |
|
||||||
} |
|
||||||
return |
|
||||||
} |
|
||||||
switch { |
|
||||||
case tt == html.ErrorToken: |
|
||||||
return |
|
||||||
case tt == html.StartTagToken: |
|
||||||
previousStartTokenTest = domDocTest.Token() |
|
||||||
if e := in_array(previousStartTokenTest.Data, tags); e { |
|
||||||
switch { |
|
||||||
case previousStartTokenTest.Data == "br": |
|
||||||
out += "\n" |
|
||||||
case previousStartTokenTest.Data == "img" && previousStartTokenTest.Attr[0].Key == "src": |
|
||||||
out += fmt.Sprintf("%s ", previousStartTokenTest.Attr[0].Val) |
|
||||||
// case previousStartTokenTest.Data == "a" && previousStartTokenTest.Attr[0].Key == "href":
|
|
||||||
// out += fmt.Sprintf(" %s ", previousStartTokenTest.Attr[0].Val)
|
|
||||||
default: |
|
||||||
out += fmt.Sprintf(" <%s>", previousStartTokenTest.Data) |
|
||||||
} |
|
||||||
flagendtag = false |
|
||||||
} |
|
||||||
case tt == html.EndTagToken: |
|
||||||
t := domDocTest.Token() |
|
||||||
if e := in_array(t.Data, tags); e { |
|
||||||
// switch {
|
|
||||||
// case t.Data == "a":
|
|
||||||
// out += " "
|
|
||||||
// default:
|
|
||||||
out += fmt.Sprintf("</%s> ", t.Data) |
|
||||||
// }
|
|
||||||
flagendtag = true |
|
||||||
} |
|
||||||
case tt == html.SelfClosingTagToken: |
|
||||||
t := domDocTest.Token() |
|
||||||
if e := in_array(t.Data, tags); e { |
|
||||||
if t.Data == "br" { |
|
||||||
out += "\n" |
|
||||||
} |
|
||||||
} |
|
||||||
case tt == html.TextToken: |
|
||||||
if previousStartTokenTest.Data == "script" { |
|
||||||
continue |
|
||||||
} |
|
||||||
TxtContent := strings.TrimSpace(html.UnescapeString(string(domDocTest.Text()))) |
|
||||||
if len(TxtContent) > 0 { |
|
||||||
out += TxtContent |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func SendAndWriteToDB(send SendItems, dbpath string, rssname string, token string, chatid int64, debug bool) error { |
|
||||||
|
|
||||||
db, err := bolt.Open(dbpath, 0600, nil) |
|
||||||
if err != nil { |
|
||||||
log.Fatal(err) |
|
||||||
} |
|
||||||
defer db.Close() |
|
||||||
|
|
||||||
for i := len(send.ItemList) - 1; i >= 0; i-- { |
|
||||||
v := send.ItemList[i] |
|
||||||
log.Printf("Send to telegram post: %s", v.Title) |
|
||||||
bot, err := tgbotapi.NewBotAPI(token) |
|
||||||
if err != nil { |
|
||||||
log.Panic(err) |
|
||||||
} |
|
||||||
bot.Debug = debug |
|
||||||
|
|
||||||
s := "<i>" + rssname + "</i>\n\n" + "<b>" + string(v.Title) + "</b>\n\n" + NormalizeHTMLforTelegram(html.UnescapeString(string(v.Description))) + |
|
||||||
"\n\n" + v.Link |
|
||||||
msg := tgbotapi.NewMessage(chatid, s) |
|
||||||
msg.ParseMode = "Html" |
|
||||||
_, err = bot.Send(msg) |
|
||||||
if err != nil { |
|
||||||
log.Panic(err) |
|
||||||
|
|
||||||
} |
|
||||||
duration := time.Duration(10) * time.Second |
|
||||||
time.Sleep(duration) |
|
||||||
|
|
||||||
db.Update(func(tx *bolt.Tx) error { |
|
||||||
b, err := tx.CreateBucketIfNotExists([]byte(rssname)) |
|
||||||
if err != nil { |
|
||||||
return err |
|
||||||
} |
|
||||||
encoded, err := json.Marshal(v) |
|
||||||
if err != nil { |
|
||||||
return err |
|
||||||
} |
|
||||||
err = b.Put([]byte(v.Link), encoded) |
|
||||||
if err != nil { |
|
||||||
return err |
|
||||||
} |
|
||||||
return nil |
|
||||||
}) |
|
||||||
} |
|
||||||
return nil |
|
||||||
} |
|
||||||
Loading…
Reference in new issue