{"id":119,"date":"2025-02-19T11:03:23","date_gmt":"2025-02-19T16:03:23","guid":{"rendered":"http:\/\/www.dev-notes.com\/blog\/?p=119"},"modified":"2025-02-19T15:13:03","modified_gmt":"2025-02-19T20:13:03","slug":"using_php_to_read_from_rss_and_post_to_bluesky","status":"publish","type":"post","link":"https:\/\/www.dev-notes.com\/blog\/2025\/02\/19\/using_php_to_read_from_rss_and_post_to_bluesky\/","title":{"rendered":"Using PHP to read from RSS and post to Bluesky"},"content":{"rendered":"<p>I am currently using a version of the following PHP code to automate the posting of content from <a href=\"https:\/\/ww2db.com\" target=\"_ww2db\">World War II Database<\/a> to the Bluesky social network.  First, please note that you will need the following <a href=\"https:\/\/bsky.app\/\" target=\"_bsky\">Bluesky<\/a> information.<\/p>\n<ul>\n<li>Bluesky Username &#8211; Likely the email address you use to log in to Bluesky<\/li>\n<li>Password<\/li>\n<li>Repository Name &#8211; Likely your Bluesky name followed by &#8220;bsky.social&#8221;; for example, &#8220;ww2db.bsky.social&#8221;<\/li>\n<\/ul>\n<p>There are two more things left to configure.<\/li>\n<ul>\n<li>URL to the RSS Feed<\/li>\n<li>File Path\/Name for a Log File &#8211; This is used to avoid posting duplicate content on Bluesky<\/li>\n<\/ul>\n<p>Below is the code.  Note that in Step 3 there is a hard-coded value of &#8220;100&#8221; to note that the log file (to keep track of which RSS content has already been posted) will keep track of the most recent 100 records; in Step 4 there is some code to handle hashtags and URLs that may be present in the RSS content; Step 5 contains the code to obtain an access token which will be used for the actual posting; finally, Step 6 posts in the format of title-space-link, which you may wish to adjust based on your RSS feed structure.<\/p>\n<pre class=\"code\">\r\n$username = 'username_here';\r\n$password = 'password_here';\r\n$repoName = 'repo_name_here';\r\n$rssUrl = 'rss_url_here';\r\n$postedIdsFile = 'rss_to_bksy_posted_ids.txt';\r\n\r\n\/\/ Change the following to \"N\" to output some debugging information\r\n$silentMode = \"Y\";\r\n\r\n\/\/ Step 1: Establish a function to get Bluesky access token\r\nfunction getAccessToken($username, $password) {\r\n    $url = 'https:\/\/bsky.social\/xrpc\/com.atproto.server.createSession';\r\n    $data = [\r\n        'identifier' => $username,\r\n        'password' => $password\r\n    ];\r\n\r\n    $options = [\r\n        CURLOPT_URL => $url,\r\n        CURLOPT_RETURNTRANSFER => true,\r\n        CURLOPT_POST => true,\r\n        CURLOPT_POSTFIELDS => json_encode($data),\r\n        CURLOPT_HTTPHEADER => [\r\n            \"Content-Type: application\/json\"\r\n        ]\r\n    ];\r\n\r\n    $ch = curl_init();\r\n    curl_setopt_array($ch, $options);\r\n    $response = curl_exec($ch);\r\n    curl_close($ch);\r\n\r\n    $response_data = json_decode($response, true);\r\n    return $response_data['accessJwt'];\r\n}\r\n\r\n\/\/ Step 2: Establish a function to fetch content from a RSS feed\r\nfunction fetchRss($rssUrl) {\r\n    $rss = simplexml_load_file($rssUrl);\r\n    return $rss;\r\n}\r\n\r\n\/\/ Step 3: Establish a function to check if post ID exists in the log file, and to update the log file\r\nfunction checkAndUpdatePostIds($postId, $file) {\r\n    $postedIds = file_exists($file) ? file($file, FILE_IGNORE_NEW_LINES) : [];\r\n\r\n    if (in_array($postId, $postedIds)) {\r\n        return false;\r\n    }\r\n\r\n    array_push($postedIds, $postId);\r\n    if (count($postedIds) > 100) {\r\n        array_shift($postedIds);\r\n    }\r\n    file_put_contents($file, implode(\"\\n\", $postedIds) . \"\\n\");\r\n    return true;\r\n}\r\n\r\n\/\/ Step 4: Establish a function to handle Bluesky facets for hashtags and URLs\r\nfunction extractFacets($content) {\r\n    preg_match_all('\/#(\\w+)\/', $content, $hashtags);\r\n    preg_match_all('\/https?:\\\/\\\/[^\\s]+\/', $content, $urls);\r\n\r\n    $facets = [];\r\n\r\n    foreach ($hashtags[0] as $hashtag) {\r\n        $startPos = strpos($content, $hashtag);\r\n        $facets[] = [\r\n            'index' => [\r\n                'byteStart' => $startPos,\r\n                'byteEnd' => $startPos + strlen($hashtag)\r\n            ],\r\n            'features' => [\r\n                [\r\n                    '$type' => 'app.bsky.richtext.facet#tag',\r\n                    'tag' => $hashtag\r\n                ]\r\n            ]\r\n        ];\r\n    }\r\n\r\n    foreach ($urls[0] as $url) {\r\n        $startPos = strpos($content, $url);\r\n        $facets[] = [\r\n            'index' => [\r\n                'byteStart' => $startPos,\r\n                'byteEnd' => $startPos + strlen($url)\r\n            ],\r\n            'features' => [\r\n                [\r\n                    '$type' => 'app.bsky.richtext.facet#link',\r\n                    'uri' => $url\r\n                ]\r\n            ]\r\n        ];\r\n    }\r\n\r\n    return $facets;\r\n}\r\n\r\n\/\/ Step 5: Establish a function to post to Bluesky\r\nfunction postToBluesky($accessJwt, $repoName, $content) {\r\n    $facets = extractFacets($content);\r\n\r\n    $url = 'https:\/\/bsky.social\/xrpc\/com.atproto.repo.createRecord';\r\n    $data = [\r\n        'repo' => $repoName,\r\n        'collection' => 'app.bsky.feed.post',\r\n        'record' => [\r\n            'text' => $content,\r\n            'createdAt' => date('c'),\r\n            'facets' => $facets\r\n        ]\r\n    ];\r\n\r\n    $options = [\r\n        CURLOPT_URL => $url,\r\n        CURLOPT_RETURNTRANSFER => true,\r\n        CURLOPT_POST => true,\r\n        CURLOPT_POSTFIELDS => json_encode($data),\r\n        CURLOPT_HTTPHEADER => [\r\n            \"Content-Type: application\/json\",\r\n            \"Authorization: Bearer $accessJwt\"\r\n        ]\r\n    ];\r\n\r\n    $ch = curl_init();\r\n    curl_setopt_array($ch, $options);\r\n    $response = curl_exec($ch);\r\n    curl_close($ch);\r\n\r\n    return $response;\r\n}\r\n\r\n\/\/ Step 6: Put everything together\r\n\r\n$accessJwt = getAccessToken($username, $password);\r\n$rss = fetchRss($rssUrl);\r\n\r\nforeach ($rss->channel->item as $item) {\r\n    $postId = (string) $item->guid;\r\n    $content = (string) $item->title . \" \" . (string) $item->link;\r\n\r\n    if (checkAndUpdatePostIds($postId, $postedIdsFile)) {\r\n        if ($silentMode == \"N\") {\r\n\t\t\techo \"Posting: $content\\n\";\r\n\t\t}\r\n        $response = postToBluesky($accessJwt, $repoName, $content);\r\n\t\tif ($silentMode == \"N\") {\r\n\t\t\techo $response . \"\\n\";\r\n\t\t}\r\n    }\r\n\telse {\r\n\t\tif ($silentMode == \"N\") {\r\n\t\t\techo \"Duplicate post detected, skipping: $content\\n\";\r\n\t\t}\r\n    }\r\n}\r\n<\/pre>\n<p>As far as usage goes, you can refactor the various pieces to fit into your existing PHP-based management tool.  As a shortcut, you can also take the above code as-is and run it via cron or other similar job schedulers.<\/p>\n<p>My implementation of this code posts contents to the WW2DB Bluesky page at the URL <a href=\"https:\/\/bsky.app\/profile\/ww2db.bsky.social\" target=\"_bsky\">https:\/\/bsky.app\/profile\/ww2db.bsky.social<\/a>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>This PHP code reads from a RSS feed and posts new items to Bluesky.<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[4,61],"tags":[],"class_list":["post-119","post","type-post","status-publish","format-standard","hentry","category-php","category-social-media"],"_links":{"self":[{"href":"https:\/\/www.dev-notes.com\/blog\/wp-json\/wp\/v2\/posts\/119","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.dev-notes.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.dev-notes.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.dev-notes.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.dev-notes.com\/blog\/wp-json\/wp\/v2\/comments?post=119"}],"version-history":[{"count":5,"href":"https:\/\/www.dev-notes.com\/blog\/wp-json\/wp\/v2\/posts\/119\/revisions"}],"predecessor-version":[{"id":421,"href":"https:\/\/www.dev-notes.com\/blog\/wp-json\/wp\/v2\/posts\/119\/revisions\/421"}],"wp:attachment":[{"href":"https:\/\/www.dev-notes.com\/blog\/wp-json\/wp\/v2\/media?parent=119"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.dev-notes.com\/blog\/wp-json\/wp\/v2\/categories?post=119"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.dev-notes.com\/blog\/wp-json\/wp\/v2\/tags?post=119"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}