Compare commits
57 commits
af1d873de0
...
9fa03b99a2
Author | SHA1 | Date | |
---|---|---|---|
|
9fa03b99a2 | ||
|
8a3261c928 | ||
|
049347a857 | ||
|
b47881967d | ||
|
5895d2e276 | ||
|
4cf25d20c7 | ||
|
a57b66a1f3 | ||
|
769cb16a6b | ||
|
c53b8d4d8a | ||
|
a451755d2b | ||
|
7350155d2d | ||
|
bf7e194ea3 | ||
|
28887c18db | ||
|
2ac79dcb7e | ||
|
b41679f9f3 | ||
|
25bbbea809 | ||
|
d0259c4814 | ||
|
188445e96b | ||
|
f14409f5f5 | ||
|
26ee35b7fc | ||
|
78b63f3f16 | ||
|
cb0d360516 | ||
|
7d846ed759 | ||
|
e4e7fe5f00 | ||
|
b313bb0e72 | ||
|
f290b7b5e7 | ||
|
813a71e4d3 | ||
|
b2beabf6cd | ||
|
41787a9451 | ||
|
b2cc63cd99 | ||
|
b67dc67b22 | ||
|
f15a72e89d | ||
|
8bcab11109 | ||
|
0f3203e903 | ||
|
efdedd3619 | ||
|
6bd21d6f0a | ||
|
ced0599e89 | ||
|
719a08169d | ||
|
71fab89046 | ||
|
4bdad64060 | ||
|
25b788428b | ||
|
2ce3ee6d84 | ||
|
1150a59e38 | ||
|
7a89401f04 | ||
|
ba9a4714e2 | ||
|
e4eea3d2df | ||
|
208c39db87 | ||
|
7753e44d36 | ||
|
1634ffdf43 | ||
|
12f2e16c81 | ||
|
892caaf796 | ||
|
e6e30baa43 | ||
|
11279e2b4f | ||
|
6875569bf2 | ||
|
d5689f2253 | ||
|
a6dd229444 | ||
|
7d2a558e89 |
30 changed files with 740 additions and 465 deletions
62
.github/workflows/build-docker.yml
vendored
62
.github/workflows/build-docker.yml
vendored
|
@ -1,62 +0,0 @@
|
||||||
name: Docker
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths-ignore:
|
|
||||||
- "README.md"
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
tests:
|
|
||||||
uses: ./.github/workflows/run-tests.yml
|
|
||||||
build-docker-amd64:
|
|
||||||
needs: [tests]
|
|
||||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
id: buildx
|
|
||||||
uses: docker/setup-buildx-action@v2
|
|
||||||
with:
|
|
||||||
version: latest
|
|
||||||
- name: Login to DockerHub
|
|
||||||
uses: docker/login-action@v2
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
|
||||||
- name: Build and push AMD64 Docker image
|
|
||||||
uses: docker/build-push-action@v3
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./Dockerfile
|
|
||||||
platforms: linux/amd64
|
|
||||||
push: true
|
|
||||||
tags: zedeus/nitter:latest,zedeus/nitter:${{ github.sha }}
|
|
||||||
build-docker-arm64:
|
|
||||||
needs: [tests]
|
|
||||||
runs-on: buildjet-2vcpu-ubuntu-2204-arm
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
id: buildx
|
|
||||||
uses: docker/setup-buildx-action@v2
|
|
||||||
with:
|
|
||||||
version: latest
|
|
||||||
- name: Login to DockerHub
|
|
||||||
uses: docker/login-action@v2
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
|
||||||
- name: Build and push ARM64 Docker image
|
|
||||||
uses: docker/build-push-action@v3
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./Dockerfile.arm64
|
|
||||||
platforms: linux/arm64
|
|
||||||
push: true
|
|
||||||
tags: zedeus/nitter:latest-arm64,zedeus/nitter:${{ github.sha }}-arm64
|
|
60
.github/workflows/build-publish-docker.yml
vendored
Normal file
60
.github/workflows/build-publish-docker.yml
vendored
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
name: Build and Publish Docker
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["master"]
|
||||||
|
paths-ignore: ["README.md"]
|
||||||
|
pull_request:
|
||||||
|
branches: ["master"]
|
||||||
|
paths-ignore: ["README.md"]
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
id: qemu
|
||||||
|
uses: docker/setup-qemu-action@v2
|
||||||
|
with:
|
||||||
|
platforms: arm64
|
||||||
|
|
||||||
|
- name: Setup Docker buildx
|
||||||
|
id: buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v4
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
ghcr.io/${{ env.IMAGE_NAME }}
|
||||||
|
|
||||||
|
|
||||||
|
- name: Build and push all platforms Docker image
|
||||||
|
id: build-and-push
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
10
README.md
10
README.md
|
@ -33,6 +33,10 @@ XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscW
|
||||||
- Archiving tweets/profiles
|
- Archiving tweets/profiles
|
||||||
- Developer API
|
- Developer API
|
||||||
|
|
||||||
|
## New Features
|
||||||
|
|
||||||
|
- Likes tab
|
||||||
|
|
||||||
## Resources
|
## Resources
|
||||||
|
|
||||||
The wiki contains
|
The wiki contains
|
||||||
|
@ -99,6 +103,12 @@ $ nimble md
|
||||||
$ cp nitter.example.conf nitter.conf
|
$ cp nitter.example.conf nitter.conf
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Edit `twitter_oauth.sh` with your Twitter account name and password.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ ./twitter_oauth.sh | tee -a guest_accounts.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
Set your hostname, port, HMAC key, https (must be correct for cookies), and
|
Set your hostname, port, HMAC key, https (must be correct for cookies), and
|
||||||
Redis info in `nitter.conf`. To run Redis, either run
|
Redis info in `nitter.conf`. To run Redis, either run
|
||||||
`redis-server --daemonize yes`, or `systemctl enable --now redis` (or
|
`redis-server --daemonize yes`, or `systemctl enable --now redis` (or
|
||||||
|
|
|
@ -1,34 +1,14 @@
|
||||||
version: "3"
|
version: "3"
|
||||||
|
|
||||||
networks:
|
|
||||||
nitter:
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# proxy:
|
|
||||||
# hostname: nitter-proxy
|
|
||||||
# container_name: nitter-proxy
|
|
||||||
# build:
|
|
||||||
# context: ./proxy
|
|
||||||
# dockerfile: Dockerfile
|
|
||||||
# environment:
|
|
||||||
# HOST: "0.0.0.0"
|
|
||||||
# PORT: "8080"
|
|
||||||
# NITTER_BASE_URL: "http://nitter:8080"
|
|
||||||
# CONCURRENCY: "1"
|
|
||||||
# ports:
|
|
||||||
# - "8002:8080"
|
|
||||||
# networks:
|
|
||||||
# - nitter
|
|
||||||
|
|
||||||
nitter:
|
nitter:
|
||||||
build: .
|
image: ghcr.io/privacydevel/nitter:master
|
||||||
container_name: nitter
|
container_name: nitter
|
||||||
hostname: nitter
|
|
||||||
ports:
|
ports:
|
||||||
- "8002:8080" # Replace with "8080:8080" if you don't use a reverse proxy
|
- "127.0.0.1:8080:8080" # Replace with "8080:8080" if you don't use a reverse proxy
|
||||||
volumes:
|
volumes:
|
||||||
- ./nitter.conf:/src/nitter.conf:Z,ro
|
- ./nitter.conf:/src/nitter.conf:Z,ro
|
||||||
- ./guest_accounts.json:/src/guest_accounts.json:Z,ro
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- nitter-redis
|
- nitter-redis
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
@ -43,8 +23,6 @@ services:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
cap_drop:
|
cap_drop:
|
||||||
- ALL
|
- ALL
|
||||||
networks:
|
|
||||||
- nitter
|
|
||||||
|
|
||||||
nitter-redis:
|
nitter-redis:
|
||||||
image: redis:6-alpine
|
image: redis:6-alpine
|
||||||
|
@ -64,8 +42,6 @@ services:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
cap_drop:
|
cap_drop:
|
||||||
- ALL
|
- ALL
|
||||||
networks:
|
|
||||||
- nitter
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
nitter-redis:
|
nitter-redis:
|
||||||
|
|
2
proxy/.prettierignore
Normal file
2
proxy/.prettierignore
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
**/*.md
|
||||||
|
pnpm-lock.yaml
|
6
proxy/.prettierrc.yaml
Normal file
6
proxy/.prettierrc.yaml
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
proseWrap: always
|
||||||
|
semi: false
|
||||||
|
singleQuote: true
|
||||||
|
printWidth: 80
|
||||||
|
trailingComma: none
|
||||||
|
htmlWhitespaceSensitivity: ignore
|
|
@ -4,6 +4,7 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rm -rf build",
|
"clean": "rm -rf build",
|
||||||
"prebuild": "npm run clean",
|
"prebuild": "npm run clean",
|
||||||
|
"format": "prettier -w --cache --check .",
|
||||||
"build": "tsc --build"
|
"build": "tsc --build"
|
||||||
},
|
},
|
||||||
"author": "",
|
"author": "",
|
||||||
|
@ -22,6 +23,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.12.12",
|
"@types/node": "^20.12.12",
|
||||||
"dotenv": "^16.4.4"
|
"dotenv": "^16.4.4",
|
||||||
|
"prettier": "^3.2.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -45,6 +45,9 @@ importers:
|
||||||
dotenv:
|
dotenv:
|
||||||
specifier: ^16.4.4
|
specifier: ^16.4.4
|
||||||
version: 16.4.4
|
version: 16.4.4
|
||||||
|
prettier:
|
||||||
|
specifier: ^3.2.5
|
||||||
|
version: 3.2.5
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
|
@ -292,6 +295,11 @@ packages:
|
||||||
resolution: {integrity: sha512-Mz/gKiRyuXu4HnpHgi1YWdHQCoWMufapzooisvFn78zl4dZciAxS+YeRkUxXl1ee/SzU80YCz1zpECCh4oC6Aw==}
|
resolution: {integrity: sha512-Mz/gKiRyuXu4HnpHgi1YWdHQCoWMufapzooisvFn78zl4dZciAxS+YeRkUxXl1ee/SzU80YCz1zpECCh4oC6Aw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
prettier@3.2.5:
|
||||||
|
resolution: {integrity: sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==}
|
||||||
|
engines: {node: '>=14'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
process-warning@2.3.2:
|
process-warning@2.3.2:
|
||||||
resolution: {integrity: sha512-n9wh8tvBe5sFmsqlg+XQhaQLumwpqoAUruLwjCopgTmUBjJ/fjtBsJzKleCaIGBOMXYEhp1YfKl4d7rJ5ZKJGA==}
|
resolution: {integrity: sha512-n9wh8tvBe5sFmsqlg+XQhaQLumwpqoAUruLwjCopgTmUBjJ/fjtBsJzKleCaIGBOMXYEhp1YfKl4d7rJ5ZKJGA==}
|
||||||
|
|
||||||
|
@ -668,6 +676,8 @@ snapshots:
|
||||||
sonic-boom: 3.8.0
|
sonic-boom: 3.8.0
|
||||||
thread-stream: 2.4.1
|
thread-stream: 2.4.1
|
||||||
|
|
||||||
|
prettier@3.2.5: {}
|
||||||
|
|
||||||
process-warning@2.3.2: {}
|
process-warning@2.3.2: {}
|
||||||
|
|
||||||
process-warning@3.0.0: {}
|
process-warning@3.0.0: {}
|
||||||
|
|
|
@ -1,89 +1,116 @@
|
||||||
|
|
||||||
import fastify, {
|
import fastify, {
|
||||||
FastifyInstance,
|
FastifyInstance,
|
||||||
FastifyListenOptions,
|
FastifyListenOptions,
|
||||||
FastifyReply,
|
FastifyReply,
|
||||||
FastifyRequest,
|
FastifyRequest
|
||||||
} from "fastify"
|
} from 'fastify'
|
||||||
import { PinoLoggerOptions } from "fastify/types/logger"
|
import { PinoLoggerOptions } from 'fastify/types/logger'
|
||||||
import { Proxy } from "./proxy"
|
import { Proxy } from './proxy'
|
||||||
import { Logger } from "pino"
|
import { Logger } from 'pino'
|
||||||
import 'dotenv/config'
|
import 'dotenv/config'
|
||||||
|
|
||||||
const host = process.env.HOST
|
const host = process.env.HOST
|
||||||
const port = parseInt(process.env.PORT ?? "8080", 10)
|
const port = parseInt(process.env.PORT ?? '8080', 10)
|
||||||
const baseUrl = process.env.NITTER_BASE_URL
|
const baseUrl = process.env.NITTER_BASE_URL
|
||||||
const concurrency = parseInt(process.env.CONCURRENCY ?? "1", 10)
|
const concurrency = parseInt(process.env.CONCURRENCY ?? '1', 10)
|
||||||
const retryAfterMillis = process.env.RETRY_AFTER_MILLIS ? parseInt(process.env.RETRY_AFTER_MILLIS, 10) : null
|
const retryAfterMillis = process.env.RETRY_AFTER_MILLIS
|
||||||
const maxCacheSize = parseInt(process.env.MAX_CACHE_SIZE ?? "100000", 10)
|
? parseInt(process.env.RETRY_AFTER_MILLIS, 10)
|
||||||
const logLevel = process.env.LOG_LEVEL ?? "debug"
|
: null
|
||||||
|
const maxCacheSize = parseInt(process.env.MAX_CACHE_SIZE ?? '100000', 10)
|
||||||
|
const logLevel = process.env.LOG_LEVEL ?? 'debug'
|
||||||
|
|
||||||
const server = fastify({
|
const server = fastify({
|
||||||
logger: {
|
logger: {
|
||||||
name: "app",
|
name: 'app',
|
||||||
level: logLevel,
|
level: logLevel,
|
||||||
...( logLevel == "trace" ? { transport: { target: 'pino-pretty' } } : {})
|
...(logLevel == 'trace' ? { transport: { target: 'pino-pretty' } } : {})
|
||||||
} as PinoLoggerOptions
|
} as PinoLoggerOptions
|
||||||
})
|
})
|
||||||
|
|
||||||
const log = server.log as Logger
|
const log = server.log as Logger
|
||||||
const proxy = new Proxy(log, baseUrl, concurrency, retryAfterMillis, maxCacheSize)
|
const proxy = new Proxy(
|
||||||
|
log,
|
||||||
|
baseUrl,
|
||||||
|
concurrency,
|
||||||
|
retryAfterMillis,
|
||||||
|
maxCacheSize
|
||||||
|
)
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
server.register(
|
||||||
|
(fastify: FastifyInstance, opts, done) => {
|
||||||
|
fastify.get(
|
||||||
|
`/user/:username`,
|
||||||
|
{},
|
||||||
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
log.debug(
|
||||||
|
{
|
||||||
|
headers: request.headers,
|
||||||
|
reqId: request.id,
|
||||||
|
params: request.params
|
||||||
|
},
|
||||||
|
'incoming request /user/:username'
|
||||||
|
)
|
||||||
|
const { username } = request.params as any
|
||||||
|
const { status, data } = await proxy.getUser(username, {
|
||||||
|
reqId: request.id
|
||||||
|
})
|
||||||
|
reply.status(status).send(data)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
server.register((fastify: FastifyInstance, opts, done) => {
|
fastify.get(
|
||||||
|
`/user/:userId/tweets`,
|
||||||
|
{},
|
||||||
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const { userId } = request.params as any
|
||||||
|
const { cursor } = request.query as any
|
||||||
|
const { status, data } = await proxy.getUserTweets(userId, cursor, {
|
||||||
|
reqId: request.id
|
||||||
|
})
|
||||||
|
reply.status(status).send(data)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
fastify.get(`/user/:username`, {},
|
fastify.get(
|
||||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
`/tweet/:id`,
|
||||||
log.debug({
|
{},
|
||||||
headers: request.headers,
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
reqId: request.id,
|
const { id } = request.params as any
|
||||||
params: request.params }, 'incoming request /user/:username')
|
const { status, data } = await proxy.getTweetById(id, {
|
||||||
const { username } = request.params as any
|
reqId: request.id
|
||||||
const { status, data } = await proxy.getUser(username, { reqId: request.id })
|
})
|
||||||
reply.status(status).send(data)
|
reply.status(status).send(data)
|
||||||
});
|
}
|
||||||
|
)
|
||||||
|
|
||||||
fastify.get(`/user/:userId/tweets`, {},
|
done()
|
||||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
},
|
||||||
const { userId } = request.params as any
|
{ prefix: '/api' }
|
||||||
const { cursor } = request.query as any
|
)
|
||||||
const { status, data } = await proxy.getUserTweets(userId, cursor, { reqId: request.id })
|
|
||||||
reply.status(status).send(data)
|
|
||||||
});
|
|
||||||
|
|
||||||
fastify.get(`/tweet/:id`, {},
|
server.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
|
||||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
reply.status(404).send({ message: `Method not found` })
|
||||||
const { id } = request.params as any
|
})
|
||||||
const { status, data } = await proxy.getTweetById(id, { reqId: request.id })
|
|
||||||
reply.status(status).send(data)
|
|
||||||
});
|
|
||||||
|
|
||||||
done()
|
server.setErrorHandler(
|
||||||
|
(err: Error, request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const { log } = request
|
||||||
|
log.error(err)
|
||||||
|
// Send error response
|
||||||
|
reply.status(500).send({ message: `Internal server error` })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
}, { prefix: '/api' })
|
// await server.register(import('@fastify/rate-limit'), {
|
||||||
|
// max: 100,
|
||||||
|
// timeWindow: '1 minute'
|
||||||
|
// })
|
||||||
|
|
||||||
server.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
|
await server.listen({ port, host } as FastifyListenOptions)
|
||||||
reply.status(404)
|
|
||||||
.send({ message: `Method not found` })
|
|
||||||
})
|
|
||||||
|
|
||||||
server.setErrorHandler((err: Error, request: FastifyRequest, reply: FastifyReply) => {
|
|
||||||
const { log } = request
|
|
||||||
log.error(err)
|
|
||||||
// Send error response
|
|
||||||
reply.status(500).send({ message: `Internal server error` })
|
|
||||||
})
|
|
||||||
|
|
||||||
// await server.register(import('@fastify/rate-limit'), {
|
|
||||||
// max: 100,
|
|
||||||
// timeWindow: '1 minute'
|
|
||||||
// })
|
|
||||||
|
|
||||||
await server.listen({ port, host } as FastifyListenOptions);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch(err => {
|
main().catch((err) => {
|
||||||
log.fatal(err)
|
log.fatal(err)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,209 +1,226 @@
|
||||||
// noinspection TypeScriptUnresolvedReference
|
// noinspection TypeScriptUnresolvedReference
|
||||||
|
|
||||||
import axios from "axios"
|
import axios from 'axios'
|
||||||
import { AxiosInstance, AxiosRequestConfig } from "axios"
|
import { AxiosInstance, AxiosRequestConfig } from 'axios'
|
||||||
import fastq from "fastq"
|
import fastq from 'fastq'
|
||||||
import { Logger } from "pino"
|
import { Logger } from 'pino'
|
||||||
import retry from "axios-retry-after"
|
import retry from 'axios-retry-after'
|
||||||
import { LRUCache } from 'lru-cache'
|
import { LRUCache } from 'lru-cache'
|
||||||
|
|
||||||
const GET_USER_POSITIVE_TTL_MS = process.env.GET_USER_POSITIVE_TTL
|
const GET_USER_POSITIVE_TTL_MS = process.env.GET_USER_POSITIVE_TTL
|
||||||
? parseInt(process.env.GET_USER_POSITIVE_TTL, 10) * 1000
|
? parseInt(process.env.GET_USER_POSITIVE_TTL, 10) * 1000
|
||||||
: 30 * 24 * 3600 * 1000
|
: 30 * 24 * 3600 * 1000
|
||||||
const GET_USER_NEGATIVE_TTL_MS = process.env.GET_USER_NEGATIVE_TTL
|
const GET_USER_NEGATIVE_TTL_MS = process.env.GET_USER_NEGATIVE_TTL
|
||||||
? parseInt(process.env.GET_USER_NEGATIVE_TTL, 10) * 1000
|
? parseInt(process.env.GET_USER_NEGATIVE_TTL, 10) * 1000
|
||||||
: 3600 * 1000
|
: 3600 * 1000
|
||||||
const GET_TWEETS_POSITIVE_TTL_MS = process.env.GET_TWEETS_POSITIVE_TTL
|
const GET_TWEETS_POSITIVE_TTL_MS = process.env.GET_TWEETS_POSITIVE_TTL
|
||||||
? parseInt(process.env.GET_TWEETS_POSITIVE_TTL, 10) * 1000
|
? parseInt(process.env.GET_TWEETS_POSITIVE_TTL, 10) * 1000
|
||||||
: 60 * 1000
|
: 60 * 1000
|
||||||
const GET_TWEETS_NEGATIVE_TTL_MS = process.env.GET_TWEETS_NEGATIVE_TTL
|
const GET_TWEETS_NEGATIVE_TTL_MS = process.env.GET_TWEETS_NEGATIVE_TTL
|
||||||
? parseInt(process.env.GET_TWEETS_NEGATIVE_TTL, 10) * 1000
|
? parseInt(process.env.GET_TWEETS_NEGATIVE_TTL, 10) * 1000
|
||||||
: 60 * 1000
|
: 60 * 1000
|
||||||
const GET_TWEET_POSITIVE_TTL_MS = process.env.GET_TWEET_POSITIVE_TTL
|
const GET_TWEET_POSITIVE_TTL_MS = process.env.GET_TWEET_POSITIVE_TTL
|
||||||
? parseInt(process.env.GET_TWEET_POSITIVE_TTL, 10) * 1000
|
? parseInt(process.env.GET_TWEET_POSITIVE_TTL, 10) * 1000
|
||||||
: 60 * 1000
|
: 60 * 1000
|
||||||
const GET_TWEET_NEGATIVE_TTL_MS = process.env.GET_TWEET_NEGATIVE_TTL
|
const GET_TWEET_NEGATIVE_TTL_MS = process.env.GET_TWEET_NEGATIVE_TTL
|
||||||
? parseInt(process.env.GET_TWEET_NEGATIVE_TTL, 10) * 1000
|
? parseInt(process.env.GET_TWEET_NEGATIVE_TTL, 10) * 1000
|
||||||
: 60 * 1000
|
: 60 * 1000
|
||||||
|
|
||||||
export interface Job {
|
export interface Job {
|
||||||
reqId: string
|
reqId: string
|
||||||
url: string
|
url: string
|
||||||
params?: Record<string, any>
|
params?: Record<string, any>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JobResponse {
|
export interface JobResponse {
|
||||||
status: number,
|
status: number
|
||||||
data: any
|
data: any
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Proxy {
|
export class Proxy {
|
||||||
|
private readonly cache: LRUCache<string, JobResponse>
|
||||||
|
private readonly client: AxiosInstance
|
||||||
|
private readonly queue: fastq.queueAsPromised<Job, JobResponse>
|
||||||
|
private counter: { requests: number }
|
||||||
|
private timeWindowMillis = 15 * 60 * 1000
|
||||||
|
private maxRequestsPerAccount = 15 * 60
|
||||||
|
|
||||||
private readonly cache: LRUCache<string, JobResponse>
|
constructor(
|
||||||
private readonly client: AxiosInstance
|
private log: Logger,
|
||||||
private readonly queue: fastq.queueAsPromised<Job, JobResponse>
|
private baseUrl: string,
|
||||||
private counter: { requests: number }
|
private concurrency: number,
|
||||||
private timeWindowMillis = 15 * 60 * 1000
|
retryAfterMillis: number,
|
||||||
private maxRequestsPerAccount = 15 * 60
|
maxCacheSize: number
|
||||||
|
) {
|
||||||
constructor(
|
this.cache = new LRUCache({ max: maxCacheSize })
|
||||||
private log: Logger,
|
this.queue = fastq.promise(this, this.sendRequest, concurrency)
|
||||||
private baseUrl: string,
|
this.client = axios.create()
|
||||||
private concurrency: number,
|
this.counter = {
|
||||||
retryAfterMillis: number,
|
requests: 0
|
||||||
maxCacheSize: number
|
|
||||||
) {
|
|
||||||
this.cache = new LRUCache({ max: maxCacheSize })
|
|
||||||
this.queue = fastq.promise(this, this.sendRequest, concurrency)
|
|
||||||
this.client = axios.create()
|
|
||||||
this.counter = {
|
|
||||||
requests: 0
|
|
||||||
}
|
|
||||||
|
|
||||||
setInterval(() => {
|
|
||||||
this.counter.requests = 0
|
|
||||||
}, this.timeWindowMillis)
|
|
||||||
|
|
||||||
if ( retryAfterMillis ) {
|
|
||||||
this.client.interceptors.response.use(null, retry(this.client, {
|
|
||||||
// Determine when we should attempt to retry
|
|
||||||
isRetryable (error) {
|
|
||||||
log.debug({ status: error.response?.status, headers: error.response?.headers }, 'checking retryable')
|
|
||||||
return (
|
|
||||||
error.response && error.response.status === 429
|
|
||||||
// Use X-Retry-After rather than Retry-After, and cap retry delay at 60 seconds
|
|
||||||
// && error.response.headers['x-retry-after'] && error.response.headers['x-retry-after'] <= 60
|
|
||||||
)
|
|
||||||
},
|
|
||||||
// Customize the wait behavior
|
|
||||||
wait (error) {
|
|
||||||
log.debug({ status: error.response?.status, headers: error.response?.headers }, 'waiting for retry')
|
|
||||||
return new Promise(
|
|
||||||
// Use X-Retry-After rather than Retry-After
|
|
||||||
// resolve => setTimeout(resolve, error.response.headers['x-retry-after'])
|
|
||||||
resolve => setTimeout(resolve, retryAfterMillis)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUser(username: string, options?: { reqId?: string }) {
|
setInterval(() => {
|
||||||
const key = `usernames:${username}`
|
this.counter.requests = 0
|
||||||
|
}, this.timeWindowMillis)
|
||||||
|
|
||||||
if ( this.cache.has(key)) {
|
if (retryAfterMillis) {
|
||||||
return this.cache.get(key)
|
this.client.interceptors.response.use(
|
||||||
}
|
null,
|
||||||
|
retry(this.client, {
|
||||||
const result = await this.queue.push({
|
// Determine when we should attempt to retry
|
||||||
url: `/api/user/${ username }`,
|
isRetryable(error) {
|
||||||
reqId: options?.reqId
|
log.debug(
|
||||||
|
{
|
||||||
|
status: error.response?.status,
|
||||||
|
headers: error.response?.headers
|
||||||
|
},
|
||||||
|
'checking retryable'
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
error.response && error.response.status === 429
|
||||||
|
// Use X-Retry-After rather than Retry-After, and cap retry delay at 60 seconds
|
||||||
|
// && error.response.headers['x-retry-after'] && error.response.headers['x-retry-after'] <= 60
|
||||||
|
)
|
||||||
|
},
|
||||||
|
// Customize the wait behavior
|
||||||
|
wait(error) {
|
||||||
|
log.debug(
|
||||||
|
{
|
||||||
|
status: error.response?.status,
|
||||||
|
headers: error.response?.headers
|
||||||
|
},
|
||||||
|
'waiting for retry'
|
||||||
|
)
|
||||||
|
return new Promise(
|
||||||
|
// Use X-Retry-After rather than Retry-After
|
||||||
|
// resolve => setTimeout(resolve, error.response.headers['x-retry-after'])
|
||||||
|
(resolve) => setTimeout(resolve, retryAfterMillis)
|
||||||
|
)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ( result.status === 200 ) {
|
async getUser(username: string, options?: { reqId?: string }) {
|
||||||
this.cache.set(key, result, { ttl: GET_USER_POSITIVE_TTL_MS })
|
const key = `usernames:${username}`
|
||||||
}
|
|
||||||
if ( result.status === 404 ) {
|
|
||||||
this.cache.set(key, result, { ttl: GET_USER_NEGATIVE_TTL_MS })
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
if (this.cache.has(key)) {
|
||||||
|
return this.cache.get(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserTweets(userId: string, cursor?: string, options?: { reqId?: string }) {
|
const result = await this.queue.push({
|
||||||
const key = `users:${userId}:tweets:${cursor ?? 'last'}`
|
url: `/api/user/${username}`,
|
||||||
|
reqId: options?.reqId
|
||||||
|
})
|
||||||
|
|
||||||
if ( this.cache.has(key) ) {
|
if (result.status === 200) {
|
||||||
return this.cache.get(key)
|
this.cache.set(key, result, { ttl: GET_USER_POSITIVE_TTL_MS })
|
||||||
}
|
}
|
||||||
|
if (result.status === 404) {
|
||||||
const result = await this.queue.push({
|
this.cache.set(key, result, { ttl: GET_USER_NEGATIVE_TTL_MS })
|
||||||
url: `/api/user/${ userId }/tweets`,
|
|
||||||
params: { cursor },
|
|
||||||
reqId: options?.reqId
|
|
||||||
})
|
|
||||||
|
|
||||||
if ( result.status === 200 ) {
|
|
||||||
this.cache.set(key, result, { ttl: GET_TWEETS_POSITIVE_TTL_MS })
|
|
||||||
}
|
|
||||||
if ( result.status === 404 ) {
|
|
||||||
this.cache.set(key, result, { ttl: GET_TWEETS_NEGATIVE_TTL_MS })
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTweetById(tweetId: string, options?: { reqId?: string }) {
|
return result
|
||||||
const key = `tweets:${tweetId}`
|
}
|
||||||
|
|
||||||
if ( this.cache.has(key) ) {
|
async getUserTweets(
|
||||||
return this.cache.get(key)
|
userId: string,
|
||||||
}
|
cursor?: string,
|
||||||
|
options?: { reqId?: string }
|
||||||
|
) {
|
||||||
|
const key = `users:${userId}:tweets:${cursor ?? 'last'}`
|
||||||
|
|
||||||
const result = await this.queue.push({
|
if (this.cache.has(key)) {
|
||||||
url: `/api/tweet/${ tweetId }`,
|
return this.cache.get(key)
|
||||||
reqId: options?.reqId
|
|
||||||
})
|
|
||||||
|
|
||||||
if ( result.status === 200 ) {
|
|
||||||
this.cache.set(key, result, { ttl: GET_TWEET_POSITIVE_TTL_MS })
|
|
||||||
}
|
|
||||||
if ( result.status === 404 ) {
|
|
||||||
this.cache.set(key, result, { ttl: GET_TWEET_NEGATIVE_TTL_MS })
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async sendRequest(job: Job): Promise<any> {
|
const result = await this.queue.push({
|
||||||
|
url: `/api/user/${userId}/tweets`,
|
||||||
|
params: { cursor },
|
||||||
|
reqId: options?.reqId
|
||||||
|
})
|
||||||
|
|
||||||
const { reqId, url, params } = job
|
if (result.status === 200) {
|
||||||
|
this.cache.set(key, result, { ttl: GET_TWEETS_POSITIVE_TTL_MS })
|
||||||
if ( this.counter.requests > this.concurrency * this.maxRequestsPerAccount ) {
|
|
||||||
return {
|
|
||||||
status: 429
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let config = {
|
|
||||||
url,
|
|
||||||
method: "get",
|
|
||||||
baseURL: this.baseUrl,
|
|
||||||
params,
|
|
||||||
} as AxiosRequestConfig
|
|
||||||
|
|
||||||
this.log.trace({ config, reqId: reqId }, 'sending request to nitter')
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await this.client.request(config)
|
|
||||||
|
|
||||||
this.log.trace({
|
|
||||||
status: response.status,
|
|
||||||
data: response.data,
|
|
||||||
reqId: reqId
|
|
||||||
}, 'nitter response')
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: response.status,
|
|
||||||
data: response.data,
|
|
||||||
} as JobResponse
|
|
||||||
|
|
||||||
} catch(err) {
|
|
||||||
|
|
||||||
this.log.warn({ err, reqId }, 'nitter error')
|
|
||||||
|
|
||||||
if ( err.name === "AxiosError" ) {
|
|
||||||
|
|
||||||
this.counter.requests = Number.MAX_SAFE_INTEGER
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: 429
|
|
||||||
} as JobResponse
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: 500
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (result.status === 404) {
|
||||||
|
this.cache.set(key, result, { ttl: GET_TWEETS_NEGATIVE_TTL_MS })
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTweetById(tweetId: string, options?: { reqId?: string }) {
|
||||||
|
const key = `tweets:${tweetId}`
|
||||||
|
|
||||||
|
if (this.cache.has(key)) {
|
||||||
|
return this.cache.get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.queue.push({
|
||||||
|
url: `/api/tweet/${tweetId}`,
|
||||||
|
reqId: options?.reqId
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.status === 200) {
|
||||||
|
this.cache.set(key, result, { ttl: GET_TWEET_POSITIVE_TTL_MS })
|
||||||
|
}
|
||||||
|
if (result.status === 404) {
|
||||||
|
this.cache.set(key, result, { ttl: GET_TWEET_NEGATIVE_TTL_MS })
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendRequest(job: Job): Promise<any> {
|
||||||
|
const { reqId, url, params } = job
|
||||||
|
|
||||||
|
if (this.counter.requests > this.concurrency * this.maxRequestsPerAccount) {
|
||||||
|
return {
|
||||||
|
status: 429
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = {
|
||||||
|
url,
|
||||||
|
method: 'get',
|
||||||
|
baseURL: this.baseUrl,
|
||||||
|
params
|
||||||
|
} as AxiosRequestConfig
|
||||||
|
|
||||||
|
this.log.trace({ config, reqId: reqId }, 'sending request to nitter')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.client.request(config)
|
||||||
|
|
||||||
|
this.log.trace(
|
||||||
|
{
|
||||||
|
status: response.status,
|
||||||
|
data: response.data,
|
||||||
|
reqId: reqId
|
||||||
|
},
|
||||||
|
'nitter response'
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: response.status,
|
||||||
|
data: response.data
|
||||||
|
} as JobResponse
|
||||||
|
} catch (err) {
|
||||||
|
this.log.warn({ err, reqId }, 'nitter error')
|
||||||
|
|
||||||
|
if (err.name === 'AxiosError') {
|
||||||
|
this.counter.requests = Number.MAX_SAFE_INTEGER
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 429
|
||||||
|
} as JobResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
58
proxy/src/types.d.ts
vendored
58
proxy/src/types.d.ts
vendored
|
@ -1,39 +1,37 @@
|
||||||
declare module 'axios-retry-after' {
|
declare module 'axios-retry-after' {
|
||||||
|
import { AxiosError, AxiosInstance } from 'axios'
|
||||||
|
|
||||||
import { AxiosError, AxiosInstance } from "axios";
|
/**
|
||||||
|
* Function to enhance Axios instance with retry-after functionality.
|
||||||
|
* @param axios Axios instance to be enhanced.
|
||||||
|
* @param options Configuration options for retry behavior.
|
||||||
|
*/
|
||||||
|
export default function (
|
||||||
|
axios: AxiosInstance,
|
||||||
|
options?: AxiosRetryAfterOptions
|
||||||
|
): (error: AxiosError) => Promise<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration options for axios-retry-after.
|
||||||
|
*/
|
||||||
|
export interface AxiosRetryAfterOptions {
|
||||||
|
/**
|
||||||
|
* Function to determine if an error response is retryable.
|
||||||
|
* @param error The Axios error to evaluate.
|
||||||
|
*/
|
||||||
|
isRetryable?: (error: AxiosError) => boolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Function to enhance Axios instance with retry-after functionality.
|
* Function to wait for a specified amount of time.
|
||||||
* @param axios Axios instance to be enhanced.
|
* @param error The Axios error that contains retry-after header.
|
||||||
* @param options Configuration options for retry behavior.
|
|
||||||
*/
|
*/
|
||||||
export default function(
|
wait?: (error: AxiosError) => Promise<void>
|
||||||
axios: AxiosInstance,
|
|
||||||
options?: AxiosRetryAfterOptions
|
|
||||||
): (error: AxiosError) => Promise<void>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration options for axios-retry-after.
|
* Function to retry the original request.
|
||||||
|
* @param axios The Axios instance used for retrying the request.
|
||||||
|
* @param error The Axios error to retry.
|
||||||
*/
|
*/
|
||||||
export interface AxiosRetryAfterOptions {
|
retry?: (axios: AxiosInstance, error: AxiosError) => Promise<any>
|
||||||
/**
|
}
|
||||||
* Function to determine if an error response is retryable.
|
|
||||||
* @param error The Axios error to evaluate.
|
|
||||||
*/
|
|
||||||
isRetryable?: (error: AxiosError) => boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Function to wait for a specified amount of time.
|
|
||||||
* @param error The Axios error that contains retry-after header.
|
|
||||||
*/
|
|
||||||
wait?: (error: AxiosError) => Promise<void>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Function to retry the original request.
|
|
||||||
* @param axios The Axios instance used for retrying the request.
|
|
||||||
* @param error The Axios error to retry.
|
|
||||||
*/
|
|
||||||
retry?: (axios: AxiosInstance, error: AxiosError) => Promise<any>;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -12,7 +12,5 @@
|
||||||
"outDir": "./build"
|
"outDir": "./build"
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": [
|
"exclude": ["node_modules"]
|
||||||
"node_modules"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
6
public/css/baguetteBox.min.css
vendored
Normal file
6
public/css/baguetteBox.min.css
vendored
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
/*!
|
||||||
|
* baguetteBox.js
|
||||||
|
* @author feimosi
|
||||||
|
* @version 1.11.1
|
||||||
|
* @url https://github.com/feimosi/baguetteBox.js
|
||||||
|
*/#baguetteBox-overlay{display:none;opacity:0;position:fixed;overflow:hidden;top:0;left:0;width:100%;height:100%;z-index:1000000;background-color:#222;background-color:rgba(0,0,0,.8);-webkit-transition:opacity .5s ease;transition:opacity .5s ease}#baguetteBox-overlay.visible{opacity:1}#baguetteBox-overlay .full-image{display:inline-block;position:relative;width:100%;height:100%;text-align:center}#baguetteBox-overlay .full-image figure{display:inline;margin:0;height:100%}#baguetteBox-overlay .full-image img{display:inline-block;width:auto;height:auto;max-height:100%;max-width:100%;vertical-align:middle;-webkit-box-shadow:0 0 8px rgba(0,0,0,.6);-moz-box-shadow:0 0 8px rgba(0,0,0,.6);box-shadow:0 0 8px rgba(0,0,0,.6)}#baguetteBox-overlay .full-image figcaption{display:block;position:absolute;bottom:0;width:100%;text-align:center;line-height:1.8;white-space:normal;color:#ccc;background-color:#000;background-color:rgba(0,0,0,.6);font-family:sans-serif}#baguetteBox-overlay .full-image:before{content:"";display:inline-block;height:50%;width:1px;margin-right:-1px}#baguetteBox-slider{position:absolute;left:0;top:0;height:100%;width:100%;white-space:nowrap;-webkit-transition:left .4s ease,-webkit-transform .4s ease;transition:left .4s ease,-webkit-transform .4s ease;transition:left .4s ease,transform .4s ease;transition:left .4s ease,transform .4s ease,-webkit-transform .4s ease,-moz-transform .4s ease}#baguetteBox-slider.bounce-from-right{-webkit-animation:bounceFromRight .4s ease-out;animation:bounceFromRight .4s ease-out}#baguetteBox-slider.bounce-from-left{-webkit-animation:bounceFromLeft .4s ease-out;animation:bounceFromLeft .4s ease-out}@-webkit-keyframes bounceFromRight{0%,100%{margin-left:0}50%{margin-left:-30px}}@keyframes bounceFromRight{0%,100%{margin-left:0}50%{margin-left:-30px}}@-webkit-keyframes bounceFromLeft{0%,100%{margin-left:0}50%{margin-left:30px}}@keyframes bounceFromLeft{0%,100%{margin-left:0}50%{margin-left:30px}}.baguetteBox-button#next-button,.baguetteBox-button#previous-button{top:50%;top:calc(50% - 30px);width:44px;height:60px}.baguetteBox-button{position:absolute;cursor:pointer;outline:0;padding:0;margin:0;border:0;-moz-border-radius:15%;border-radius:15%;background-color:#323232;background-color:rgba(50,50,50,.5);color:#ddd;font:1.6em sans-serif;-webkit-transition:background-color .4s ease;transition:background-color .4s ease}.baguetteBox-button:focus,.baguetteBox-button:hover{background-color:rgba(50,50,50,.9)}.baguetteBox-button#next-button{right:2%}.baguetteBox-button#previous-button{left:2%}.baguetteBox-button#close-button{top:20px;right:2%;right:calc(2% + 6px);width:30px;height:30px}.baguetteBox-button svg{position:absolute;left:0;top:0}.baguetteBox-spinner{width:40px;height:40px;display:inline-block;position:absolute;top:50%;left:50%;margin-top:-20px;margin-left:-20px}.baguetteBox-double-bounce1,.baguetteBox-double-bounce2{width:100%;height:100%;-moz-border-radius:50%;border-radius:50%;background-color:#fff;opacity:.6;position:absolute;top:0;left:0;-webkit-animation:bounce 2s infinite ease-in-out;animation:bounce 2s infinite ease-in-out}.baguetteBox-double-bounce2{-webkit-animation-delay:-1s;animation-delay:-1s}@-webkit-keyframes bounce{0%,100%{-webkit-transform:scale(0);transform:scale(0)}50%{-webkit-transform:scale(1);transform:scale(1)}}@keyframes bounce{0%,100%{-webkit-transform:scale(0);-moz-transform:scale(0);transform:scale(0)}50%{-webkit-transform:scale(1);-moz-transform:scale(1);transform:scale(1)}}
|
7
public/js/baguetteBox.min.js
vendored
Normal file
7
public/js/baguetteBox.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
3
public/js/zoom.js
Normal file
3
public/js/zoom.js
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
window.addEventListener("load", function () {
|
||||||
|
baguetteBox.run(".attachments");
|
||||||
|
});
|
BIN
screenshot.png
BIN
screenshot.png
Binary file not shown.
Before Width: | Height: | Size: 957 KiB After Width: | Height: | Size: 797 KiB |
|
@ -21,6 +21,9 @@ let
|
||||||
# Images aren't supported due to errors from Teddit when the image
|
# Images aren't supported due to errors from Teddit when the image
|
||||||
# wasn't first displayed via a post on the Teddit instance.
|
# wasn't first displayed via a post on the Teddit instance.
|
||||||
|
|
||||||
|
imgurRegex = re"((i|i.stack)\.)?imgur\.(com|io)"
|
||||||
|
mediumRegex = re"([a-zA-Z0-9_.-]+\.)?medium\.com"
|
||||||
|
|
||||||
wwwRegex = re"https?://(www[0-9]?\.)?"
|
wwwRegex = re"https?://(www[0-9]?\.)?"
|
||||||
m3u8Regex = re"""url="(.+.m3u8)""""
|
m3u8Regex = re"""url="(.+.m3u8)""""
|
||||||
userPicRegex = re"_(normal|bigger|mini|200x200|400x400)(\.[A-z]+)$"
|
userPicRegex = re"_(normal|bigger|mini|200x200|400x400)(\.[A-z]+)$"
|
||||||
|
@ -69,6 +72,12 @@ proc replaceUrls*(body: string; prefs: Prefs; absolute=""): string =
|
||||||
if prefs.replaceReddit in result and "/gallery/" in result:
|
if prefs.replaceReddit in result and "/gallery/" in result:
|
||||||
result = result.replace("/gallery/", "/comments/")
|
result = result.replace("/gallery/", "/comments/")
|
||||||
|
|
||||||
|
if prefs.replaceImgur.len > 0 and "imgur" in result:
|
||||||
|
result = result.replace(imgurRegex, prefs.replaceImgur)
|
||||||
|
|
||||||
|
if prefs.replaceMedium.len > 0 and "medium.com" in result:
|
||||||
|
result = result.replace(mediumRegex, prefs.replaceMedium)
|
||||||
|
|
||||||
if absolute.len > 0 and "href" in result:
|
if absolute.len > 0 and "href" in result:
|
||||||
result = result.replace("href=\"/", &"href=\"{absolute}/")
|
result = result.replace("href=\"/", &"href=\"{absolute}/")
|
||||||
|
|
||||||
|
@ -82,6 +91,8 @@ proc proxifyVideo*(manifest: string; proxy: bool): string =
|
||||||
for line in manifest.splitLines:
|
for line in manifest.splitLines:
|
||||||
let url =
|
let url =
|
||||||
if line.startsWith("#EXT-X-MAP:URI"): line[16 .. ^2]
|
if line.startsWith("#EXT-X-MAP:URI"): line[16 .. ^2]
|
||||||
|
elif line.startsWith("#EXT-X-MEDIA") and "URI=" in line:
|
||||||
|
line[line.find("URI=") + 5 .. -1 + line.find("\"", start= 5 + line.find("URI="))]
|
||||||
else: line
|
else: line
|
||||||
if url.startsWith('/'):
|
if url.startsWith('/'):
|
||||||
let path = "https://video.twimg.com" & url
|
let path = "https://video.twimg.com" & url
|
||||||
|
|
|
@ -11,7 +11,7 @@ import types, config, prefs, formatters, redis_cache, http_pool, auth
|
||||||
import views/[general, about]
|
import views/[general, about]
|
||||||
import routes/[
|
import routes/[
|
||||||
preferences, timeline, status, media, search, rss, list, debug,
|
preferences, timeline, status, media, search, rss, list, debug,
|
||||||
unsupported, embed, resolver, router_utils]
|
unsupported, embed, resolver, router_utils, home,follow]
|
||||||
|
|
||||||
const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
|
const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
|
||||||
const issuesUrl = "https://github.com/zedeus/nitter/issues"
|
const issuesUrl = "https://github.com/zedeus/nitter/issues"
|
||||||
|
@ -60,9 +60,6 @@ settings:
|
||||||
reusePort = true
|
reusePort = true
|
||||||
|
|
||||||
routes:
|
routes:
|
||||||
get "/":
|
|
||||||
resp renderMain(renderSearch(), request, cfg, themePrefs())
|
|
||||||
|
|
||||||
get "/about":
|
get "/about":
|
||||||
resp renderMain(renderAbout(), request, cfg, themePrefs())
|
resp renderMain(renderAbout(), request, cfg, themePrefs())
|
||||||
|
|
||||||
|
@ -94,7 +91,9 @@ routes:
|
||||||
const link = a("another instance", href = instancesUrl)
|
const link = a("another instance", href = instancesUrl)
|
||||||
resp Http429, showError(
|
resp Http429, showError(
|
||||||
&"Instance has been rate limited.<br>Use {link} or try again later.", cfg)
|
&"Instance has been rate limited.<br>Use {link} or try again later.", cfg)
|
||||||
|
|
||||||
|
extend home, ""
|
||||||
|
extend follow, ""
|
||||||
extend rss, ""
|
extend rss, ""
|
||||||
extend status, ""
|
extend status, ""
|
||||||
extend search, ""
|
extend search, ""
|
||||||
|
|
|
@ -50,6 +50,11 @@ macro genPrefs*(prefDsl: untyped) =
|
||||||
const `name`*: PrefList = toOrderedTable(`table`)
|
const `name`*: PrefList = toOrderedTable(`table`)
|
||||||
|
|
||||||
genPrefs:
|
genPrefs:
|
||||||
|
Timeline:
|
||||||
|
following(input, ""):
|
||||||
|
"A comma-separated list of users to follow."
|
||||||
|
placeholder: "taskylizard,vercel,nodejs"
|
||||||
|
|
||||||
Display:
|
Display:
|
||||||
theme(select, "Nitter"):
|
theme(select, "Nitter"):
|
||||||
"Theme"
|
"Theme"
|
||||||
|
@ -107,6 +112,14 @@ genPrefs:
|
||||||
"Reddit -> Teddit/Libreddit"
|
"Reddit -> Teddit/Libreddit"
|
||||||
placeholder: "Teddit hostname"
|
placeholder: "Teddit hostname"
|
||||||
|
|
||||||
|
replaceImgur(input, ""):
|
||||||
|
"Imgur -> Rimgo"
|
||||||
|
placeholder: "Rimgo hostname"
|
||||||
|
|
||||||
|
replaceMedium(input, ""):
|
||||||
|
"Medium -> Scribe"
|
||||||
|
placeholder: "Scribe hostname"
|
||||||
|
|
||||||
iterator allPrefs*(): Pref =
|
iterator allPrefs*(): Pref =
|
||||||
for k, v in prefList:
|
for k, v in prefList:
|
||||||
for pref in v:
|
for pref in v:
|
||||||
|
|
42
src/routes/follow.nim
Normal file
42
src/routes/follow.nim
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import jester, asyncdispatch, strutils, sequtils
|
||||||
|
import router_utils
|
||||||
|
import ../types
|
||||||
|
|
||||||
|
export follow
|
||||||
|
|
||||||
|
proc addUserToFollowing*(following, toAdd: string): string =
|
||||||
|
var updated = following.split(",")
|
||||||
|
if updated == @[""]:
|
||||||
|
return toAdd
|
||||||
|
elif toAdd in updated:
|
||||||
|
return following
|
||||||
|
else:
|
||||||
|
updated = concat(updated, @[toAdd])
|
||||||
|
result = updated.join(",")
|
||||||
|
|
||||||
|
proc removeUserFromFollowing*(following, remove: string): string =
|
||||||
|
var updated = following.split(",")
|
||||||
|
if updated == @[""]:
|
||||||
|
return ""
|
||||||
|
else:
|
||||||
|
updated = filter(updated, proc(x: string): bool = x != remove)
|
||||||
|
result = updated.join(",")
|
||||||
|
|
||||||
|
proc createFollowRouter*(cfg: Config) =
|
||||||
|
router follow:
|
||||||
|
post "/follow/@name":
|
||||||
|
let
|
||||||
|
following = cookiePrefs().following
|
||||||
|
toAdd = @"name"
|
||||||
|
updated = addUserToFollowing(following, toAdd)
|
||||||
|
setCookie("following", updated, daysForward(if isEmptyOrWhitespace(updated): -10 else: 360),
|
||||||
|
httpOnly=true, secure=cfg.useHttps, path="/")
|
||||||
|
redirect(refPath())
|
||||||
|
post "/unfollow/@name":
|
||||||
|
let
|
||||||
|
following = cookiePrefs().following
|
||||||
|
remove = @"name"
|
||||||
|
updated = removeUserFromFollowing(following, remove)
|
||||||
|
setCookie("following", updated, daysForward(360),
|
||||||
|
httpOnly=true, secure=cfg.useHttps, path="/")
|
||||||
|
redirect(refPath())
|
49
src/routes/home.nim
Normal file
49
src/routes/home.nim
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
import jester
|
||||||
|
import asyncdispatch, strutils, options, router_utils, timeline
|
||||||
|
import ".."/[prefs, types, utils, redis_cache]
|
||||||
|
import ../views/[general, home, search]
|
||||||
|
|
||||||
|
export home
|
||||||
|
|
||||||
|
proc showHome*(request: Request; query: Query; cfg: Config; prefs: Prefs;
|
||||||
|
after: string): Future[string] {.async.} =
|
||||||
|
let
|
||||||
|
timeline = await getGraphTweetSearch(query, after)
|
||||||
|
html = renderHome(timeline, prefs, getPath())
|
||||||
|
return renderMain(html, request, cfg, prefs)
|
||||||
|
|
||||||
|
proc createHomeRouter*(cfg: Config) =
|
||||||
|
router home:
|
||||||
|
get "/":
|
||||||
|
let
|
||||||
|
prefs = cookiePrefs()
|
||||||
|
after = getCursor()
|
||||||
|
names = getNames(prefs.following)
|
||||||
|
|
||||||
|
var query = request.getQuery("", prefs.following)
|
||||||
|
query.fromUser = names
|
||||||
|
|
||||||
|
if @"scroll".len > 0:
|
||||||
|
var timeline = await getGraphTweetSearch(query, after)
|
||||||
|
if timeline.content.len == 0: resp Http404
|
||||||
|
timeline.beginning = true
|
||||||
|
resp $renderHome(timeline, prefs, getPath())
|
||||||
|
|
||||||
|
if names.len == 0:
|
||||||
|
resp renderMain(renderSearch(), request, cfg, themePrefs())
|
||||||
|
resp (await showHome(request, query, cfg, prefs, after))
|
||||||
|
get "/following":
|
||||||
|
let
|
||||||
|
prefs = cookiePrefs()
|
||||||
|
names = getNames(prefs.following)
|
||||||
|
var
|
||||||
|
profs: seq[User]
|
||||||
|
query = request.getQuery("", prefs.following)
|
||||||
|
query.fromUser = names
|
||||||
|
query.kind = userList
|
||||||
|
|
||||||
|
for name in names:
|
||||||
|
let prof = await getCachedUser(name)
|
||||||
|
profs &= @[prof]
|
||||||
|
|
||||||
|
resp renderMain(renderFollowing(query, profs, prefs), request, cfg, prefs)
|
|
@ -1,130 +1,139 @@
|
||||||
@import '_variables';
|
@import "_variables";
|
||||||
@import '_mixins';
|
@import "_mixins";
|
||||||
|
|
||||||
.profile-card {
|
.profile-card {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
background: var(--bg_panel);
|
background: var(--bg_panel);
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-card-info {
|
.profile-card-info {
|
||||||
@include breakable;
|
@include breakable;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-card-tabs-name {
|
.profile-card-tabs-name-and-follow {
|
||||||
@include breakable;
|
@include breakable;
|
||||||
max-width: 100%;
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card-follow-button {
|
||||||
|
float: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-card-username {
|
.profile-card-username {
|
||||||
@include breakable;
|
@include breakable;
|
||||||
color: var(--fg_color);
|
color: var(--fg_color);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-card-fullname {
|
.profile-card-fullname {
|
||||||
@include breakable;
|
@include breakable;
|
||||||
color: var(--fg_color);
|
color: var(--fg_color);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-shadow: none;
|
text-shadow: none;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-card-avatar {
|
.profile-card-avatar {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
margin-right: 4px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
margin-top: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-right: 4px;
|
height: 100%;
|
||||||
margin-bottom: 6px;
|
border: 4px solid var(--darker_grey);
|
||||||
|
background: var(--bg_panel);
|
||||||
&:after {
|
}
|
||||||
content: '';
|
|
||||||
display: block;
|
|
||||||
margin-top: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
box-sizing: border-box;
|
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border: 4px solid var(--darker_grey);
|
|
||||||
background: var(--bg_panel);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-card-extra {
|
.profile-card-extra {
|
||||||
display: contents;
|
display: contents;
|
||||||
flex: 100%;
|
flex: 100%;
|
||||||
margin-top: 7px;
|
margin-top: 7px;
|
||||||
|
|
||||||
.profile-bio {
|
.profile-bio {
|
||||||
@include breakable;
|
@include breakable;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 4px -6px 6px 0;
|
margin: 4px -6px 6px 0;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.profile-joindate, .profile-location, .profile-website {
|
.profile-joindate,
|
||||||
color: var(--fg_faded);
|
.profile-location,
|
||||||
margin: 1px 0;
|
.profile-website {
|
||||||
width: 100%;
|
color: var(--fg_faded);
|
||||||
}
|
margin: 1px 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-card-extra-links {
|
.profile-card-extra-links {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-statlist {
|
.profile-statlist {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
||||||
li {
|
li {
|
||||||
display: table-cell;
|
display: table-cell;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-stat-header {
|
.profile-stat-header {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: var(--profile_stat);
|
color: var(--profile_stat);
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-stat-num {
|
.profile-stat-num {
|
||||||
display: block;
|
display: block;
|
||||||
color: var(--profile_stat);
|
color: var(--profile_stat);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media(max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.profile-card-info {
|
.profile-card-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-card-tabs-name {
|
.profile-card-tabs-name {
|
||||||
flex-shrink: 100;
|
flex-shrink: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-card-avatar {
|
.profile-card-avatar {
|
||||||
width: 80px;
|
width: 98px;
|
||||||
height: 80px;
|
height: auto;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
border-width: 2px;
|
border-width: 2px;
|
||||||
width: unset;
|
width: unset;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -52,8 +52,11 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||||
let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
|
let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
|
||||||
|
|
||||||
buildHtml(head):
|
buildHtml(head):
|
||||||
link(rel="stylesheet", type="text/css", href="/css/style.css?v=19")
|
link(rel="stylesheet", type="text/css", href="/css/style.css?v=20")
|
||||||
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=2")
|
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=2")
|
||||||
|
link(rel="stylesheet", href="/css/baguetteBox.min.css")
|
||||||
|
script(src="/js/baguetteBox.min.js", `async`="")
|
||||||
|
script(src="/js/zoom.js")
|
||||||
|
|
||||||
if theme.len > 0:
|
if theme.len > 0:
|
||||||
link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css"))
|
link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css"))
|
||||||
|
|
32
src/views/home.nim
Normal file
32
src/views/home.nim
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import karax/[karaxdsl, vdom]
|
||||||
|
import search, timeline, renderutils
|
||||||
|
import ../types
|
||||||
|
|
||||||
|
proc renderFollowingUsers*(results: seq[User]; prefs: Prefs): VNode =
|
||||||
|
buildHtml(tdiv(class="timeline")):
|
||||||
|
for user in results:
|
||||||
|
renderUser(user, prefs)
|
||||||
|
|
||||||
|
proc renderHomeTabs*(query: Query): VNode =
|
||||||
|
buildHtml(ul(class="tab")):
|
||||||
|
li(class=query.getTabClass(posts)):
|
||||||
|
a(href="/"): text "Tweets"
|
||||||
|
li(class=query.getTabClass(userList)):
|
||||||
|
a(href=("/following")): text "Following"
|
||||||
|
|
||||||
|
proc renderHome*(results: Timeline; prefs: Prefs; path: string): VNode =
|
||||||
|
let query = results.query
|
||||||
|
buildHtml(tdiv(class="timeline-container")):
|
||||||
|
if query.fromUser.len > 0:
|
||||||
|
renderHomeTabs(query)
|
||||||
|
|
||||||
|
if query.fromUser.len == 0 or query.kind == tweets:
|
||||||
|
tdiv(class="timeline-header"):
|
||||||
|
renderSearchPanel(query)
|
||||||
|
|
||||||
|
renderTimelineTweets(results, prefs, path)
|
||||||
|
|
||||||
|
proc renderFollowing*(query: Query; following: seq[User]; prefs: Prefs): VNode =
|
||||||
|
buildHtml(tdiv(class="timeline-container")):
|
||||||
|
renderHomeTabs(query)
|
||||||
|
renderFollowingUsers(following, prefs)
|
|
@ -12,8 +12,8 @@ proc renderStat(num: int; class: string; text=""): VNode =
|
||||||
span(class="profile-stat-num"):
|
span(class="profile-stat-num"):
|
||||||
text insertSep($num, ',')
|
text insertSep($num, ',')
|
||||||
|
|
||||||
proc renderUserCard*(user: User; prefs: Prefs): VNode =
|
proc renderUserCard*(user: User; prefs: Prefs; path: string): VNode =
|
||||||
buildHtml(tdiv(class="profile-card")):
|
buildHtml(tdiv(class="profile-card", "data-profile-id" = $user.id)):
|
||||||
tdiv(class="profile-card-info"):
|
tdiv(class="profile-card-info"):
|
||||||
let
|
let
|
||||||
url = getPicUrl(user.getUserPic())
|
url = getPicUrl(user.getUserPic())
|
||||||
|
@ -24,9 +24,15 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
|
||||||
a(class="profile-card-avatar", href=url, target="_blank"):
|
a(class="profile-card-avatar", href=url, target="_blank"):
|
||||||
genImg(user.getUserPic(size))
|
genImg(user.getUserPic(size))
|
||||||
|
|
||||||
tdiv(class="profile-card-tabs-name"):
|
tdiv(class="profile-card-tabs-name-and-follow"):
|
||||||
linkUser(user, class="profile-card-fullname")
|
tdiv():
|
||||||
linkUser(user, class="profile-card-username")
|
linkUser(user, class="profile-card-fullname")
|
||||||
|
linkUser(user, class="profile-card-username")
|
||||||
|
let following = isFollowing(user.username, prefs.following)
|
||||||
|
if not following:
|
||||||
|
buttonReferer "/follow/" & user.username, "Follow", path, "profile-card-follow-button"
|
||||||
|
else:
|
||||||
|
buttonReferer "/unfollow/" & user.username, "Unfollow", path, "profile-card-follow-button"
|
||||||
|
|
||||||
tdiv(class="profile-card-extra"):
|
tdiv(class="profile-card-extra"):
|
||||||
if user.bio.len > 0:
|
if user.bio.len > 0:
|
||||||
|
@ -113,7 +119,7 @@ proc renderProfile*(profile: var Profile; cfg: Config; prefs: Prefs; path: strin
|
||||||
|
|
||||||
let sticky = if prefs.stickyProfile: " sticky" else: ""
|
let sticky = if prefs.stickyProfile: " sticky" else: ""
|
||||||
tdiv(class=("profile-tab" & sticky)):
|
tdiv(class=("profile-tab" & sticky)):
|
||||||
renderUserCard(profile.user, prefs)
|
renderUserCard(profile.user, prefs, path)
|
||||||
if profile.photoRail.len > 0:
|
if profile.photoRail.len > 0:
|
||||||
renderPhotoRail(profile)
|
renderPhotoRail(profile)
|
||||||
|
|
||||||
|
|
|
@ -100,3 +100,7 @@ proc getTabClass*(query: Query; tab: QueryKind): string =
|
||||||
proc getAvatarClass*(prefs: Prefs): string =
|
proc getAvatarClass*(prefs: Prefs): string =
|
||||||
if prefs.squareAvatars: "avatar"
|
if prefs.squareAvatars: "avatar"
|
||||||
else: "avatar round"
|
else: "avatar round"
|
||||||
|
|
||||||
|
proc isFollowing*(name, following: string): bool =
|
||||||
|
let following = following.split(",")
|
||||||
|
return name in following
|
||||||
|
|
|
@ -33,10 +33,6 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
|
||||||
#let urlPrefix = getUrlPrefix(cfg)
|
#let urlPrefix = getUrlPrefix(cfg)
|
||||||
#let text = replaceUrls(tweet.text, defaultPrefs, absolute=urlPrefix)
|
#let text = replaceUrls(tweet.text, defaultPrefs, absolute=urlPrefix)
|
||||||
<p>${text.replace("\n", "<br>\n")}</p>
|
<p>${text.replace("\n", "<br>\n")}</p>
|
||||||
#if tweet.quote.isSome and get(tweet.quote).available:
|
|
||||||
# let quoteLink = getLink(get(tweet.quote))
|
|
||||||
<p><a href="${urlPrefix}${quoteLink}">${cfg.hostname}${quoteLink}</a></p>
|
|
||||||
#end if
|
|
||||||
#if tweet.photos.len > 0:
|
#if tweet.photos.len > 0:
|
||||||
# for photo in tweet.photos:
|
# for photo in tweet.photos:
|
||||||
<img src="${urlPrefix}${getPicUrl(photo)}" style="max-width:250px;" />
|
<img src="${urlPrefix}${getPicUrl(photo)}" style="max-width:250px;" />
|
||||||
|
@ -54,6 +50,12 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
|
||||||
<img src="${urlPrefix}${getPicUrl(card.image)}" style="max-width:250px;" />
|
<img src="${urlPrefix}${getPicUrl(card.image)}" style="max-width:250px;" />
|
||||||
# end if
|
# end if
|
||||||
#end if
|
#end if
|
||||||
|
#if tweet.quote.isSome and get(tweet.quote).available:
|
||||||
|
# let quoteLink = getLink(get(tweet.quote))
|
||||||
|
<hr/>
|
||||||
|
<p>Quoting: <a href="${urlPrefix}${quoteLink}">${cfg.hostname}${quoteLink}</a></p>
|
||||||
|
${renderRssTweet(get(tweet.quote), cfg)}
|
||||||
|
#end if
|
||||||
#end proc
|
#end proc
|
||||||
#
|
#
|
||||||
#proc renderRssTweets(tweets: seq[Tweets]; cfg: Config; userId=""): string =
|
#proc renderRssTweets(tweets: seq[Tweets]; cfg: Config; userId=""): string =
|
||||||
|
|
|
@ -55,7 +55,16 @@ proc renderThread(thread: Tweets; prefs: Prefs; path: string): VNode =
|
||||||
renderTweet(tweet, prefs, path, class=(header & "thread"),
|
renderTweet(tweet, prefs, path, class=(header & "thread"),
|
||||||
index=i, last=(i == thread.high), showThread=show)
|
index=i, last=(i == thread.high), showThread=show)
|
||||||
|
|
||||||
proc renderUser(user: User; prefs: Prefs): VNode =
|
proc threadFilter(tweets: openArray[Tweet]; threads: openArray[int64]; it: Tweet): seq[Tweet] =
|
||||||
|
result = @[it]
|
||||||
|
if it.retweet.isSome or it.replyId in threads: return
|
||||||
|
for t in tweets:
|
||||||
|
if t.id == result[0].replyId:
|
||||||
|
result.insert t
|
||||||
|
elif t.replyId == result[0].id:
|
||||||
|
result.add t
|
||||||
|
|
||||||
|
proc renderUser*(user: User; prefs: Prefs): VNode =
|
||||||
buildHtml(tdiv(class="timeline-item")):
|
buildHtml(tdiv(class="timeline-item")):
|
||||||
a(class="tweet-link", href=("/" & user.username))
|
a(class="tweet-link", href=("/" & user.username))
|
||||||
tdiv(class="tweet-body profile-result"):
|
tdiv(class="tweet-body profile-result"):
|
||||||
|
|
|
@ -188,7 +188,7 @@ proc renderStats(stats: TweetStats; views: string; tweet: Tweet): VNode =
|
||||||
span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets)
|
span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets)
|
||||||
a(href="/search?q=quoted_tweet_id:" & $tweet.id):
|
a(href="/search?q=quoted_tweet_id:" & $tweet.id):
|
||||||
span(class="tweet-stat"): icon "quote", formatStat(stats.quotes)
|
span(class="tweet-stat"): icon "quote", formatStat(stats.quotes)
|
||||||
a(href=getLink(tweet, false) & "/favoriters"):
|
a():
|
||||||
span(class="tweet-stat"): icon "heart", formatStat(stats.likes)
|
span(class="tweet-stat"): icon "heart", formatStat(stats.likes)
|
||||||
a(href=getLink(tweet)):
|
a(href=getLink(tweet)):
|
||||||
if views.len > 0:
|
if views.len > 0:
|
||||||
|
|
36
twitter_oauth.sh
Normal file
36
twitter_oauth.sh
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Grab oauth token for use with Nitter (requires Twitter account).
|
||||||
|
# results: {"oauth_token":"xxxxxxxxxx-xxxxxxxxx","oauth_token_secret":"xxxxxxxxxxxxxxxxxxxxx"}
|
||||||
|
|
||||||
|
username=""
|
||||||
|
password=""
|
||||||
|
|
||||||
|
if [[ -z "$username" || -z "$password" ]]; then
|
||||||
|
echo "needs username and password"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
bearer_token='AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F'
|
||||||
|
guest_token=$(curl -s -XPOST https://api.twitter.com/1.1/guest/activate.json -H "Authorization: Bearer ${bearer_token}" | jq -r '.guest_token')
|
||||||
|
base_url='https://api.twitter.com/1.1/onboarding/task.json'
|
||||||
|
header=(-H "Authorization: Bearer ${bearer_token}" -H "User-Agent: TwitterAndroid/10.21.1" -H "Content-Type: application/json" -H "X-Guest-Token: ${guest_token}")
|
||||||
|
|
||||||
|
# start flow
|
||||||
|
flow_1=$(curl -si -XPOST "${base_url}?flow_name=login" "${header[@]}")
|
||||||
|
|
||||||
|
# get 'att', now needed in headers, and 'flow_token' from flow_1
|
||||||
|
att=$(sed -En 's/^att: (.*)\r/\1/p' <<< "${flow_1}")
|
||||||
|
flow_token=$(sed -n '$p' <<< "${flow_1}" | jq -r .flow_token)
|
||||||
|
|
||||||
|
# username
|
||||||
|
token_2=$(curl -s -XPOST "${base_url}" -H "att: ${att}" "${header[@]}" \
|
||||||
|
-d '{"flow_token":"'"${flow_token}"'","subtask_inputs":[{"subtask_id":"LoginEnterUserIdentifierSSO","settings_list":{"setting_responses":[{"key":"user_identifier","response_data":{"text_data":{"result":"'"${username}"'"}}}],"link":"next_link"}}]}' | jq -r .flow_token)
|
||||||
|
|
||||||
|
# password
|
||||||
|
token_3=$(curl -s -XPOST "${base_url}" -H "att: ${att}" "${header[@]}" \
|
||||||
|
-d '{"flow_token":"'"${token_2}"'","subtask_inputs":[{"enter_password":{"password":"'"${password}"'","link":"next_link"},"subtask_id":"LoginEnterPassword"}]}' | jq -r .flow_token)
|
||||||
|
|
||||||
|
# finally print oauth_token and secret
|
||||||
|
curl -s -XPOST "${base_url}" -H "att: ${att}" "${header[@]}" \
|
||||||
|
-d '{"flow_token":"'"${token_3}"'","subtask_inputs":[{"check_logged_in_account":{"link":"AccountDuplicationCheck_false"},"subtask_id":"AccountDuplicationCheck"}]}' | \
|
||||||
|
jq -c '.subtasks[0]|if(.open_account) then {oauth_token: .open_account.oauth_token, oauth_token_secret: .open_account.oauth_token_secret} else empty end'
|
Loading…
Reference in a new issue