@@ -1,14 +1,20 @@
|
||||
const RSS = require("rss")
|
||||
const constants = require("../constants")
|
||||
const config = require("../../../config")
|
||||
const TimelineImage = require("./TimelineImage")
|
||||
const TimelineEntry = require("./TimelineEntry")
|
||||
const InstaCache = require("../cache")
|
||||
const collectors = require("../collectors")
|
||||
require("../testimports")(constants, collectors, TimelineImage, InstaCache)
|
||||
require("../testimports")(constants, collectors, TimelineEntry, InstaCache)
|
||||
|
||||
/** @param {any[]} edges */
|
||||
function transformEdges(edges) {
|
||||
return edges.map(e => collectors.createShortcodeFromData(e.node, false))
|
||||
return edges.map(e => {
|
||||
/** @type {import("../types").TimelineEntryAll} */
|
||||
const data = e.node
|
||||
const entry = collectors.getOrCreateShortcode(data.shortcode)
|
||||
entry.apply(data)
|
||||
return entry
|
||||
})
|
||||
}
|
||||
|
||||
class Timeline {
|
||||
@@ -17,7 +23,7 @@ class Timeline {
|
||||
*/
|
||||
constructor(user) {
|
||||
this.user = user
|
||||
/** @type {import("./TimelineImage")[][]} */
|
||||
/** @type {import("./TimelineEntry")[][]} */
|
||||
this.pages = []
|
||||
this.addPage(this.user.data.edge_owner_to_timeline_media)
|
||||
this.page_info = this.user.data.edge_owner_to_timeline_media.page_info
|
||||
|
||||
33
src/lib/structures/TimelineBaseMethods.js
Normal file
33
src/lib/structures/TimelineBaseMethods.js
Normal file
@@ -0,0 +1,33 @@
|
||||
const constants = require("../constants")
|
||||
const {proxyImage, proxyExtendedOwner} = require("../utils/proxyurl")
|
||||
|
||||
class TimelineBaseMethods {
|
||||
constructor() {
|
||||
/** @type {import("../types").GraphChildAll & {owner?: any}} */
|
||||
this.data
|
||||
}
|
||||
|
||||
getType() {
|
||||
if (this.data.__typename === "GraphImage") {
|
||||
if (this.data.owner) return constants.symbols.TYPE_IMAGE
|
||||
else return constants.symbols.TYPE_GALLERY_IMAGE
|
||||
} else if (this.data.__typename === "GraphVideo") {
|
||||
if (this.data.owner) return constants.symbols.TYPE_VIDEO
|
||||
else return constants.symbols.TYPE_GALLERY_VIDEO
|
||||
} else if (this.data.__typename === "GraphSidecar") {
|
||||
return constants.symbols.TYPE_GALLERY
|
||||
} else {
|
||||
throw new Error("Unknown shortcode __typename: "+this.data.__typename)
|
||||
}
|
||||
}
|
||||
|
||||
getDisplayUrlP() {
|
||||
return proxyImage(this.data.display_url)
|
||||
}
|
||||
|
||||
getAlt() {
|
||||
return this.data.accessibility_caption || "No image description available."
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TimelineBaseMethods
|
||||
@@ -1,41 +1,16 @@
|
||||
const config = require("../../../config")
|
||||
const {proxyImage} = require("../utils/proxyurl")
|
||||
const collectors = require("../collectors")
|
||||
const TimelineBaseMethods = require("./TimelineBaseMethods")
|
||||
require("../testimports")(collectors)
|
||||
|
||||
class TimelineChild {
|
||||
class TimelineChild extends TimelineBaseMethods {
|
||||
/**
|
||||
* @param {import("../types").GraphChild} data
|
||||
* @param {import("../types").GraphChildAll} data
|
||||
*/
|
||||
constructor(data) {
|
||||
super()
|
||||
this.data = data
|
||||
this.proxyDisplayURL = proxyImage(this.data.display_url)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} size
|
||||
*/
|
||||
getSuggestedResource(size) {
|
||||
let found = null
|
||||
for (const tr of this.data.display_resources) {
|
||||
found = tr
|
||||
if (tr.config_width >= size) break
|
||||
}
|
||||
found = proxyImage(found, size)
|
||||
return found
|
||||
}
|
||||
|
||||
getSrcset() {
|
||||
return this.data.display_resources.map(tr => {
|
||||
const p = new URLSearchParams()
|
||||
p.set("width", String(tr.config_width))
|
||||
p.set("url", tr.src)
|
||||
return `/imageproxy?${p.toString()} ${tr.config_width}w`
|
||||
}).join(", ")
|
||||
}
|
||||
|
||||
getAlt() {
|
||||
return this.data.accessibility_caption || "No image description available."
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
220
src/lib/structures/TimelineEntry.js
Normal file
220
src/lib/structures/TimelineEntry.js
Normal file
@@ -0,0 +1,220 @@
|
||||
const config = require("../../../config")
|
||||
const constants = require("../constants")
|
||||
const {proxyImage, proxyExtendedOwner} = require("../utils/proxyurl")
|
||||
const {compile} = require("pug")
|
||||
const collectors = require("../collectors")
|
||||
const TimelineBaseMethods = require("./TimelineBaseMethods")
|
||||
const TimelineChild = require("./TimelineChild")
|
||||
require("../testimports")(collectors, TimelineChild, TimelineBaseMethods)
|
||||
|
||||
const rssDescriptionTemplate = compile(`
|
||||
p(style='white-space: pre-line')= caption
|
||||
img(alt=alt src=src)
|
||||
`)
|
||||
|
||||
class TimelineEntry extends TimelineBaseMethods {
|
||||
constructor() {
|
||||
super()
|
||||
/** @type {import("../types").TimelineEntryAll} some properties may not be available yet! */
|
||||
// @ts-ignore
|
||||
this.data = {}
|
||||
setImmediate(() => { // next event loop
|
||||
if (!this.data.__typename) throw new Error("TimelineEntry data was not initalised in same event loop (missing __typename)")
|
||||
})
|
||||
/** @type {string} Not available until fetchExtendedOwnerP is called */
|
||||
this.ownerPfpCacheP = null
|
||||
/** @type {import("./TimelineChild")[]} Not available until fetchChildren is called */
|
||||
this.children = null
|
||||
}
|
||||
|
||||
async update() {
|
||||
const data = await collectors.fetchShortcodeData(this.data.shortcode)
|
||||
this.applyN3(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* General apply function that detects the data format
|
||||
*/
|
||||
apply(data) {
|
||||
if (!data.display_resources) {
|
||||
this.applyN1(data)
|
||||
} else if (data.thumbnail_resources) {
|
||||
this.applyN2(data)
|
||||
} else {
|
||||
this.applyN3(data)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("../types").TimelineEntryN1} data
|
||||
*/
|
||||
applyN1(data) {
|
||||
Object.assign(this.data, data)
|
||||
this.fixData()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("../types").TimelineEntryN2} data
|
||||
*/
|
||||
applyN2(data) {
|
||||
Object.assign(this.data, data)
|
||||
this.fixData()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("../types").TimelineEntryN3} data
|
||||
*/
|
||||
applyN3(data) {
|
||||
Object.assign(this.data, data)
|
||||
this.fixData()
|
||||
}
|
||||
|
||||
/**
|
||||
* This should keep the same state when applied multiple times to the same data.
|
||||
* All mutations should act exactly once and have no effect on already mutated data.
|
||||
*/
|
||||
fixData() {
|
||||
}
|
||||
|
||||
getCaption() {
|
||||
const edge = this.data.edge_media_to_caption.edges[0]
|
||||
if (!edge) return null // no caption
|
||||
else return edge.node.text.replace(/\u2063/g, "") // I don't know why U+2063 INVISIBLE SEPARATOR is in here, but it is, and it causes rendering issues with certain fonts, so let's just remove it.
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to get the first meaningful line or sentence from the caption.
|
||||
*/
|
||||
getCaptionIntroduction() {
|
||||
const caption = this.getCaption()
|
||||
if (!caption) return null
|
||||
else return caption.split("\n")[0].split(". ")[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Alt text is not available for N2, the caption or a placeholder string will be returned instead.
|
||||
* @override
|
||||
*/
|
||||
getAlt() {
|
||||
return this.data.accessibility_caption || this.getCaption() || "No image description available."
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {import("../types").BasicOwner}
|
||||
*/
|
||||
getBasicOwner() {
|
||||
return this.data.owner
|
||||
}
|
||||
|
||||
/**
|
||||
* Not available on N3!
|
||||
* Returns proxied URLs (P)
|
||||
*/
|
||||
getThumbnailSrcsetP() {
|
||||
if (this.data.thumbnail_resources) {
|
||||
return this.data.thumbnail_resources.map(tr => {
|
||||
return `${proxyImage(tr.src, tr.config_width)} ${tr.config_width}w`
|
||||
}).join(", ")
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Not available on N3!
|
||||
* Returns proxied URLs (P)
|
||||
* @param {number} size
|
||||
* @return {import("../types").DisplayResource}
|
||||
*/
|
||||
getSuggestedThumbnailP(size) {
|
||||
if (this.data.thumbnail_resources) {
|
||||
let found = null // start with nothing
|
||||
for (const tr of this.data.thumbnail_resources) { // and keep looping up the sizes (sizes come sorted)
|
||||
found = tr
|
||||
if (tr.config_width >= size) break // don't proceed once we find one large enough
|
||||
}
|
||||
return {
|
||||
config_width: found.config_width,
|
||||
config_height: found.config_height,
|
||||
src: proxyImage(found.src, found.config_width) // force resize to config rather than requested
|
||||
}
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
getThumbnailSizes() {
|
||||
return `(max-width: 820px) 120px, 260px` // from css :(
|
||||
}
|
||||
|
||||
async fetchChildren() {
|
||||
// Cached children?
|
||||
if (this.children) return this.children
|
||||
// Not a gallery? Convert self to a child and return.
|
||||
if (this.getType() !== constants.symbols.TYPE_GALLERY) {
|
||||
return this.children = [new TimelineChild(this.data)]
|
||||
}
|
||||
// Fetch children if needed
|
||||
if (!this.data.edge_sidecar_to_children) {
|
||||
await this.update()
|
||||
}
|
||||
// Create children
|
||||
return this.children = this.data.edge_sidecar_to_children.edges.map(e => new TimelineChild(e.node))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a proxied profile pic URL (P)
|
||||
* @returns {Promise<import("../types").ExtendedOwner>}
|
||||
*/
|
||||
async fetchExtendedOwnerP() {
|
||||
// Do we just already have the extended owner?
|
||||
if (this.data.owner.full_name) { // this property is on extended owner and not basic owner
|
||||
const clone = proxyExtendedOwner(this.data.owner)
|
||||
this.ownerPfpCacheP = clone.profile_pic_url
|
||||
return clone
|
||||
}
|
||||
// The owner may be in the user cache, so copy from that.
|
||||
// This could be implemented better.
|
||||
else if (collectors.requestCache.hasWithoutClean("user/"+this.data.owner.username)) {
|
||||
/** @type {import("./User")} */
|
||||
const user = collectors.requestCache.getWithoutClean("user/"+this.data.owner.username)
|
||||
this.data.owner = {
|
||||
id: user.data.id,
|
||||
username: user.data.username,
|
||||
is_verified: user.data.is_verified,
|
||||
full_name: user.data.full_name,
|
||||
profile_pic_url: user.data.profile_pic_url // _hd is also available here.
|
||||
}
|
||||
const clone = proxyExtendedOwner(this.data.owner)
|
||||
this.ownerPfpCacheP = clone.profile_pic_url
|
||||
return clone
|
||||
}
|
||||
// We'll have to re-request ourselves.
|
||||
else {
|
||||
await this.update()
|
||||
const clone = proxyExtendedOwner(this.data.owner)
|
||||
this.ownerPfpCacheP = clone.profile_pic_url
|
||||
return clone
|
||||
}
|
||||
}
|
||||
|
||||
getFeedData() {
|
||||
return {
|
||||
title: this.getCaptionIntroduction() || `New post from @${this.getBasicOwner().username}`,
|
||||
description: rssDescriptionTemplate({src: `${config.website_origin}${this.getDisplayUrlP()}`, alt: this.getAlt(), caption: this.getCaption()}),
|
||||
author: this.data.owner.username,
|
||||
url: `${config.website_origin}/p/${this.data.shortcode}`,
|
||||
guid: `${config.website_origin}/p/${this.data.shortcode}`,
|
||||
date: new Date(this.data.taken_at_timestamp*1000)
|
||||
/*
|
||||
Readers should display the description as HTML rather than using the media enclosure.
|
||||
enclosure: {
|
||||
url: this.data.display_url,
|
||||
type: "image/jpeg" //TODO: can instagram have PNGs? everything is JPEG according to https://medium.com/@autolike.it/how-to-avoid-low-res-thumbnails-on-instagram-android-problem-bc24f0ed1c7d
|
||||
}
|
||||
*/
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TimelineEntry
|
||||
@@ -1,160 +0,0 @@
|
||||
const config = require("../../../config")
|
||||
const {proxyImage} = require("../utils/proxyurl")
|
||||
const {compile} = require("pug")
|
||||
const collectors = require("../collectors")
|
||||
const TimelineChild = require("./TimelineChild")
|
||||
require("../testimports")(collectors, TimelineChild)
|
||||
|
||||
const rssDescriptionTemplate = compile(`
|
||||
p(style='white-space: pre-line')= caption
|
||||
img(alt=alt src=src)
|
||||
`)
|
||||
|
||||
class TimelineImage {
|
||||
/**
|
||||
* @param {import("../types").GraphImage} data
|
||||
* @param {boolean} isDirect
|
||||
*/
|
||||
constructor(data, isDirect) {
|
||||
this.data = data
|
||||
this.isDirect = isDirect
|
||||
this.proxyDisplayURL = proxyImage(this.data.display_url)
|
||||
/** @type {import("../types").BasicOwner} */
|
||||
this.basicOwner = null
|
||||
/** @type {import("../types").ExtendedOwner} */
|
||||
this.extendedOwner = null
|
||||
/** @type {string} */
|
||||
this.proxyOwnerProfilePicture = null
|
||||
this.fixData()
|
||||
}
|
||||
|
||||
/**
|
||||
* This must not cause issues if called multiple times on the same data.
|
||||
*/
|
||||
fixData() {
|
||||
this.data.edge_media_to_caption.edges.forEach(edge => edge.node.text = edge.node.text.replace(/\u2063/g, "")) // I don't know why U+2063 INVISIBLE SEPARATOR is in here, but it is, and it causes rendering issues with certain fonts.
|
||||
this.basicOwner = {
|
||||
id: this.data.owner.id,
|
||||
username: this.data.owner.username
|
||||
}
|
||||
// @ts-ignore
|
||||
if (this.data.owner.full_name !== undefined) this.extendedOwner = this.data.owner
|
||||
if (this.extendedOwner) this.proxyOwnerProfilePicture = proxyImage(this.extendedOwner.profile_pic_url)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("../types").GraphImage} data
|
||||
* @param {boolean} isDirect
|
||||
*/
|
||||
updateData(data, isDirect) {
|
||||
this.data = data
|
||||
this.isDirect = isDirect
|
||||
this.fixData()
|
||||
}
|
||||
|
||||
fetchDirect() {
|
||||
return collectors.fetchShortcode(this.data.shortcode, true) // automatically calls updateData
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<import("../types").ExtendedOwner>}
|
||||
*/
|
||||
fetchExtendedOwner() {
|
||||
// Do we just already have the extended owner?
|
||||
if (this.extendedOwner) {
|
||||
return Promise.resolve(this.extendedOwner)
|
||||
}
|
||||
// The owner happens to be in the user cache, so update from that.
|
||||
// This should maybe be moved to collectors.
|
||||
else if (collectors.requestCache.has("user/"+this.basicOwner.username)) {
|
||||
/** @type {import("./User")} */
|
||||
const user = collectors.requestCache.get("user/"+this.basicOwner.username)
|
||||
this.extendedOwner = {
|
||||
id: user.data.id,
|
||||
username: user.data.username,
|
||||
full_name: user.data.full_name,
|
||||
profile_pic_url: user.data.profile_pic_url
|
||||
}
|
||||
this.fixData()
|
||||
return Promise.resolve(this.extendedOwner)
|
||||
}
|
||||
// All else failed, we'll re-request ourselves.
|
||||
else {
|
||||
return this.fetchDirect().then(() => this.extendedOwner) // collectors will manage the updating.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {TimelineImage[]|import("./TimelineChild")[]}
|
||||
*/
|
||||
getChildren() {
|
||||
if (this.data.__typename === "GraphSidecar" && this.data.edge_sidecar_to_children && this.data.edge_sidecar_to_children.edges.length) {
|
||||
return this.data.edge_sidecar_to_children.edges.map(edge => new TimelineChild(edge.node))
|
||||
} else {
|
||||
return [this]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} size
|
||||
* @return {import("../types").Thumbnail}
|
||||
*/
|
||||
getSuggestedThumbnail(size) {
|
||||
let found = null
|
||||
for (const tr of this.data.thumbnail_resources) {
|
||||
found = tr
|
||||
if (tr.config_width >= size) break
|
||||
}
|
||||
return {
|
||||
config_width: found.config_width,
|
||||
config_height: found.config_height,
|
||||
src: proxyImage(found.src, found.config_width) // do not resize to requested size because of hidpi
|
||||
}
|
||||
}
|
||||
|
||||
getSrcset() {
|
||||
return this.data.thumbnail_resources.map(tr => {
|
||||
return `${proxyImage(tr.src, tr.config_width)} ${tr.config_width}w`
|
||||
}).join(", ")
|
||||
}
|
||||
|
||||
getSizes() {
|
||||
return `(max-width: 820px) 120px, 260px` // from css :(
|
||||
}
|
||||
|
||||
getCaption() {
|
||||
if (this.data.edge_media_to_caption.edges[0]) return this.data.edge_media_to_caption.edges[0].node.text
|
||||
else return null
|
||||
}
|
||||
|
||||
getIntroduction() {
|
||||
const caption = this.getCaption()
|
||||
if (caption) return caption.split("\n")[0].split(". ")[0] // try to get first meaningful line or sentence
|
||||
else return null
|
||||
}
|
||||
|
||||
getAlt() {
|
||||
// For some reason, pages 2+ don't contain a11y data. Instagram web client falls back to image caption.
|
||||
return this.data.accessibility_caption || this.getCaption() || "No image description available."
|
||||
}
|
||||
|
||||
getFeedData() {
|
||||
return {
|
||||
title: this.getIntroduction() || "No caption provided",
|
||||
description: rssDescriptionTemplate({src: config.website_origin+proxyImage(this.data.display_url), alt: this.getAlt(), caption: this.getCaption()}),
|
||||
author: this.data.owner.username,
|
||||
url: `${config.website_origin}/p/${this.data.shortcode}`,
|
||||
guid: `${config.website_origin}/p/${this.data.shortcode}`,
|
||||
date: new Date(this.data.taken_at_timestamp*1000)
|
||||
/*
|
||||
Readers should display the description as HTML rather than using the media enclosure.
|
||||
enclosure: {
|
||||
url: this.data.display_url,
|
||||
type: "image/jpeg" //TODO: can instagram have PNGs? everything is JPEG according to https://medium.com/@autolike.it/how-to-avoid-low-res-thumbnails-on-instagram-android-problem-bc24f0ed1c7d
|
||||
}
|
||||
*/
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TimelineImage
|
||||
Reference in New Issue
Block a user