feed2gram 1.0.0 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 31ad42be4bf5ec4ba881a78695bc7b9cc0619bb3b399559d668361b3be9872b0
4
- data.tar.gz: 38617996403f39ca1eeee6285cb31896be7c4dbbb05033d98713c2ac6a1d245e
3
+ metadata.gz: 186b11421ce39cc03f041f010e695fbd295edb0631235d80545e42928fe6edca
4
+ data.tar.gz: 688601d45092f6f1c57c9871af2b337c7df93f441ccefc6b787e01169a8bd236
5
5
  SHA512:
6
- metadata.gz: e28c8903d7933cd8d55454ccf59df9a50a58a60dbaf2794463dbd1ff623af34d4c6077d9b4b202009e2a85b5a031a967e0e71d1ec8f90f58a46bc38222845ff1
7
- data.tar.gz: 3a3d448efa515863ad1f37f4be8e75801e9a71769ed00b376a5d5f3723884d94ffa25a965c14b662a3f92e73c94b1c4b670973ae08164ab51605a1bb3d3afa9c
6
+ metadata.gz: 4682d5d13f8443ebb0cdc853394fb9e8a0c8677a0e4850daaa0df8feb54d9fcc73cc64eb5c41368dd14c958e8a5be25fe50e2c8a006602d66751161c411c9e13
7
+ data.tar.gz: 7ca4d2096fa0b0b60df85c0f902f312ab27083a2ec9b7b0590951315370b3dd74fee02c92781ebdc7347945a5dc7142ac9f1204cbfe468125bd523c4b5509964
data/CHANGELOG.md CHANGED
@@ -1,4 +1,15 @@
1
- ## [Unreleased]
1
+ ## [1.1.0]
2
+
3
+ * Add support for videos and stories, including:
4
+ * single-video posts (which post as reels), by setting `data-media-type=video`
5
+ attribute on a feed entry's `<figure>`'s only `<img>` child
6
+ * single-image and single-video stories, by setting `data-post-type=stories`
7
+ attribute on a feed entry's `<figure>` element
8
+ * carousels that contain videos and photos by setting `data-media-type=video`
9
+ attribute on each `<img>` tag that contains a video
10
+ * Print much more granular feedback when publishing and in verbose mode
11
+ * When all posts are filtered out from the cache, say so (when verbose) and
12
+ don't update the cache file needlessly
2
13
 
3
14
  ## [1.0.0]
4
15
 
data/README.md CHANGED
@@ -102,13 +102,18 @@ feed2gram uses the first `<figure>` element to generate each Instagram post. Tha
102
102
 
103
103
  Some things to keep in mind:
104
104
 
105
+ * A `<figure>` may specify a `data-post-type` with a value of `reels`, `stories`, or `post` (if unspecified, the type defaults to `post`)
106
+ * If `data-post-type` is set to `stories` or `reels`, exactly one image or video must be included. If `post`, then multiple (up to ten) images and videos can be included and will publish as a carousel post
107
+ * Posting stories (i.e. `<figure data-post-type="stories">`) requires a _business_ account, not a creator one (in which case a, "the user is not an Instagram Business," error will be returned)
105
108
  * If one `<img>` tag is present, a single photo post will be created. If there are more, a [carousel post](https://developers.facebook.com/docs/instagram-api/guides/content-publishing/#carousel-posts) will be created
106
109
  * Because Facebook's servers actually _download your image_ as opposed to receiving them as uploads via the API, every `<img>` tag's `src` attribute must be set to a publicly-reachable, fully-qualified URL
107
- * Images can't be more than 8MB, or else posting will fail
108
- * Images must be standard-issue JPEGs, or else posting will fail
110
+ * To post videos, stories, or reels, set the `data-media-type` attribute on the `<img>` tag to `video` or `image` (a media type of `image` will be assumed by default if left unspecified). Note that while `image` and `video` media may be interspersed throughout a carousel
109
111
  * For carousel posts, the aspect ratio of the first image determines the aspect ratio of the rest, so be mindful of how you order the images based on how you want them to appear in the app
110
112
  * Only one caption will be published, regardless of whether it's a single photo post or a carousel
111
113
  * The caption limit is 2200 characters, so feed2gram will truncate it if necessary
114
+ * The API is pretty strict about media file formats, too, so you may wish to preprocess images and videos to avoid errors in processing:
115
+ * Images can't be more than 8MB and must be standard-issue JPEGs
116
+ * Videos are even stricter (best to just [read the docs](https://developers.facebook.com/docs/instagram-api/reference/ig-user/media#creating), including this bit on [reels](https://developers.facebook.com/docs/video-api/guides/reels-publishing)). Videos that appear in carousels seem to have additional no-longer-documented restrictions (in my testing, 9:16 videos routinely failed but 16:9, 1:1, 4:3, and 3:4 succeeded)
112
117
 
113
118
  Here's an example `<entry>` from my blog feed:
114
119
 
@@ -207,7 +212,7 @@ Look at your cache file (by default, `feed2gram.cache.yml`) and you should see
207
212
  all the Atom feed entry URLs that succeeded, failed, or were (by the `--populate-cache` option) skipped. If you don't see the error in the log, try
208
213
  removing the relevant URL from the cache and running `feed2gram` again.
209
214
 
210
- ### What are the valid aspect ratios?
215
+ ### What are the valid aspect ratios for images?
211
216
 
212
217
  If you're seeing an embedded API error like this one:
213
218
 
@@ -216,6 +221,6 @@ The submitted image with aspect ratio ('719/194',) cannot be published. Please s
216
221
  ```
217
222
 
218
223
  It means your photo is too avant garde for a mainstream normie platform like
219
- Instagram. Make sure all images' aspect ratiosa re between 4:5 and 1.91:1 or
224
+ Instagram. Make sure all images' aspect ratios are between 4:5 and 1.91:1 or
220
225
  else the post will fail.
221
226
 
@@ -2,20 +2,56 @@ require "nokogiri"
2
2
  require "open-uri"
3
3
 
4
4
  module Feed2Gram
5
- Post = Struct.new(:url, :images, :caption, keyword_init: true)
5
+ Media = Struct.new(:media_type, :url, keyword_init: true) do
6
+ def video?
7
+ media_type == "VIDEO"
8
+ end
9
+ end
10
+ Post = Struct.new(:media_type, :url, :medias, :caption, keyword_init: true)
6
11
 
7
12
  class ParsesEntries
8
13
  def parse(feed_url)
9
14
  feed = Nokogiri::XML(URI.parse(feed_url).open)
10
15
  feed.xpath("//*:entry").map { |entry|
11
16
  html = Nokogiri::HTML(entry.xpath("*:content[1]").text)
17
+ medias = html.xpath("//figure[1]/img").map { |img|
18
+ Media.new(
19
+ media_type: (img["data-media-type"] || "image").upcase,
20
+ url: img["src"]
21
+ )
22
+ }
12
23
 
13
24
  Post.new(
25
+ media_type: determine_post_media_type(html, medias),
14
26
  url: entry.xpath("*:id[1]").text,
15
- images: html.xpath("//figure[1]/img").map { |img| img["src"] },
27
+ medias: medias,
16
28
  caption: html.xpath("//figure[1]/figcaption").text.strip
17
29
  )
18
- }.reject { |post| post.images.empty? }
30
+ }.select { |post|
31
+ if post.medias.empty?
32
+ warn "Skipping post with no <img> tag: #{post.url}"
33
+ elsif ["STORIES", "REELS"].include?(post.media_type) && post.medias.size > 1
34
+ warn "Skipping #{post.media_type.downcase} with more than one <img> tag (only one allowed): #{post.url}"
35
+ else
36
+ true
37
+ end
38
+ }
39
+ end
40
+
41
+ private
42
+
43
+ def determine_post_media_type(html, medias)
44
+ post_type = html.at("//figure[1]")["data-post-type"]&.upcase
45
+ if ["STORIES", "REELS"].include?(post_type)
46
+ post_type
47
+ elsif medias.size > 1
48
+ "CAROUSEL"
49
+ elsif medias.first.media_type == "VIDEO"
50
+ # The VIDEO value for media_type is deprecated outside carousel items. Use the REELS media type to publish a video to your Instagram feed. Please visit https://developers.facebook.com/docs/instagram-api/reference/ig-user/media#creating to publish a video.
51
+ "REELS"
52
+ else
53
+ "IMAGE"
54
+ end
19
55
  end
20
56
  end
21
57
  end
@@ -9,12 +9,12 @@ module Feed2Gram
9
9
  # reverse to post oldest first (most Atom feeds are reverse-chronological)
10
10
  posts.reverse.take(post_limit).map { |post|
11
11
  begin
12
- if post.images.size == 1
13
- puts "Publishing single image post for: #{post.url}" if options.verbose
14
- publish_single_image(post, config)
12
+ if post.medias.size == 1
13
+ puts "Publishing #{post.media_type.downcase} for: #{post.url}" if options.verbose
14
+ publish_single_media(post, config, options)
15
15
  else
16
- puts "Publishing carousel post for: #{post.url}" if options.verbose
17
- publish_carousel(post, config)
16
+ puts "Publishing carousel for: #{post.url}" if options.verbose
17
+ publish_carousel(post, config, options)
18
18
  end
19
19
  rescue => e
20
20
  warn "Failed to post #{post.url}: #{e.message}"
@@ -25,39 +25,85 @@ module Feed2Gram
25
25
 
26
26
  private
27
27
 
28
- def publish_single_image(post, config)
29
- container_response = Http.post("/#{config.instagram_id}/media", {
30
- image_url: post.images.first,
31
- caption: post.caption,
32
- access_token: config.access_token
33
- })
28
+ def publish_single_media(post, config, options)
29
+ media = post.medias.first
30
+
31
+ puts "Creating media resource for URL - #{media.url}" if options.verbose
32
+ container_id = Http.post("/#{config.instagram_id}/media", {
33
+ :media_type => post.media_type,
34
+ :caption => post.caption,
35
+ :access_token => config.access_token,
36
+ media.video? ? :video_url : :image_url => media.url
37
+ }.compact)[:id]
38
+
39
+ if media.video?
40
+ wait_for_media_to_upload!(media.url, container_id, config, options)
41
+ end
42
+
43
+ puts "Publishing media for URL - #{media.url}" if options.verbose
34
44
  Http.post("/#{config.instagram_id}/media_publish", {
35
- creation_id: container_response[:id],
45
+ creation_id: container_id,
36
46
  access_token: config.access_token
37
47
  })
38
48
  Result.new(post: post, status: :posted)
39
49
  end
40
50
 
41
- def publish_carousel(post, config)
42
- image_containers = post.images.take(10).map { |image|
51
+ def publish_carousel(post, config, options)
52
+ media_containers = post.medias.take(10).map { |media|
53
+ puts "Creating media resource for URL - #{media.url}" if options.verbose
43
54
  res = Http.post("/#{config.instagram_id}/media", {
44
- is_carousel_item: true,
45
- image_url: image,
46
- access_token: config.access_token
47
- })
55
+ :media_type => media.media_type,
56
+ :is_carousel_item => true,
57
+ :access_token => config.access_token,
58
+ media.video? ? :video_url : :image_url => media.url
59
+ }.compact)
48
60
  res[:id]
49
61
  }
50
- carousel_container = Http.post("/#{config.instagram_id}/media", {
62
+ post.medias.select(&:video?).zip(media_containers).each { |media, container_id|
63
+ wait_for_media_to_upload!(media.url, container_id, config, options)
64
+ }
65
+
66
+ puts "Creating carousel media resource for post - #{post.url}" if options.verbose
67
+ carousel_id = Http.post("/#{config.instagram_id}/media", {
51
68
  caption: post.caption,
52
- media_type: "CAROUSEL",
53
- children: image_containers.join(","),
69
+ media_type: post.media_type,
70
+ children: media_containers.join(","),
54
71
  access_token: config.access_token
55
- })
72
+ })[:id]
73
+ wait_for_media_to_upload!(post.url, carousel_id, config, options)
74
+
75
+ puts "Publishing carousel media for post - #{post.url}" if options.verbose
56
76
  Http.post("/#{config.instagram_id}/media_publish", {
57
- creation_id: carousel_container[:id],
77
+ creation_id: carousel_id,
58
78
  access_token: config.access_token
59
79
  })
60
80
  Result.new(post: post, status: :posted)
61
81
  end
82
+
83
+ # Good ol' loop-and-sleep. Haven't loop do'd in a while
84
+ def wait_for_media_to_upload!(url, container_id, config, options)
85
+ wait_attempts = 0
86
+ loop do
87
+ if wait_attempts > 90
88
+ warn "Giving up waiting for media to upload after waiting 120 seconds: #{url}"
89
+ break
90
+ end
91
+
92
+ res = Http.get("/#{container_id}", {
93
+ fields: "status_code",
94
+ access_token: config.access_token
95
+ })
96
+ puts "Upload status #{res[:status_code]} after #{wait_attempts + 1} check for #{url}" if options.verbose
97
+ if res[:status_code] == "FINISHED"
98
+ break
99
+ elsif res[:status_code] == "IN_PROGRESS"
100
+ wait_attempts += 1
101
+ sleep 1
102
+ else
103
+ warn "Unexpected status code (#{res[:status_code]}) uploading: #{url}"
104
+ break
105
+ end
106
+ end
107
+ end
62
108
  end
63
109
  end
@@ -1,3 +1,3 @@
1
1
  module Feed2Gram
2
- VERSION = "1.0.0"
2
+ VERSION = "1.1.0"
3
3
  end
data/lib/feed2gram.rb CHANGED
@@ -26,12 +26,16 @@ module Feed2Gram
26
26
  entries = ParsesEntries.new.parse(config.feed_url)
27
27
  puts "Found #{entries.size} entries in feed" if options.verbose
28
28
  posts = FiltersPosts.new.filter(entries, cache)
29
- results = if options.populate_cache
30
- puts "Populating cache, marking #{posts.size} posts as skipped" if options.verbose
31
- posts.map { |post| Result.new(post: post, status: :skipped) }
29
+ if posts.empty?
30
+ puts "No new posts to publish after filtering already-processed posts in #{options.cache_path}" if options.verbose
32
31
  else
33
- PublishesPosts.new.publish(posts, config, options)
32
+ results = if options.populate_cache
33
+ puts "Populating cache, marking #{posts.size} posts as skipped" if options.verbose
34
+ posts.map { |post| Result.new(post: post, status: :skipped) }
35
+ else
36
+ PublishesPosts.new.publish(posts, config, options)
37
+ end
38
+ UpdatesCache.new.update!(cache, results, options)
34
39
  end
35
- UpdatesCache.new.update!(cache, results, options)
36
40
  end
37
41
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: feed2gram
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Searls
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-11-08 00:00:00.000000000 Z
11
+ date: 2023-11-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: nokogiri
@@ -73,7 +73,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
73
73
  - !ruby/object:Gem::Version
74
74
  version: '0'
75
75
  requirements: []
76
- rubygems_version: 3.4.17
76
+ rubygems_version: 3.4.21
77
77
  signing_key:
78
78
  specification_version: 4
79
79
  summary: Reads an Atom feed and posts its entries to Instagram