First commit: /a/, /gallery/, images, gifv

This commit is contained in:
3nprob 2021-10-06 18:43:59 +09:00
commit 7c2e53c6e4
19 changed files with 6559 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
dist
node_modules

5995
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
package.json Normal file
View File

@ -0,0 +1,37 @@
{
"name": "imgur-proxy",
"version": "0.0.1",
"description": "",
"main": "dist/index.js",
"typings": "dist/index",
"scripts": {
"start": "node dist/index.js",
"build": "npx tsc",
"watch": "npx tsc --watch",
"test": "npx mocha -r ts-node/register test/**/*.test.ts",
"dev:tsc": "tsc --watch -p .",
"dev:serve": "nodemon -e js -w dist dist/index.js",
"dev": "run-p dev:*"
},
"author": "3np",
"license": "GPL-3.0-or-later",
"devDependencies": {
"@types/hapi__hapi": "^20.0.9",
"@types/hapi__inert": "^5.2.3",
"@types/hapi__vision": "^5.5.3",
"@types/node": "^16.10.3",
"@types/pug": "^2.0.5",
"nodemon": "^2.0.13",
"npm-run-all": "^4.1.5",
"typescript": "^4.4.3"
},
"dependencies": {
"@hapi/hapi": "^20.2.0",
"@hapi/inert": "^6.0.4",
"@hapi/vision": "^6.1.0",
"cheerio": "^1.0.0-rc.10",
"got": "^11.8.2",
"hpagent": "^0.1.2",
"pug": "^3.0.2"
}
}

BIN
samples/.a_DfEsrAB.swp Normal file

Binary file not shown.

1
samples/a_DfEsrAB Normal file

File diff suppressed because one or more lines are too long

1
samples/comments_g1bk7CB Normal file

File diff suppressed because one or more lines are too long

1
samples/gallery_g1bk7CB Normal file

File diff suppressed because one or more lines are too long

8
src/config.ts Normal file
View File

@ -0,0 +1,8 @@
export default {
port: process.env.RIMGU_PORT || 8080,
host: process.env.RIMGU_HOST || 'localhost',
address: process.env.RIMGU_ADDRESS || '127.0.0.1',
http_proxy: process.env.RIMGU_HTTP_PROXY || null,
https_proxy: process.env.RIMGU_HTTPS_PROXY || null,
imgur_client_id: process.env.RIMGU_IMGUR_CLIENT_ID || null,
};

68
src/fetchers.ts Normal file
View File

@ -0,0 +1,68 @@
import cheerio from 'cheerio';
import got, { Response } from 'got';
import { HttpsProxyAgent, HttpProxyAgent } from 'hpagent';
import { globalAgent as httpGlobalAgent } from 'http';
import { globalAgent as httpsGlobalAgent } from 'https';
import CONFIG from './config';
const GALLERY_JSON_REGEX = /window\.postDataJSON=(".*")$/;
const agent = {
http: CONFIG.http_proxy
? new HttpProxyAgent({
keepAlive: true,
keepAliveMsecs: 1000,
maxSockets: 256,
maxFreeSockets: 256,
scheduling: 'lifo',
proxy: CONFIG.http_proxy,
})
: httpGlobalAgent,
https: CONFIG.https_proxy
? new HttpsProxyAgent({
keepAlive: true,
keepAliveMsecs: 1000,
maxSockets: 256,
maxFreeSockets: 256,
scheduling: 'lifo',
proxy: CONFIG.https_proxy,
})
: httpsGlobalAgent
};
export const fetchComments = async (galleryID: string): Promise<Comment[]> => {
// https://api.imgur.com/comment/v1/comments?client_id=${CLIENT_ID}%5Bpost%5D=eq%3Ag1bk7CB&include=account%2Cadconfig&per_page=30&sort=best
const response = await got(`https://api.imgur.com/comment/v1/comments?client_id=${CONFIG.imgur_client_id}&filter%5Bpost%5D=eq%3A${galleryID}&include=account%2Cadconfig&per_page=30&sort=best`);
return JSON.parse(response.body).data;
}
export const fetchGallery = async (galleryID: string): Promise<Gallery> => {
// https://imgur.com/gallery/g1bk7CB
const response = await got(`https://imgur.com/gallery/${galleryID}`, { agent });
const $ = cheerio.load(response.body);
const postDataScript = $('head script:first-of-type').html();
if (!postDataScript) {
throw new Error('Could not find gallery data');
}
const postDataMatches = postDataScript.match(GALLERY_JSON_REGEX);
if (!postDataMatches || postDataMatches.length < 2) {
throw new Error('Could not parse gallery data');
}
const postData = JSON.parse(JSON.parse(postDataMatches[1]));
return postData;
};
export const fetchAlbumURL = async (albumID: string): Promise<string> => {
// https://imgur.com/a/DfEsrAB
const response = await got(`https://imgur.com/a/${albumID}`, { agent });
const $ = cheerio.load(response.body);
const url = $('head meta[property="og:image"]').attr('content')?.replace(/\/\?.*$/, '');
if (!url) {
throw new Error('Could not read image url');
}
return url;
};
export const fetchMedia = async (filename: string): Promise<Response<string>> =>
await got(`https://i.imgur.com/${filename}`, { agent });

45
src/handlers.ts Normal file
View File

@ -0,0 +1,45 @@
import Hapi = require('@hapi/hapi');
import '@hapi/vision';
import { fetchAlbumURL, fetchComments, fetchGallery, fetchMedia } from './fetchers';
import * as util from './util';
export const handleMedia = async (request: Hapi.Request, h: Hapi.ResponseToolkit) => {
const {
baseName,
extension,
} = request.params;
const result = await fetchMedia(`${baseName}.${extension}`);
const response = h.response(result.rawBody)
.header('Content-Type', result.headers["content-type"] || `image/${extension}`);
return response;
};
export const handleAlbum = async (request: Hapi.Request, h: Hapi.ResponseToolkit) => {
// https://imgur.com/a/DfEsrAB
const url = await fetchAlbumURL(request.params.albumID);
return h.view('album', {
url,
util,
});
};
export const handleUser = (request: Hapi.Request, h: Hapi.ResponseToolkit) => {
// https://imgur.com/user/MomBotNumber5
throw new Error('not implemented');
};
export const handleTag = (request: Hapi.Request, h: Hapi.ResponseToolkit) => {
// https://imgur.com/t/funny
throw new Error('not implemented');
};
export const handleGallery = async (request: Hapi.Request, h: Hapi.ResponseToolkit) => {
const galleryID = request.params.galleryID;
const gallery = await fetchGallery(galleryID);
const comments = await fetchComments(galleryID);
return h.view('gallery', {
...gallery,
comments,
util,
});
};

74
src/index.ts Normal file
View File

@ -0,0 +1,74 @@
'use strict';
import Hapi = require('@hapi/hapi');
import Path = require('path');
import { handleAlbum, handleGallery, handleMedia, handleTag, handleUser } from './handlers';
import CONFIG from './config';
const init = async () => {
const server = Hapi.server({
port: CONFIG.port,
host: CONFIG.host,
address: CONFIG.address,
routes: {
files: {
relativeTo: Path.join(__dirname, 'static')
}
}
});
await server.register(require('@hapi/vision'));
await server.register(require('@hapi/inert'));
server.route({
method: 'GET',
path: '/css/{param*}',
handler: ({
directory: {
path: Path.join(__dirname, 'static/css')
}
} as any)
});
server.views({
engines: {
pug: require('pug')
},
relativeTo: __dirname,
path: 'templates',
});
server.route({
method: 'GET',
path: '/{baseName}.{extension}',
handler: handleMedia,
});
server.route({
method: 'GET',
path: '/a/{albumID?}',
handler: handleAlbum,
});
server.route({
method: 'GET',
path: '/t/{tagID?}',
handler: handleTag,
});
server.route({
method: 'GET',
path: '/user/{userID?}',
handler: handleUser,
});
server.route({
method: 'GET',
path: '/gallery/{galleryID}',
handler: handleGallery,
});
await server.start();
console.log('Server running on %s', server.info.uri);
};
process.on('unhandledRejection', (err) => {
console.error(err);
process.exit(1);
});
init();

77
src/types/index.d.ts vendored Normal file
View File

@ -0,0 +1,77 @@
interface Account {
id: number;
username: string;
avatar_url: string;
created_at: string;
}
interface Gallery {
id: string;
title: string;
account: Account;
media: Media[];
tags: Tag[];
cover: Media;
}
type MediaMimeType = 'image/jpeg' | 'image/png' | 'image/gif';
type MediaType = 'image';
type MediaExt = 'jpeg' | 'png' | 'gif';
interface Tag {
tag: string;
display: string;
background_id: string;
accent: string;
is_promoted: boolean;
}
interface Media {
id: string;
account_id: number;
mime_type: MediaMimeType;
type: MediaType;
name: string;
basename: string;
url: string;
ext: MediaExt;
width: number;
height: number;
size: number;
metadata: {
title: string;
description: string;
is_animated: boolean;
is_looping: boolean;
duration: number;
has_sound: boolean;
},
created_at: string;
updated_at: string | null;
}
type MediaPlatform = 'ios' | 'android' | 'api' | 'web';
interface Comment {
id: number;
parent_id: number;
comment: string;
account_id: number;
post_id: string;
upvote_count: number;
downvote_count: number;
point_count: number;
vote: null; // ?
platform_id: number;
platform: MediaPlatform;
created_at: string;
updated_at: "2021-10-01T00:08:51Z",
deleted_at: null,
next: null; //?
comments: Comment[];
account: {
id: number;
username: string;
avatar: string;
}
}

11
src/util.ts Normal file
View File

@ -0,0 +1,11 @@
export const proxyURL = (url: string): string =>
url.replace(/^https?:\/\/[^.]*\.imgur.com\//, '/');
export const linkify = (content: string) =>
content.replace(
/https?:\/\/[^.]*\.imgur.com\/([\/_a-zA-Z0-9-]+)\.gifv/g,
'<video src="/$1.mp4" class="commentVideo commentObject" loop="" autoplay=""></video>'
).replace(
/https?:\/\/[^.]*\.imgur.com\/([\/_a-zA-Z0-9-]+\.[a-z0-9A-Z]{2,6})/g,
'<a href="/$1" target="_blank"><img class="commentImage commentObject" src="/$1" loading="lazy" /></a>'
);

4
static/css/custom.css Normal file
View File

@ -0,0 +1,4 @@
img.album-img {
max-width: 100%;
max-height: 100%;
}

139
static/css/styles.css Normal file
View File

@ -0,0 +1,139 @@
.UserAvatar {
display: block;
height: 32px;
width: 32px;
background-size: cover;
}
.TagPill {
box-shadow: 0 5px 5px rgba(0,0,0,.25);
border-radius: 54px;
font-size: 14px;
line-height: 20px;
text-align: center;
letter-spacing: .02em;
color: #eff1f4;
text-shadow: 0 1px 4px #000;
padding: 8px 30px;
display: inline-block;
font-family: Proxima Nova Bold,Helvetica Neue,Helvetica,Arial,sans-serif;
transition: box-shadow .2s ease-out;
text-transform: lowercase;
}
.GalleryComment-avatar-bar .avatar span {
display: block;
background-color: grey;
border-radius: 100%;
height: 24px;
width: 24px;
background-size: cover;
}
.GalleryComment-byLine .author-name {
text-overflow: ellipsis;
color: #01b96b;
font-family: Proxima Nova Bold,Helvetica Neue,Helvetica,Arial,sans-serif;
}
.GalleryComment-avatar-bar .avatar {
margin: 2px 0;
border-radius: 50%;
}
.GalleryComment-byLine {
font-size: 12px;
line-height: 12px;
color: #b4b9c2;
}
.GalleryComment-avatar-bar {
width: 24px;
margin-right: 8px;
flex-direction: column;
align-items: center;
position: relative;
}
.GalleryComment-byLine .Meta {
display: flex;
align-items: center;
}
*, ::after, ::before {
box-sizing: inherit;
}
.GalleryComment-replies {
padding-left: 32px;
padding-top: 12px;
}
.GalleryComment-body .commentObject {
display: block;
cursor: pointer;
max-height: 100px;
min-width: 50px;
max-width: 500px;
padding: 5px 0 0;
}
.GalleryComment-body {
font-size: 15px;
line-height: 150%;
overflow-wrap: break-word;
color: #eff1f4;
}
.GalleryComment-actions .points {
padding: 0 6px;
}
.GalleryComment-actions .actions-btn {
color: #b4b9c2;
cursor: pointer;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
svg:not(:root) {
overflow: hidden;
}
.Vote {
position: relative;
}
.GalleryComment-actions .actions-btn.vote-btn {
top: -1px;
}
.GalleryComment-actions .actions-btn {
display: flex;
align-items: center;
border: 0;
background-color: transparent;
color: #b4b9c2;
outline: none;
/* cursor: pointer; */
padding: 5px;
position: relative;
min-height: 19px;
border-radius: 3px;
height: 26px;
justify-content: center;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
.GalleryComment-actions {
display: flex;
margin: 4px 0 8px -5px;
align-items: center;
color: #b4b9c2;
font-family: Proxima Nova Bold,Helvetica Neue,Helvetica,Arial,sans-serif;
font-size: 12px;
line-height: 12px;
}

6
templates/album.pug Normal file
View File

@ -0,0 +1,6 @@
html
head
title imgur-proxy
include includes/head.pug
body
img(src=util.proxyURL(url), alt='' class='album-img')

71
templates/gallery.pug Normal file
View File

@ -0,0 +1,71 @@
mixin commentbox(comment)
div(class='GalleryComment')
div(class='GalleryComment-wrapper')
div(class='GalleryComment-content')
div(class='GalleryComment-byLine')
div(class='Meta')
div(class='GalleryComment-avatar-bar')
div(class='avatar')
a(title='View profile of '+comment.account.username, href='/user/'+comment.account.username)
span(title=comment.account.username, style='background-image: url("' + util.proxyURL(comment.account.avatar) + '");')
a(class='author-name', title='View profile of '+comment.account.username, href='/user/'+comment.account.username) #{comment.account.username}
span(class="date", title=comment.created_at)
span(class="delimiter") •
span #{comment.created_at} via <a class="platform bold" href="/apps">#{comment.platform}</a>
div(class='GalleryComment-body')
span(class='Linkify')
| !{util.linkify(comment.comment)}
div(class='GalleryComment-actions')
div(class='vote-btn upvote actions-btn' title='Upvotes')
div(class='Vote Vote-up')
svg(width='16', height='16', viewBox='0 0 16 16', fill='none', xmlns='http://www.w3.org/2000/svg')
title Upvotes
| <path fill="none" stroke="#B4B9C2" stroke-width="2" fill-rule="evenodd" clip-rule="evenodd" d="M7.197 2.524a1.2 1.2 0 011.606 0c.521.46 1.302 1.182 2.363 2.243a29.617 29.617 0 012.423 2.722c.339.435.025 1.028-.526 1.028h-2.397v4.147c0 .524-.306.982-.823 1.064-.417.066-1.014.122-1.843.122s-1.427-.056-1.843-.122c-.517-.082-.824-.54-.824-1.064V8.517H2.937c-.552 0-.865-.593-.527-1.028.52-.669 1.32-1.62 2.423-2.722a52.996 52.996 0 012.364-2.243z"></path>
.points + #{comment.upvote_count}
div(class='vote-btn down actions-btn' title='Downvotes')
div(class='Vote Vote-down')
svg(width='16', height='16', viewBox='0 0 16 16', fill='none', xmlns='http://www.w3.org/2000/svg')
title Downvotes
| <path fill="none" stroke="#B4B9C2" stroke-width="2" fill-rule="evenodd" clip-rule="evenodd" d="M8.803 13.476a1.2 1.2 0 01-1.606 0 53.03 53.03 0 01-2.364-2.243 29.613 29.613 0 01-2.422-2.722c-.339-.435-.025-1.028.526-1.028h2.397V3.336c0-.524.306-.982.823-1.064A11.874 11.874 0 018 2.15c.829 0 1.427.056 1.843.122.517.082.824.54.824 1.064v4.147h2.396c.552 0 .865.593.527 1.028-.52.669-1.32 1.62-2.423 2.722a53.038 53.038 0 01-2.364 2.243z"></path>
.points - #{comment.downvote_count}
.points = #{comment.point_count}
div(class='GalleryComment-replies')
each reply in comment.comments
+commentbox(reply)
html
head
title imgur-proxy
include includes/head.pug
body
div(class='Gallery-Content')
div(class='Gallery-Header')
div(class='Gallery-Title')
span #{title}
div(class='Gallery-Byline')
a(class='author-link' title='View profile of '+account.username, href='/user/'+account.username)
span(class='UserAvatar', title=account.username, style='background-image: url("' + util.proxyURL(account.avatar_url) + '");')
div(class='Info-Wrapper')
div(class='Info')
a(class='author-name' title='View profile of '+account.username, href='/user/'+account.username) #{account.username}
div(class='Meta')
span #{view_count} Views
span(class='delimiter') •
span(title=created_at) #{created_at}
div(class='Gallery-ContentWrapper')
div(class='Gallery-Content--media')
div(class='imageContainer')
img(src=util.proxyURL(cover.url))
div(class='Gallery-Content--tags')
each tag in tags
a(class='TagPill'
style='background: linear-gradient(0deg, rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.2)) repeat scroll 0% 0%, rgba(0, 0, 0, 0) url("/' + tag.background_id + '_d.jpg?maxwidth=200&fidelity=grand") repeat scroll 0% 0%;'
href='/t/'+tag.tag) #{tag.tag}
div(class='CommentsList')
div(class='CommentsList-headline')
div(class='CommentsList-headline--counter')
span #{comments.length} Comments
div
div(class='CommentsList-comments')
div(class='CommentsList-comments--container')
each comment in comments
+commentbox(comment)

View File

@ -0,0 +1,2 @@
link(rel="stylesheet", type="text/css", href="/css/styles.css")
link(rel="stylesheet", type="text/css", href="/css/custom.css")

17
tsconfig.json Executable file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "es2019",
"noImplicitAny": true,
"strictNullChecks": true,
"declaration": true,
"sourceMap": false,
"outDir": "dist",
"typeRoots": [
"src/types/"
]
},
"include": [
"src/**/*"
]
}