rss.lisp 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. (in-package #:chatikbot)
  2. (defstruct feed id url title next-fetch (period 300))
  3. (defstruct feed-item feed guid link title description published)
  4. (defparameter *rss-min-period* 60 "Min rss refresh period in seconds")
  5. (defparameter *rss-max-period* 1800 "Max rss refresh period in seconds")
  6. (defparameter *rss-change-rate* 0.1 "Refresh period adjustment rate")
  7. (defun find-rss-links (url)
  8. (handler-case
  9. (multiple-value-bind (root uri encoding) (xml-request url)
  10. (declare (ignore encoding))
  11. (loop for link in (get-by-tag root "link")
  12. when (string= "application/rss+xml" (plump:attribute link "type"))
  13. collect (list (plump:attribute link "title")
  14. (puri:render-uri
  15. (puri:merge-uris
  16. (puri:uri (plump:attribute link "href"))
  17. uri) nil))))
  18. (error (e) (log:error e))))
  19. (defun build-feed (url)
  20. (let ((root (xml-request url)))
  21. (alexandria:when-let (rss (car (get-by-tag root "rss")))
  22. (make-feed :url url :title (child-text rss "title")))))
  23. (defun adjust-period (period new-items)
  24. "Adjust the period of feed based on whenever there were new items. With clamping"
  25. (let ((diff (round (* period *rss-change-rate*))))
  26. (min *rss-max-period*
  27. (max *rss-min-period*
  28. (- period (* diff (if (zerop new-items) -1 new-items)))))))
  29. (defun need-fetch-p (feed)
  30. (or (null (feed-next-fetch feed))
  31. (local-time:timestamp> (local-time:now) (feed-next-fetch feed))))
  32. (defun refresh-feed (feed &optional (skip-p #'not))
  33. (let* ((items
  34. (loop for item in (ignore-errors (fetch-feed-items feed))
  35. unless (funcall skip-p item)
  36. collect item))
  37. (new-period (adjust-period (feed-period feed) (length items))))
  38. (setf (feed-period feed) new-period
  39. (feed-next-fetch feed) (local-time:timestamp+ (local-time:now) new-period :sec))
  40. items))
  41. (defun trim-nil (text)
  42. (when text
  43. (let ((text (string-trim " " text)))
  44. (unless (zerop (length text))
  45. text))))
  46. (defun text-with-cdata (node)
  47. "Compiles all text nodes within the nesting-node into one string."
  48. (with-output-to-string (stream)
  49. (labels ((r (node)
  50. (loop for child across (plump:children node)
  51. do (typecase child
  52. (plump:text-node (write-string (plump:text child) stream))
  53. (plump:cdata (write-string (plump:text child) stream))
  54. (plump:nesting-node (r child))))))
  55. (r node))))
  56. (defun child-text (node tag)
  57. (alexandria:when-let (child (car (get-by-tag node tag)))
  58. (trim-nil (text-with-cdata child))))
  59. (defun clean-text (text)
  60. (when text (trim-nil (plump:text (plump:parse text)))))
  61. (defun %send-feeds (chat-id feeds)
  62. (bot-send-message chat-id
  63. (if (null feeds)
  64. "Пока ничего не постим"
  65. (format nil "Постим~%~{~A) ~A: ~A~^~%~}"
  66. (loop for feed in feeds
  67. for index from 1
  68. append (list index (feed-title feed) (feed-url feed)))))
  69. :disable-web-preview 1))
  70. (defun %fetch-new-items (feed)
  71. (loop for item in (refresh-feed feed #'db/rss-item-exists)
  72. do (db/rss-add-item item)
  73. collect item))
  74. (defun %get-feed (url)
  75. (when url
  76. (or (db/rss-get-feed-by-url url)
  77. (alexandria:when-let (feed (build-feed url))
  78. (log:info "Added feed" feed)
  79. (db/rss-add-feed feed)
  80. (%fetch-new-items feed)
  81. feed))))
  82. (defun fetch-feed-items (feed)
  83. (let ((plump:*tag-dispatchers* plump:*xml-tags*))
  84. (loop for item in (get-by-tag (xml-request (feed-url feed)) "item")
  85. collect (make-feed-item :feed feed
  86. :guid (or (child-text item "guid") (child-text item "link"))
  87. :link (child-text item "link")
  88. :title (clean-text (child-text item "title"))
  89. :description (clean-text (child-text item "description"))
  90. :published
  91. (ignore-errors
  92. (alexandria:when-let
  93. (pub (or (child-text item "pubDate")
  94. (child-text item "published")))
  95. (local-time:universal-to-timestamp
  96. (date-time-parser:parse-date-time pub))))))))
  97. (defun format-feed-item (item)
  98. (format nil "[~A](~A)~@[ @ ~A~]~%~A~%"
  99. (feed-title (feed-item-feed item))
  100. (feed-item-link item)
  101. (alexandria:when-let (ts (feed-item-published item))
  102. (local-time:format-timestring
  103. nil ts
  104. :format '((:year 2) "-" (:month 2) "-" (:day 2)
  105. " " (:hour 2) ":" (:min 2))))
  106. (feed-item-title item)))
  107. (defun feed-next-fetch-unix (feed)
  108. (alexandria:when-let (ts (feed-next-fetch feed))
  109. (local-time:timestamp-to-unix ts)))
  110. (defun feed-item-published-unix (item)
  111. (alexandria:when-let (ts (feed-item-published item))
  112. (local-time:timestamp-to-unix ts)))
  113. ;; Databaase
  114. (def-db-init (db)
  115. (%db-execute db "create table if not exists rss_feeds (id INTEGER PRIMARY KEY, url, title, next_fetch, period)")
  116. (%db-execute db "create unique index if not exists rss_feeds_url_idx on rss_feeds (url)")
  117. (%db-execute db "create table if not exists rss_items (id INTEGER PRIMARY KEY, feed_id REFERENCES rss_feeds, guid, link, title, published)")
  118. (%db-execute db "create index if not exists rss_items_idx on rss_items (feed_id, guid)")
  119. (%db-execute db "create table if not exists rss_chat_feeds (chat_id, feed_id REFERENCES rss_feeds)")
  120. (%db-execute db "create index if not exists rss_chat_feeds_chat_idx on rss_chat_feeds (chat_id)")
  121. (%db-execute db "create index if not exists rss_chat_feeds_feed_idx on rss_chat_feeds (feed_id)"))
  122. (defun %db/make-feed (row)
  123. (when row
  124. (make-feed :id (nth 0 row)
  125. :url (nth 1 row)
  126. :title (nth 2 row)
  127. :next-fetch (when (nth 3 row) (local-time:unix-to-timestamp (nth 3 row)))
  128. :period (nth 4 row))))
  129. (defun db/rss-get-feed-by-url (url)
  130. (%db/make-feed (car (db-select "select id, url, title, next_fetch, period from rss_feeds where url = ?" url))))
  131. (defun db/rss-add-feed (feed)
  132. (with-slots (url title period) feed
  133. (let ((next-fetch (feed-next-fetch-unix feed)))
  134. (setf (feed-id feed) (db-execute "insert into rss_feeds (url, title, next_fetch, period) values (?, ?, ?, ?)"
  135. url title next-fetch period))
  136. feed)))
  137. (defun db/rss-update-feed (feed)
  138. (with-slots (id title period) feed
  139. (let ((next-fetch (feed-next-fetch-unix feed)))
  140. (db-execute "update rss_feeds set title = ?, next_fetch = ?, period = ? where id = ?"
  141. title next-fetch period id))))
  142. (defun db/rss-get-active-feeds ()
  143. (mapcar #'%db/make-feed
  144. (db-select "select id, url, title, next_fetch, period from rss_feeds where exists (select 1 from rss_chat_feeds where feed_id=id)")))
  145. (defun db/rss-get-chat-feeds (chat-id)
  146. (mapcar #'%db/make-feed
  147. (db-select "select id, url, title, next_fetch, period from rss_feeds where id in (select feed_id from rss_chat_feeds where chat_id = ?)" chat-id)))
  148. (defun db/rss-get-feed-chats (feed)
  149. (with-slots (id) feed
  150. (flatten (db-select "select chat_id from rss_chat_feeds where feed_id = ?" id))))
  151. (defun db/rss-set-chat-feeds (chat-id feeds)
  152. (db-transaction (db)
  153. (%db-execute db "delete from rss_chat_feeds where chat_id = ?" chat-id)
  154. (dolist (feed feeds)
  155. (with-slots (id) feed
  156. (%db-execute db "insert into rss_chat_feeds (chat_id, feed_id) values (?, ?)" chat-id id)))))
  157. (defun db/rss-item-exists (item)
  158. (let ((feed-id (feed-id (feed-item-feed item)))
  159. (guid (feed-item-guid item)))
  160. (db-single "select id from rss_items where feed_id = ? and guid = ? limit 1"
  161. feed-id guid)))
  162. (defun db/rss-add-item (item)
  163. (let ((feed-id (feed-id (feed-item-feed item)))
  164. (published (feed-item-published-unix item)))
  165. (with-slots (guid link title) item
  166. (db-execute "insert into rss_items (feed_id, guid, link, title, published) values (?, ?, ?, ?, ?)"
  167. feed-id guid link title published))))
  168. (defun %db/make-feed-item (feed row)
  169. (when row
  170. (make-feed-item :feed feed
  171. :guid (nth 0 row)
  172. :link (nth 1 row)
  173. :title (nth 2 row)
  174. :published (when (nth 3 row) (local-time:unix-to-timestamp (nth 3 row))))))
  175. (defun db/rss-last-feed-items (feed &optional (limit 10))
  176. (with-slots (id) feed
  177. (mapcar #'(lambda (row) (%db/make-feed-item feed row))
  178. (db-select "select guid, link, title, published from rss_items where feed_id = ? order by published desc, id desc limit ?" id limit))))
  179. ;; Cron
  180. (defcron process-feeds ()
  181. (dolist (feed (remove-if-not #'need-fetch-p (db/rss-get-active-feeds)))
  182. (log:info "Fetching new items" (feed-url feed))
  183. (dolist (item (%fetch-new-items feed))
  184. (dolist (chat-id (db/rss-get-feed-chats feed))
  185. (telegram-send-message chat-id
  186. (format-feed-item item)
  187. :parse-mode "Markdown"
  188. :disable-web-preview 1)))
  189. (db/rss-update-feed feed))) ;; Update next fetch and period
  190. ;; Hooks
  191. (def-message-cmd-handler handler-cmd-feeds (:feeds)
  192. (bot-send-message
  193. chat-id
  194. (if (null args)
  195. "URL давай"
  196. (format nil "~:[Не нашел RSS там~;~:*~{~{~A - ~A~}~^~%~}~]"
  197. (find-rss-links (car args))))
  198. :disable-web-preview 1))
  199. (def-message-cmd-handler handler-cmd-rss (:rss)
  200. (let ((feeds (db/rss-get-chat-feeds chat-id)))
  201. (if (null args)
  202. (%send-feeds chat-id feeds)
  203. (progn
  204. (dolist (url args)
  205. (handler-case
  206. (let ((idx (parse-integer url)))
  207. (when (<= idx (length feeds))
  208. (setf feeds (remove (nth (1- idx) feeds) feeds))))
  209. (parse-error ()
  210. (alexandria:when-let (feed (%get-feed
  211. (or (cadar (find-rss-links url))
  212. url)))
  213. (let ((existing (find (feed-url feed) feeds :key #'feed-url :test #'equal)))
  214. (if existing
  215. (setf feeds (remove existing feeds))
  216. (push feed feeds)))))
  217. (error (e) (log:error "~A" e))))
  218. (db/rss-set-chat-feeds chat-id feeds)
  219. (%send-feeds chat-id (db/rss-get-chat-feeds chat-id))))))
  220. (def-message-cmd-handler handler-cmd-last-rss (:lastrss)
  221. (let ((feeds (db/rss-get-chat-feeds chat-id)))
  222. (if (null args)
  223. (%send-feeds chat-id feeds)
  224. (let* ((idx (1- (parse-integer (car args))))
  225. (limit (min 20 (if (> (length args) 1) (parse-integer (second args)) 5)))
  226. (items (db/rss-last-feed-items (nth idx feeds) limit)))
  227. (telegram-send-message chat-id
  228. (format nil "~{~A~^~%~%~}"
  229. (mapcar #'format-feed-item items))
  230. :parse-mode "Markdown"
  231. :disable-web-preview 1)))))