diff --git a/lib.js b/lib.js index f43ae0a..7a213ab 100644 --- a/lib.js +++ b/lib.js @@ -121,7 +121,7 @@ export const retryDelayedFetch = async (url, options, courtesyWait, retryAttempt // | `-. `-. // ' `-' `-' -export async function fetchChannel(source) { +export const fetchChannel = async (source) => { let { hostname } = source let error let response @@ -206,9 +206,11 @@ export const createPosts = async (channel, source, fromDate, reducerCallback) => } export const createPost = (item, source) => { - let description = new JSDOM(item.querySelector('description').textContent).window.document + let description = item.querySelector('description') + description = description === null ? '' : new JSDOM(description.textContent).window.document + let date = readPubDate(item.querySelector('pubDate')) - let link = item.querySelector('link').textContent + let link = item.querySelector('link')?.textContent let guid = item.querySelector('guid')?.textContent let title = item.querySelector('title')?.textContent diff --git a/platforms/bluesky.js b/platforms/bluesky.js new file mode 100644 index 0000000..4296f1b --- /dev/null +++ b/platforms/bluesky.js @@ -0,0 +1,225 @@ +import Path from "path" +import { createSource, createSourceOptions, downloadImages, fetchChannel, getPostIdFromPathname, isUnset } from "../lib.js" +import { channel } from "diagnostics_channel" + +class BlueskyError extends Error {} + +let bluesky = {} + +bluesky.createEndpoint = (domain, method) => { + let endpoint = new URL('https://' + domain) + endpoint.pathname = Path.join('xrpc', method) + + return endpoint +} + +bluesky.createHeaders = (client) => { + return { + // 'Accept': 'application/json', + 'Authorization': 'Bearer ' + client.accessJwt + } +} + +bluesky.handleRequest = response => { + return response + .then(response => { + response = response.json() + + if(response.error) { + throw new BlueskyError(`${response.error}: ${response.message}`) + } + + return response + }) + .catch(err => { + throw new BlueskyError(err) + }) +} + +bluesky.login = async (handle, password, domain = 'bsky.social') => { + let body = { identifier: handle, password } + let endpoint = bluesky.createEndpoint(domain, 'com.atproto.server.createSession') + + let { accessJwt, refreshJwt } = await bluesky.handleRequest( + fetch(endpoint.href, { + method: 'POST', + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(body) + }) + ) + + return { + domain, + accessJwt, + refreshJwt + } +} + +const getPostUriChunks = channel => { + let guids = channel.querySelectorAll('guid') + let storedUris = new Set() + let chunks = [] + let uris = [] + let index = 0 + + for(let guid of guids) { + let uri = guid?.textContent + + if(!uri || storedUris.has(uri)) + continue + + if(index !== 0 && index % 25 === 0) { + chunks.push(uris) + uris = [] + } + + uris.push(uri) + storedUris.add(uri) + index++ + } + + chunks.push(uris) + return chunks +} + +bluesky.fetchAnnotations = async (client, channel) => { + let chunks = getPostUriChunks(channel) + let annotations = new Map() + + for(let uris of chunks) { + let endpoint = bluesky.createEndpoint(client.domain, 'app.bsky.feed.getPosts') + for(let uri of uris) + endpoint.searchParams.append('uris', uri) + + let { posts } = await bluesky.handleRequest( + fetch(endpoint.href, { + headers: bluesky.createHeaders(client) + }) + ) + + for(let post of posts) { + annotations.set(post.uri, post) + } + } + + return annotations +} + +export default bluesky + + + +bluesky.createSource = (handle, options = {}, client, postReducerCallback, cache) => { + let startOfServerHostname = handle.indexOf('.') + let user = handle.slice(0, startOfServerHostname) + let serverHostname = handle.slice(startOfServerHostname + 1) + + let source = { + type: 'bluesky', + description: `Aggregate feed for @${user} on bluesky`, + hostname: 'bsky.app', + pathname: 'profile/' + handle + "/rss", + name: `${serverHostname}-${user}`, + displayName: handle, + user, + handle, + postsToPopulate: new Map(), + postsChecked: new Set(), + ...createSourceOptions(options) + } + + return createSource( + source, + isUnset(client) ? fetchChannel : bluesky.fetchAnnotatedChannelFor(client), + postReducerCallback, + cache + ) +} + +bluesky.fetchAnnotatedChannelFor = (client) => async (source) => { + let channel = await fetchChannel(source) + source.postAnnotations = await bluesky.fetchAnnotations(client, channel) + + return channel +} + +bluesky.isRepost = (post) => { + let annotation = post.source.postAnnotations.get(post.guid) + let beenChecked = post.source.postsChecked.has(post.guid) + + post.source.postsChecked.add(post.guid) + return annotation ? + beenChecked && annotation.author.handle === post.source.handle : + beenChecked +} + +bluesky.pullImages = async (post, view, discardPostIfNoImages) => { + let annotation = post.source.postAnnotations.get(post.guid) + let noImages = isUnset(annotation.embed) + || annotation.embed.type === 'app.bsky.embed.images#view' + + if(isUnset(annotation) || noImages) { + return discardPostIfNoImages ? null : post + } + + post.images = await downloadImages( + annotation.embed.images.map(image => image.fullsize), + post.source, + getPostIdFromPathname(post), + view + ) + return post +} + +// export const bluesky = { +// login(identifier, password) { + +// }, + +// createSource(usertag, options, postReducerCallback) { +// let startOfServerHostname = usertag.indexOf('.') +// let user = usertag.slice(0, startOfServerHostname) +// let serverHostname = usertag.slice(startOfServerHostname + 1) + +// let source = { +// type: 'bluesky', +// description: `Aggregate feed for @${user} on bluesky`, +// hostname: 'bsky.app', +// pathname: 'profile/' + usertag + "/rss", +// name: `${serverHostname}-${user}`, +// displayName: usertag, +// user, +// postsToPopulate: new Map(), +// ...createSourceOptions(options) +// } + +// return createSource(source, fetchRss, postReducerCallback) +// }, + +// isRepost(post) { +// // Bluesky's rss does not provide retweets/retoots +// return false +// }, + +// async pullImages(post, view, discardPostIfNoImages) { +// let getPostsEndpoint = new URL('https://bsky.social/xrpc/app.bsky.feed.getPosts') + +// getPostsEndpoint.searchParams.set('uris', [ post.guid ]) +// console.log(getPostsEndpoint) + +// // fetch(getPostsEndpoint) + +// // post.source.postsToPopulate.set(post.guid, { +// // discardPostIfNoImages, +// // view, +// // post +// // }) +// }, + +// populatePosts(source) { + +// console.log(getPostsEndpoint) +// } +// }