1
0
mirror of https://git.sr.ht/~cadence/bibliogram synced 2024-11-22 08:07:30 +00:00
This commit is contained in:
Cadence Fish 2020-01-15 03:38:33 +13:00
parent b5f163891c
commit 30b45c2573
No known key found for this signature in database
GPG Key ID: 81015DF9AA8607E1
17 changed files with 157 additions and 26 deletions

3
config.js Normal file
View File

@ -0,0 +1,3 @@
module.exports = {
website_origin: "http://localhost:10407"
}

29
package-lock.json generated
View File

@ -1384,6 +1384,30 @@
"glob": "^7.1.3" "glob": "^7.1.3"
} }
}, },
"rss": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/rss/-/rss-1.2.2.tgz",
"integrity": "sha1-UKFpiHYTgTOnT5oF0r3I240nqSE=",
"requires": {
"mime-types": "2.1.13",
"xml": "1.0.1"
},
"dependencies": {
"mime-db": {
"version": "1.25.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.25.0.tgz",
"integrity": "sha1-wY29fHOl2/b0SgJNwNFloeexw5I="
},
"mime-types": {
"version": "2.1.13",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.13.tgz",
"integrity": "sha1-4HqqnGxrmnyjASxpADrSWjnpKog=",
"requires": {
"mime-db": "~1.25.0"
}
}
}
},
"safe-buffer": { "safe-buffer": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
@ -1748,6 +1772,11 @@
"resolved": "https://registry.npmjs.org/ws/-/ws-7.2.1.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-7.2.1.tgz",
"integrity": "sha512-sucePNSafamSKoOqoNfBd8V0StlkzJKL2ZAhGQinCfNQ+oacw+Pk7lcdAElecBF2VkLNZRiIb5Oi1Q5lVUVt2A==" "integrity": "sha512-sucePNSafamSKoOqoNfBd8V0StlkzJKL2ZAhGQinCfNQ+oacw+Pk7lcdAElecBF2VkLNZRiIb5Oi1Q5lVUVt2A=="
}, },
"xml": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
"integrity": "sha1-eLpyAgApxbyHuKgaPPzXS0ovweU="
},
"y18n": { "y18n": {
"version": "3.2.1", "version": "3.2.1",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz",

View File

@ -13,6 +13,7 @@
"gm": "^1.23.1", "gm": "^1.23.1",
"node-dir": "^0.1.17", "node-dir": "^0.1.17",
"node-fetch": "^2.6.0", "node-fetch": "^2.6.0",
"pinski": "github:cloudrac3r/pinski#6df26c4d35ff845a4ac56485a79a52970ea50c87" "pinski": "github:cloudrac3r/pinski#6df26c4d35ff845a4ac56485a79a52970ea50c87",
"rss": "^1.2.2"
} }
} }

View File

@ -22,6 +22,10 @@ class InstaCache {
return this.cache.get(key).data return this.cache.get(key).data
} }
getTtl(key, factor = 1) {
return Math.max((Math.floor(Date.now() - this.cache.get(key).time) / factor), 0)
}
/** /**
* @param {string} key * @param {string} key
* @param {any} data * @param {any} data

View File

@ -5,7 +5,7 @@ const InstaCache = require("./cache")
const {User} = require("./structures") const {User} = require("./structures")
require("./testimports")(constants, request, extractSharedData, InstaCache, User) require("./testimports")(constants, request, extractSharedData, InstaCache, User)
const cache = new InstaCache(600e3) const cache = new InstaCache(constants.resource_cache_time)
function fetchUser(username) { function fetchUser(username) {
return cache.getOrFetch("user/"+username, () => { return cache.getOrFetch("user/"+username, () => {

View File

@ -1,9 +1,11 @@
module.exports = { module.exports = {
image_cache_control: `public, max-age=${7*24*60*60}`, image_cache_control: `public, max-age=${7*24*60*60}`,
resource_cache_time: 30*60*1000,
external: { external: {
timeline_query_hash: "e769aa130647d2354c40ea6a439bfc08", timeline_query_hash: "e769aa130647d2354c40ea6a439bfc08",
timeline_fetch_first: 12 timeline_fetch_first: 12,
username_regex: "[\\w.]+"
}, },
symbols: { symbols: {

View File

@ -1,8 +1,11 @@
const RSS = require("rss")
const constants = require("../constants") const constants = require("../constants")
const config = require("../../../config")
const TimelineImage = require("./TimelineImage") const TimelineImage = require("./TimelineImage")
const collectors = require("../collectors") const collectors = require("../collectors")
require("../testimports")(constants, TimelineImage) require("../testimports")(constants, TimelineImage)
/** @param {any[]} edges */
function transformEdges(edges) { function transformEdges(edges) {
return edges.map(e => new TimelineImage(e.node)) return edges.map(e => new TimelineImage(e.node))
} }
@ -13,6 +16,7 @@ class Timeline {
*/ */
constructor(user) { constructor(user) {
this.user = user this.user = user
/** @type {import("./TimelineImage")[][]} */
this.pages = [] this.pages = []
this.addPage(this.user.data.edge_owner_to_timeline_media) this.addPage(this.user.data.edge_owner_to_timeline_media)
this.page_info = this.user.data.edge_owner_to_timeline_media.page_info this.page_info = this.user.data.edge_owner_to_timeline_media.page_info
@ -40,6 +44,23 @@ class Timeline {
this.pages.push(transformEdges(page.edges)) this.pages.push(transformEdges(page.edges))
this.page_info = page.page_info this.page_info = page.page_info
} }
getFeed() {
const feed = new RSS({
title: `@${this.user.data.username}`,
feed_url: `${config.website_origin}/u/${this.user.data.username}/rss.xml`,
site_url: config.website_origin,
description: this.user.data.biography,
image_url: this.user.data.profile_pic_url,
pubDate: new Date(this.user.cachedAt),
ttl: this.user.getTtl(1000*60) // scale to minute
})
const page = this.pages[0] // only get posts from first page
for (const item of page) {
feed.item(item.getFeedData())
}
return feed
}
} }
module.exports = Timeline module.exports = Timeline

View File

@ -1,9 +1,23 @@
const config = require("../../../config")
const {proxyImage} = require("../utils/proxyurl")
const {compile} = require("pug")
const rssDescriptionTemplate = compile(`
img(alt=alt src=src)
p(style='white-space: pre-line')= caption
`)
class GraphImage { class GraphImage {
/** /**
* @param {import("../types").GraphImage} data * @param {import("../types").GraphImage} data
*/ */
constructor(data) { constructor(data) {
this.data = data 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.
}
getProxy(url) {
return proxyImage(url)
} }
/** /**
@ -32,10 +46,34 @@ class GraphImage {
else return null 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() { getAlt() {
// For some reason, pages 2+ don't contain a11y data. Instagram web client falls back to image caption. // 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." return this.data.accessibility_caption || this.getCaption() || "No image description available."
} }
getFeedData() {
return {
title: this.getIntroduction() || "No caption provided",
description: rssDescriptionTemplate({src: 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 = GraphImage module.exports = GraphImage

View File

@ -1,5 +1,7 @@
const constants = require("../constants")
const {proxyImage} = require("../utils/proxyurl")
const Timeline = require("./Timeline") const Timeline = require("./Timeline")
require("../testimports")(Timeline) require("../testimports")(constants, Timeline)
class User { class User {
/** /**
@ -11,6 +13,14 @@ class User {
this.followedBy = data.edge_followed_by.count this.followedBy = data.edge_followed_by.count
this.posts = data.edge_owner_to_timeline_media.count this.posts = data.edge_owner_to_timeline_media.count
this.timeline = new Timeline(this) this.timeline = new Timeline(this)
this.cachedAt = Date.now()
this.proxyProfilePicture = proxyImage(this.data.profile_pic_url)
}
getTtl(scale = 1) {
const expiresAt = this.cachedAt + constants.resource_cache_time
const ttl = expiresAt - Date.now()
return Math.ceil(Math.max(ttl, 0) / scale)
} }
export() { export() {

View File

@ -0,0 +1,7 @@
function proxyImage(url) {
const params = new URLSearchParams()
params.set("url", url)
return "/imageproxy?"+params.toString()
}
module.exports.proxyImage = proxyImage

View File

@ -1,3 +1,4 @@
const constants = require("../../lib/constants")
const {fetchUser} = require("../../lib/collectors") const {fetchUser} = require("../../lib/collectors")
function reply(statusCode, content) { function reply(statusCode, content) {
@ -9,7 +10,7 @@ function reply(statusCode, content) {
} }
module.exports = [ module.exports = [
{route: "/api/user/(\\w+)", methods: ["GET"], code: async ({fill}) => { {route: `/api/user/(${constants.external.username_regex})`, methods: ["GET"], code: async ({fill}) => {
const user = await fetchUser(fill[0]) const user = await fetchUser(fill[0])
const data = user.export() const data = user.export()
return reply(200, data) return reply(200, data)

15
src/site/api/feed.js Normal file
View File

@ -0,0 +1,15 @@
const constants = require("../../lib/constants")
const {fetchUser} = require("../../lib/collectors")
const {render} = require("pinski/plugins")
module.exports = [
{route: `/u/(${constants.external.username_regex})/rss.xml`, methods: ["GET"], code: async ({url, fill}) => {
const user = await fetchUser(fill[0])
const content = user.timeline.getFeed().xml()
return {
statusCode: 200,
contentType: "application/rss+xml", // see https://stackoverflow.com/questions/595616/what-is-the-correct-mime-type-to-use-for-an-rss-feed
content
}
}}
]

View File

@ -1,8 +1,9 @@
const constants = require("../../lib/constants")
const {fetchUser} = require("../../lib/collectors") const {fetchUser} = require("../../lib/collectors")
const {render} = require("pinski/plugins") const {render} = require("pinski/plugins")
module.exports = [ module.exports = [
{route: "/u/([\\w.]+)", methods: ["GET"], code: async ({url, fill}) => { {route: `/u/(${constants.external.username_regex})`, methods: ["GET"], code: async ({url, fill}) => {
const params = url.searchParams const params = url.searchParams
const user = await fetchUser(fill[0]) const user = await fetchUser(fill[0])
const page = +params.get("page") const page = +params.get("page")
@ -11,7 +12,7 @@ module.exports = [
} }
return render(200, "pug/user.pug", {url, user}) return render(200, "pug/user.pug", {url, user})
}}, }},
{route: "/fragment/user/([\\w.]+)/(\\d+)", methods: ["GET"], code: async ({url, fill}) => { {route: `/fragment/user/(${constants.external.username_regex})/(\\d+)`, methods: ["GET"], code: async ({url, fill}) => {
const user = await fetchUser(fill[0]) const user = await fetchUser(fill[0])
const pageNumber = +fill[1] const pageNumber = +fill[1]
const pageIndex = pageNumber - 1 const pageIndex = pageNumber - 1

View File

@ -1,5 +0,0 @@
mixin image(url)
-
let params = new URLSearchParams()
params.set("url", url)
img(src="/imageproxy?"+params.toString())&attributes(attributes)

View File

@ -1,15 +1,14 @@
//- Needs page, pageIndex //- Needs page, pageIndex
include image.pug
mixin timeline_page(page, pageIndex) mixin timeline_page(page, pageIndex)
- const pageNumber = pageIndex + 1 section.timeline-page
if pageNumber > 1 - const pageNumber = pageIndex + 1
.page-number(id=`page-${pageNumber}`) if pageNumber > 1
span.number Page #{pageNumber} header.page-number(id=`page-${pageNumber}`)
span.number Page #{pageNumber}
.timeline-inner .timeline-inner
- const suggestedSize = 300 - const suggestedSize = 300
each image in page each image in page
- const thumbnail = image.getSuggestedThumbnail(suggestedSize) //- use this as the src in case there are problems with srcset - const thumbnail = image.getSuggestedThumbnail(suggestedSize) //- use this as the src in case there are problems with srcset
+image(thumbnail.src)(alt=image.getAlt() width=thumbnail.config_width height=thumbnail.config_height srcset=image.getSrcset() sizes=`${suggestedSize}px`).image img(src=image.getProxy(thumbnail.src) alt=image.getAlt() width=thumbnail.config_width height=thumbnail.config_height srcset=image.getSrcset() sizes=`${suggestedSize}px`).image

View File

@ -16,10 +16,10 @@ html
.main-divider .main-divider
header.profile-overview header.profile-overview
.profile-sticky .profile-sticky
+image(user.data.profile_pic_url)(width="150px" height="150px").pfp img(src=user.proxyProfilePicture width="150px" height="150px" alt=`${user.data.full_name}'s profile picture.`).pfp
//- //-
Instagram only uses the above URL, but an HD version is also available: Instagram only uses the above URL, but an HD version is also available.
+image(user.data.profile_pic_url_hd) The alt text is pathetic, I know. I don't have much to work with.
h1.full-name= user.data.full_name h1.full-name= user.data.full_name
h2.username= `@${user.data.username}` h2.username= `@${user.data.username}`
p.bio= user.data.biography p.bio= user.data.biography
@ -35,6 +35,8 @@ html
span(data-numberformat=user.followedBy).count #{numberFormat(user.followedBy)} span(data-numberformat=user.followedBy).count #{numberFormat(user.followedBy)}
| |
| followed by | followed by
div.links
a(rel="alternate" type="application/rss+xml" href=`/u/${user.data.username}/rss.xml`) RSS
main#timeline.timeline main#timeline.timeline
each page, pageIndex in user.timeline.pages each page, pageIndex in user.timeline.pages

View File

@ -71,6 +71,9 @@ body
.count .count
font-weight: bold font-weight: bold
.links
margin-top: 20px
.timeline .timeline
--image-size: 260px --image-size: 260px
$image-size: var(--image-size) $image-size: var(--image-size)