Create post viewer
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
class InstaCache {
|
||||
/**
|
||||
* @template T
|
||||
*/
|
||||
class TtlCache {
|
||||
/**
|
||||
* @property {number} ttl time to keep each resource in milliseconds
|
||||
* @param {number} ttl time to keep each resource in milliseconds
|
||||
*/
|
||||
constructor(ttl) {
|
||||
this.ttl = ttl
|
||||
/** @type {Map<string, {data: any, time: number}>} */
|
||||
/** @type {Map<string, {data: T, time: number}>} */
|
||||
this.cache = new Map()
|
||||
}
|
||||
|
||||
@@ -18,10 +21,23 @@ class InstaCache {
|
||||
/**
|
||||
* @param {string} key
|
||||
*/
|
||||
get(key) {
|
||||
return this.cache.get(key).data
|
||||
has(key) {
|
||||
return this.cache.has(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
*/
|
||||
get(key) {
|
||||
const value = this.cache.get(key)
|
||||
if (value) return value.data
|
||||
else return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {number} factor factor to divide the result by. use 60*1000 to get the ttl in minutes.
|
||||
*/
|
||||
getTtl(key, factor = 1) {
|
||||
return Math.max((Math.floor(Date.now() - this.cache.get(key).time) / factor), 0)
|
||||
}
|
||||
@@ -33,6 +49,15 @@ class InstaCache {
|
||||
set(key, data) {
|
||||
this.cache.set(key, {data, time: Date.now()})
|
||||
}
|
||||
}
|
||||
|
||||
class RequestCache extends TtlCache {
|
||||
/**
|
||||
* @param {number} ttl time to keep each resource in milliseconds
|
||||
*/
|
||||
constructor(ttl) {
|
||||
super(ttl)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
@@ -67,4 +92,5 @@ class InstaCache {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = InstaCache
|
||||
module.exports.TtlCache = TtlCache
|
||||
module.exports.RequestCache = RequestCache
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
const constants = require("./constants")
|
||||
const {request} = require("./utils/request")
|
||||
const {extractSharedData} = require("./utils/body")
|
||||
const InstaCache = require("./cache")
|
||||
const {User} = require("./structures")
|
||||
require("./testimports")(constants, request, extractSharedData, InstaCache, User)
|
||||
const {TtlCache, RequestCache} = require("./cache")
|
||||
require("./testimports")(constants, request, extractSharedData, RequestCache)
|
||||
|
||||
const cache = new InstaCache(constants.resource_cache_time)
|
||||
const requestCache = new RequestCache(constants.resource_cache_time)
|
||||
/** @type {import("./cache").TtlCache<import("./structures/TimelineImage")>} */
|
||||
const timelineImageCache = new TtlCache(constants.resource_cache_time)
|
||||
|
||||
function fetchUser(username) {
|
||||
return cache.getOrFetch("user/"+username, () => {
|
||||
return requestCache.getOrFetch("user/"+username, () => {
|
||||
return request(`https://www.instagram.com/${username}/`).then(res => res.text()).then(text => {
|
||||
// require down here or have to deal with require loop. require cache will take care of it anyway.
|
||||
// User -> Timeline -> TimelineImage -> collectors -/> User
|
||||
const User = require("./structures/User")
|
||||
const sharedData = extractSharedData(text)
|
||||
const user = new User(sharedData.entry_data.ProfilePage[0].graphql.user)
|
||||
return user
|
||||
@@ -30,7 +34,7 @@ function fetchTimelinePage(userID, after) {
|
||||
first: constants.external.timeline_fetch_first,
|
||||
after: after
|
||||
}))
|
||||
return cache.getOrFetchPromise("page/"+after, () => {
|
||||
return requestCache.getOrFetchPromise("page/"+after, () => {
|
||||
return request(`https://www.instagram.com/graphql/query/?${p.toString()}`).then(res => res.json()).then(root => {
|
||||
/** @type {import("./types").PagedEdges<import("./types").GraphImage>} */
|
||||
const timeline = root.data.user.edge_owner_to_timeline_media
|
||||
@@ -39,5 +43,52 @@ function fetchTimelinePage(userID, after) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} shortcode
|
||||
* @param {boolean} needDirect
|
||||
* @returns {Promise<import("./structures/TimelineImage")>}
|
||||
*/
|
||||
function fetchShortcode(shortcode, needDirect = false) {
|
||||
const attempt = timelineImageCache.get(shortcode)
|
||||
if (attempt && (attempt.isDirect === true || needDirect === false)) return Promise.resolve(attempt)
|
||||
|
||||
// example actual query from web:
|
||||
// query_hash=2b0673e0dc4580674a88d426fe00ea90&variables={"shortcode":"xxxxxxxxxxx","child_comment_count":3,"fetch_comment_count":40,"parent_comment_count":24,"has_threaded_comments":true}
|
||||
// we will not include params about comments, which means we will not receive comments, but everything else should still work fine
|
||||
const p = new URLSearchParams()
|
||||
p.set("query_hash", constants.external.shortcode_query_hash)
|
||||
p.set("variables", JSON.stringify({shortcode}))
|
||||
return requestCache.getOrFetchPromise("shortcode/"+shortcode, () => {
|
||||
return request(`https://www.instagram.com/graphql/query/?${p.toString()}`).then(res => res.json()).then(root => {
|
||||
/** @type {import("./types").GraphImage} */
|
||||
const data = root.data.shortcode_media
|
||||
return createShortcodeFromData(data, true)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("./types").GraphImage} data
|
||||
* @param {boolean} isDirect
|
||||
*/
|
||||
function createShortcodeFromData(data, isDirect) {
|
||||
const existing = timelineImageCache.get(data.shortcode)
|
||||
if (existing) {
|
||||
existing.updateData(data, isDirect)
|
||||
return existing
|
||||
} else {
|
||||
// require down here or have to deal with require loop. require cache will take care of it anyway.
|
||||
// TimelineImage -> collectors -/> TimelineImage
|
||||
const TimelineImage = require("./structures/TimelineImage")
|
||||
const timelineImage = new TimelineImage(data, false)
|
||||
timelineImageCache.set(data.shortcode, timelineImage)
|
||||
return timelineImage
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.fetchUser = fetchUser
|
||||
module.exports.fetchTimelinePage = fetchTimelinePage
|
||||
module.exports.fetchShortcode = fetchShortcode
|
||||
module.exports.createShortcodeFromData = createShortcodeFromData
|
||||
module.exports.requestCache = requestCache
|
||||
module.exports.timelineImageCache = timelineImageCache
|
||||
|
||||
@@ -4,8 +4,10 @@ module.exports = {
|
||||
|
||||
external: {
|
||||
timeline_query_hash: "e769aa130647d2354c40ea6a439bfc08",
|
||||
shortcode_query_hash: "2b0673e0dc4580674a88d426fe00ea90",
|
||||
timeline_fetch_first: 12,
|
||||
username_regex: "[\\w.]+"
|
||||
username_regex: "[\\w.]+",
|
||||
shortcode_regex: "[\\w-]+"
|
||||
},
|
||||
|
||||
symbols: {
|
||||
|
||||
@@ -2,12 +2,13 @@ const RSS = require("rss")
|
||||
const constants = require("../constants")
|
||||
const config = require("../../../config")
|
||||
const TimelineImage = require("./TimelineImage")
|
||||
const InstaCache = require("../cache")
|
||||
const collectors = require("../collectors")
|
||||
require("../testimports")(constants, TimelineImage)
|
||||
require("../testimports")(constants, collectors, TimelineImage, InstaCache)
|
||||
|
||||
/** @param {any[]} edges */
|
||||
function transformEdges(edges) {
|
||||
return edges.map(e => new TimelineImage(e.node))
|
||||
return edges.map(e => collectors.createShortcodeFromData(e.node, false))
|
||||
}
|
||||
|
||||
class Timeline {
|
||||
|
||||
42
src/lib/structures/TimelineChild.js
Normal file
42
src/lib/structures/TimelineChild.js
Normal file
@@ -0,0 +1,42 @@
|
||||
const config = require("../../../config")
|
||||
const {proxyImage} = require("../utils/proxyurl")
|
||||
const collectors = require("../collectors")
|
||||
require("../testimports")(collectors)
|
||||
|
||||
class TimelineChild {
|
||||
/**
|
||||
* @param {import("../types").GraphChild} data
|
||||
*/
|
||||
constructor(data) {
|
||||
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."
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TimelineChild
|
||||
@@ -1,27 +1,103 @@
|
||||
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 GraphImage {
|
||||
class TimelineImage {
|
||||
/**
|
||||
* @param {import("../types").GraphImage} data
|
||||
* @param {boolean} isDirect
|
||||
*/
|
||||
constructor(data) {
|
||||
constructor(data, isDirect) {
|
||||
this.data = data
|
||||
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.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()
|
||||
}
|
||||
|
||||
getProxy(url) {
|
||||
return proxyImage(url)
|
||||
/**
|
||||
* 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
|
||||
@@ -29,18 +105,23 @@ class GraphImage {
|
||||
found = tr
|
||||
if (tr.config_width >= size) break
|
||||
}
|
||||
return found
|
||||
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 => {
|
||||
const p = new URLSearchParams()
|
||||
p.set("width", String(tr.config_width))
|
||||
p.set("url", tr.src)
|
||||
return `/imageproxy?${p.toString()} ${tr.config_width}w`
|
||||
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
|
||||
@@ -76,4 +157,4 @@ class GraphImage {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GraphImage
|
||||
module.exports = TimelineImage
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
module.exports = {
|
||||
User: require("./User")
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
module.exports = function(...items) {
|
||||
items.forEach(item => {
|
||||
items.forEach((item, index) => {
|
||||
if (item === undefined || (item && item.constructor && item.constructor.name == "Object" && Object.keys(item).length == 0)) {
|
||||
throw new Error("Bad import: item looks like this: "+JSON.stringify(item))
|
||||
console.log(`Bad import for arg index ${index}`)
|
||||
// @ts-ignore
|
||||
require("/") // generate an error with a require stack.
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
* @type {{edges: {node: {text: string}}[]}}
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef GraphEdgesChildren
|
||||
* @type {{edges: {node: GraphChild}[]}}
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef PagedEdges<T>
|
||||
* @property {number} count
|
||||
@@ -46,6 +51,7 @@
|
||||
|
||||
/**
|
||||
* @typedef GraphImage
|
||||
* @property {string} __typename
|
||||
* @property {string} id
|
||||
* @property {GraphEdgesText} edge_media_to_caption
|
||||
* @property {string} shortcode
|
||||
@@ -55,10 +61,39 @@
|
||||
* @property {GraphEdgeCount} edge_media_preview_like
|
||||
* @property {{width: number, height: number}} dimensions
|
||||
* @property {string} display_url
|
||||
* @property {{id: string, username: string}} owner
|
||||
* @property {BasicOwner|ExtendedOwner} owner
|
||||
* @property {string} thumbnail_src
|
||||
* @property {Thumbnail[]} thumbnail_resources
|
||||
* @property {string} accessibility_caption
|
||||
* @property {GraphEdgesChildren} edge_sidecar_to_children
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef GraphChild
|
||||
* @property {string} __typename
|
||||
* @property {string} id
|
||||
* @property {string} shortcode
|
||||
* @property {{width: number, height: number}} dimensions
|
||||
* @property {string} display_url
|
||||
* @property {Thumbnail[]} display_resources
|
||||
* @property {string} accessibility_caption
|
||||
* @property {boolean} is_video
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef BasicOwner
|
||||
* From user HTML response.
|
||||
* @property {string} id
|
||||
* @property {string} username
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef ExtendedOwner
|
||||
* From post API response.
|
||||
* @property {string} id
|
||||
* @property {string|null} profile_pic_url
|
||||
* @property {string} username
|
||||
* @property {string} full_name
|
||||
*/
|
||||
|
||||
module.exports = {}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
function proxyImage(url) {
|
||||
function proxyImage(url, width) {
|
||||
const params = new URLSearchParams()
|
||||
if (width) params.set("width", width)
|
||||
params.set("url", url)
|
||||
return "/imageproxy?"+params.toString()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const fetch = require("node-fetch").default
|
||||
|
||||
function request(url) {
|
||||
console.log("-> [OUT]", url) // todo: make more like pinski?
|
||||
return fetch(url, {
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36"
|
||||
|
||||
Reference in New Issue
Block a user