0.3.0原版
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
data/rss-data
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
# Created by .ignore support plugin (hsz.mobi)
|
||||
### JetBrains template
|
||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm
|
||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||
|
||||
# User-specific stuff:
|
||||
# Sensitive or high-churn files:
|
||||
.idea/**/dataSources/
|
||||
.idea/**/dataSources.ids
|
||||
.idea/**/dataSources.xml
|
||||
.idea/**/dataSources.local.xml
|
||||
.idea/**/sqlDataSources.xml
|
||||
.idea/**/dynamic.xml
|
||||
.idea/**/uiDesigner.xml
|
||||
|
||||
# Gradle:
|
||||
.idea/**/gradle.xml
|
||||
.idea/**/libraries
|
||||
|
||||
# Mongo Explorer plugin:
|
||||
.idea/**/mongoSettings.xml
|
||||
|
||||
## File-based project format:
|
||||
*.iws
|
||||
|
||||
## Plugin-specific files:
|
||||
|
||||
# IntelliJ
|
||||
/out/
|
||||
|
||||
# mpeltonen/sbt-idea plugin
|
||||
.idea_modules/
|
||||
|
||||
.idea/
|
||||
|
||||
# JIRA plugin
|
||||
atlassian-ide-plugin.xml
|
||||
|
||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||
com_crashlytics_export_strings.xml
|
||||
crashlytics.properties
|
||||
crashlytics-build.properties
|
||||
fabric.properties
|
||||
|
||||
/node_modules/
|
||||
|
||||
/data/*
|
||||
!/data/.gitkeep
|
||||
/dist/
|
||||
yarn-error.log
|
||||
|
||||
.DS_Store
|
||||
/config.js
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
FROM node:18-alpine as builder
|
||||
RUN mkdir /app
|
||||
WORKDIR /app
|
||||
COPY . /app
|
||||
RUN npm i -g pnpm && pnpm i && pnpm build
|
||||
|
||||
FROM node:18-alpine
|
||||
LABEL maintainer="https://github.com/zgq354/weibo-rss"
|
||||
RUN mkdir /app
|
||||
WORKDIR /app
|
||||
|
||||
# container init
|
||||
RUN wget -O /usr/local/bin/dumb-init https://github.com/Yelp/dumb-init/releases/download/v1.2.1/dumb-init_1.2.1_amd64 && \
|
||||
echo "057ecd4ac1d3c3be31f82fc0848bf77b1326a975b4f8423fe31607205a0fe945 /usr/local/bin/dumb-init" | sha256sum -c - && \
|
||||
chmod 755 /usr/local/bin/dumb-init
|
||||
|
||||
COPY package.json pnpm-lock.yaml /app/
|
||||
RUN npm install -g pnpm && pnpm install --prod
|
||||
|
||||
# app code
|
||||
COPY . /app
|
||||
COPY --from=builder /app/dist /app/dist
|
||||
|
||||
EXPOSE 3000
|
||||
ENTRYPOINT ["/usr/local/bin/dumb-init", "--"]
|
||||
CMD ["node", "/app/dist/app.js"]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017 Zou Guoqing
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,48 @@
|
||||
# weibo-rss
|
||||
|
||||
简单的微博 RSS 订阅源生成器,可将某人最近发布的微博转换为符合 RSS Feed 标准的格式,供阅读器订阅。
|
||||
|
||||
让你不再错过喜欢的博主的动态更新,即使身处纷繁复杂中。
|
||||
|
||||
[](https://heroku.com/deploy)
|
||||
|
||||
## 特点
|
||||
1. 简单:主页链接一键转换 RSS 订阅源地址
|
||||
2. 克制:严格限制程序对微博的并发请求,不产生额外压力
|
||||
3. 省资源:基于 Node.js 实现,采用 [LevelDB](https://github.com/google/leveldb) 在本地文件系统做 cache,内存占用低(60MB 左右)
|
||||
|
||||
## 手动部署
|
||||
|
||||
依赖:`Node.js` 和 `pnpm`
|
||||
|
||||
安装:
|
||||
```
|
||||
git clone https://github.com/zgq354/weibo-rss.git
|
||||
cd weibo-rss
|
||||
pnpm i && pnpm build
|
||||
```
|
||||
|
||||
启动:
|
||||
```
|
||||
pnpm install pm2 -g
|
||||
pm2 start process.json
|
||||
```
|
||||
|
||||
程序将启动一个 HTTP Server,默认监听 `3000` 端口
|
||||
还需另外配置域名、HTTP 反向代理等
|
||||
|
||||
## ToDo
|
||||
1. 更多的接口单元测试
|
||||
|
||||
## 贡献者们
|
||||
<a href="https://github.com/zgq354/weibo-rss/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=zgq354/weibo-rss" />
|
||||
</a>
|
||||
|
||||
## 相关项目
|
||||
|
||||
* [RSSHub](https://github.com/DIYgod/RSSHub)
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "weibo-rss",
|
||||
"description": "Heroku Weibo Rss Generator",
|
||||
"repository": "https://github.com/zgq354/weibo-rss",
|
||||
"logo": "https://huan.github.io/weibo-rss/images/weibo-rss.png",
|
||||
"keywords": [
|
||||
"weibo",
|
||||
"rss"
|
||||
],
|
||||
"website": "https://zgq.ink/posts/weibo-rss",
|
||||
"success_url": "/",
|
||||
"env": {
|
||||
"EXAMPLE": {
|
||||
"description": "Example",
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": "10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
version: '2'
|
||||
|
||||
services:
|
||||
weibo-rss:
|
||||
build: .
|
||||
container_name: docker-weibo-rss
|
||||
restart: always
|
||||
ports:
|
||||
- "3000:3000"
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.8 KiB |
@@ -0,0 +1,5 @@
|
||||
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "weibo-rss",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "ts-node src/app.ts",
|
||||
"serve": "node dist/app.js",
|
||||
"dev": "cross-env DEBUG=1 ts-node-dev src/app.ts",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@koa/router": "^12.0.0",
|
||||
"axios": "^1.16.1",
|
||||
"koa": "^2.16.4",
|
||||
"koa-static": "^5.0.0",
|
||||
"level": "^6.0.1",
|
||||
"leveldown": "^6.1.1",
|
||||
"levelup": "^5.1.1",
|
||||
"node-schedule": "^1.3.0",
|
||||
"np-queue": "^2.0.0",
|
||||
"rss": "^1.2.2",
|
||||
"tracer": "^1.1.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "^29.7.0",
|
||||
"@types/koa": "^2.15.2",
|
||||
"@types/koa-static": "^4.0.2",
|
||||
"@types/koa__router": "^12.0.5",
|
||||
"@types/leveldown": "^4.0.2",
|
||||
"@types/levelup": "^4.3.0",
|
||||
"@types/node": "^14.18.36",
|
||||
"@types/node-schedule": "^1.3.1",
|
||||
"@types/rss": "^0.0.29",
|
||||
"cross-env": "^7.0.3",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.4.11",
|
||||
"ts-node": "^10.9.1",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^4.9.4"
|
||||
}
|
||||
}
|
||||
Generated
+4275
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
allowBuilds:
|
||||
leveldown: true
|
||||
|
||||
overrides:
|
||||
'@babel/helpers': ^7.26.10
|
||||
brace-expansion: ^1.1.13
|
||||
cross-spawn: ^7.0.6
|
||||
diff: ^4.0.4
|
||||
js-yaml: ^3.14.2
|
||||
minimatch: ^3.1.5
|
||||
picomatch: ^2.3.2
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"apps": [
|
||||
{
|
||||
"name": "weibo-rss",
|
||||
"script": "node ./dist/app.js",
|
||||
"watch": true,
|
||||
"env": {
|
||||
"PORT": "3000"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+6
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
@@ -0,0 +1,135 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="renderer" content="webkit" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||
<title>微博RSS订阅生成器</title>
|
||||
<link rel="stylesheet" href="css/bootstrap.min.css">
|
||||
<link rel="shortcut icon" href="favicon.ico"/>
|
||||
<style>
|
||||
.content {
|
||||
max-width: 500px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: 15px;
|
||||
}
|
||||
.head {
|
||||
margin-top: 70px;
|
||||
}
|
||||
.intro {
|
||||
margin-top: 30px;
|
||||
}
|
||||
.loading {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.result {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.footer-bottom {
|
||||
margin-top: 100px;
|
||||
}
|
||||
.reader-list {
|
||||
margin-top: 20px;
|
||||
}
|
||||
#form {
|
||||
margin-top: 18px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="content">
|
||||
<div class="head">
|
||||
<h2 class="text-center">Weibo to RSS</h2>
|
||||
<p class="intro">优雅地使用 RSS 阅读器订阅喜欢的微博博主</p>
|
||||
<p>食用方式:<br>1. 在输入框中粘贴你想订阅的微博地址<br>2. 点击“生成”按钮,获得RSS订阅源</p>
|
||||
<form id="form">
|
||||
<div class="input-group">
|
||||
<input id="text-input" type="text" class="form-control" placeholder="https://weibo.com/kaifulee">
|
||||
<span class="input-group-btn">
|
||||
<button class="btn btn-default">生成</button>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="loading" style="display: none;"><p class="text-center">加载中...</p></div>
|
||||
<div class="result" style="display: none;">
|
||||
<p>生成的RSS订阅源:</p>
|
||||
<div class="input-group">
|
||||
<input id="foo" type="text" class="form-control" value="">
|
||||
<span class="input-group-btn">
|
||||
<button id="copy-btn" data-clipboard-action="copy" data-clipboard-target="#foo" class="btn btn-default">复制到剪贴板</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<div class="container">
|
||||
<p class="text-mute text-center">Powered by <a href="https://github.com/zgq354/weibo-rss" target="_blank">weibo-rss</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
<script src="js/jquery-3.2.1.min.js"></script>
|
||||
<script src="js/clipboard.min.js"></script>
|
||||
<script>
|
||||
var prefix = {
|
||||
inoreader: "https://www.inoreader.com/?add_feed=",
|
||||
feedly: "https://cloud.feedly.com/#subscription/feed/",
|
||||
};
|
||||
|
||||
// 初始化Clipboard插件
|
||||
var clipboard = new Clipboard('#copy-btn');
|
||||
clipboard.on('success', function(e) {
|
||||
console.log(e);
|
||||
});
|
||||
clipboard.on('error', function(e) {
|
||||
console.log(e);
|
||||
});
|
||||
|
||||
// 链接处理
|
||||
$('#form').on('submit', function (e) {
|
||||
e.preventDefault();
|
||||
$(".result").hide();
|
||||
$(".loading").show();
|
||||
|
||||
var input = $("#text-input").val();
|
||||
var temp = input.match(/weibo\.(com|cn)\/(u|(profile))?\/?(\d{10})/);
|
||||
if (!temp) {
|
||||
temp = input.match(/weibo\.(com|cn)\/(u|(profile))?\/?([^?]+?)($|\?)/);
|
||||
if (!temp) {
|
||||
showResult(false);
|
||||
} else {
|
||||
$.get("api/domain2uid?domain=" + temp[4], function (data) {
|
||||
showResult(data.uid);
|
||||
}).fail(function (err) {
|
||||
showResult(false);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
showResult(temp[4]);
|
||||
}
|
||||
});
|
||||
// 显示结果
|
||||
function showResult(uid) {
|
||||
$(".loading").hide();
|
||||
// console.log(uid);
|
||||
if (!uid) {
|
||||
alert("数据获取失败");
|
||||
return;
|
||||
}
|
||||
var baseUrl = location.href.split("?")[0];
|
||||
if (baseUrl.substr(-1, 1) !== "/") baseUrl += "/";
|
||||
var feedUrl = baseUrl + "rss/user/" + uid;
|
||||
|
||||
$("#foo").val(feedUrl);
|
||||
for (var reader in prefix) {
|
||||
$("#" + reader).attr("href", prefix[reader] + feedUrl);
|
||||
}
|
||||
$(".result").show();
|
||||
}
|
||||
</script>
|
||||
</html>
|
||||
Vendored
+7
File diff suppressed because one or more lines are too long
Vendored
+4
File diff suppressed because one or more lines are too long
+53
@@ -0,0 +1,53 @@
|
||||
import Koa from 'koa';
|
||||
import Router from '@koa/router';
|
||||
import serve from 'koa-static';
|
||||
import path from 'path';
|
||||
import config from './config';
|
||||
import { logger } from "./modules/logger";
|
||||
import { normalizePort } from './utils';
|
||||
import { registerRoutes } from './modules/routes';
|
||||
import { RSSKoaContext, RSSKoaState } from './types';
|
||||
import { LevelCache } from './modules/cache';
|
||||
import { WeiboData } from './modules/weibo/weibo';
|
||||
|
||||
const koaApp = new Koa<RSSKoaState, RSSKoaContext>();
|
||||
const initApp = () => {
|
||||
const router = new Router<RSSKoaState, RSSKoaContext>();
|
||||
registerRoutes(router);
|
||||
|
||||
// levelCache
|
||||
const cache = LevelCache.getInstance(path.join(config.rootDir, 'data'), logger);
|
||||
cache.startScheduleCleanJob();
|
||||
|
||||
// weibo
|
||||
const weiboData = new WeiboData(cache, logger);
|
||||
|
||||
// enable X-Forwarded-For
|
||||
koaApp.proxy = true;
|
||||
|
||||
// init middleware
|
||||
return koaApp
|
||||
.use(async (ctx, next) => {
|
||||
// duration log
|
||||
const startTime = Date.now();
|
||||
logger.debug(`${ctx.req.method} ${ctx.originalUrl} ${ctx.ip}`);
|
||||
await next();
|
||||
const duration = Date.now() - startTime;
|
||||
logger.info(`[${ctx.status}] ${ctx.req.method} ${ctx.originalUrl} ${ctx.ip} hit: ${ctx.state.hit || 0} ${duration}ms`);
|
||||
})
|
||||
.use(async (ctx, next) => {
|
||||
ctx.cache = cache;
|
||||
ctx.weibo = weiboData;
|
||||
await next();
|
||||
})
|
||||
.use(serve(config.rootDir + '/public'))
|
||||
.use(router.routes());
|
||||
};
|
||||
|
||||
// start service
|
||||
initApp();
|
||||
const port = normalizePort(process.env.PORT || config.port);
|
||||
koaApp.listen(port, () => {
|
||||
logger.info(`weibo-rss start`);
|
||||
logger.info(`Listening http://0.0.0.0:${port}`);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 配置文件加载相关
|
||||
*/
|
||||
import { existsSync } from "fs";
|
||||
import { logger } from "./modules/logger";
|
||||
|
||||
const rootDir = `${__dirname}/../`;
|
||||
let customConfig = {};
|
||||
|
||||
// 加载自定义配置
|
||||
if (existsSync(`${rootDir}/config.js`)) {
|
||||
try {
|
||||
customConfig = require('../config');
|
||||
} catch (err) {
|
||||
logger.error('custom config load failed,', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
const defaultConfig = {
|
||||
// 程序监听的 TCP 端口,也可以在环境变量指定
|
||||
port: '3000',
|
||||
// 输出 rss feed 文本的 ttl 字段(分钟为单位)
|
||||
rssTTL: 15,
|
||||
// 真正的缓存配置,具体关注代码(秒为单位)
|
||||
cacheTTL: {
|
||||
rssXml: 15 * 60,
|
||||
apiStatusList: 15 * 60,
|
||||
apiIndexInfo: 1 * 24 * 60 * 60,
|
||||
apiLongText: 7 * 24 * 60 * 60,
|
||||
apiDetail: 7 * 24 * 60 * 60,
|
||||
apiDomain: 7 * 24 * 60 * 60,
|
||||
},
|
||||
// 图片缓存代理
|
||||
imageCache: 'https://image.baidu.com/search/down?url=',
|
||||
};
|
||||
|
||||
/**
|
||||
* 自定义配置
|
||||
*/
|
||||
export default {
|
||||
...defaultConfig,
|
||||
...customConfig,
|
||||
rootDir, // 站点根路径
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
import LevelDOWN from "leveldown";
|
||||
import levelUp, { LevelUp } from "levelup";
|
||||
import { scheduleJob } from "node-schedule";
|
||||
import path from "path";
|
||||
import { CacheInterface, LoggerInterface } from "../types";
|
||||
import { logger } from "./logger";
|
||||
|
||||
// 缓存条目的结构
|
||||
export interface CacheObject {
|
||||
created: number; // 缓存设置时的时间戳(毫秒)
|
||||
expire: boolean | number; // 有效期(秒),falsy 为不清理
|
||||
value: any;
|
||||
};
|
||||
|
||||
// levelup 的错误类型
|
||||
// https://github.com/Level/errors/blob/f5e5e406a5e325a8a62eb4d1135fa3d010816f8b/errors.js
|
||||
export interface NotFoundError extends Error {
|
||||
notFound: boolean;
|
||||
}
|
||||
|
||||
// 默认缓存目录
|
||||
const DB_FOLDER = 'rss-data';
|
||||
|
||||
/**
|
||||
* LevelDB 实现的文件系统缓存
|
||||
*/
|
||||
export class LevelCache implements CacheInterface {
|
||||
private instance: LevelUp;
|
||||
private logger: LoggerInterface;
|
||||
|
||||
constructor(dbPath: string, logger: LoggerInterface) {
|
||||
this.instance = levelUp(LevelDOWN(dbPath));
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
static instance: LevelCache = null;
|
||||
|
||||
static getInstance(dataBaseDir: string, log: LoggerInterface = logger) {
|
||||
if (!LevelCache.instance) {
|
||||
LevelCache.instance = new LevelCache(path.join(dataBaseDir, DB_FOLDER), log);
|
||||
}
|
||||
return LevelCache.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置缓存
|
||||
* @param key 需缓存的 key
|
||||
* @param value key 对应的值
|
||||
* @param expire 过期时间(单位秒)
|
||||
*/
|
||||
set(key: string, value: any, expire: number = 0) {
|
||||
this.logger.debug(`[cache] set ${key}`);
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const data: CacheObject = {
|
||||
created: Date.now(),
|
||||
expire: !!expire,
|
||||
value,
|
||||
};
|
||||
if (expire) {
|
||||
data.expire = expire;
|
||||
}
|
||||
this.instance.put(key, JSON.stringify(data), (err) => {
|
||||
if (err) return reject(err);
|
||||
return resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存的值
|
||||
* @param key 缓存对象的 key
|
||||
*/
|
||||
get(key: string) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.instance.get(key, (err: NotFoundError, value) => {
|
||||
if (err) {
|
||||
if (err.notFound) {
|
||||
this.logger.debug(`[cache] get ${key} notFound`);
|
||||
return resolve(null);
|
||||
}
|
||||
this.logger.error(`[cache] get ${key} error`, err);
|
||||
return reject(err);
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(String(value)) as CacheObject;
|
||||
if (this.checkExpired(data)) {
|
||||
this.logger.debug(`[cache] get ${key} expired`);
|
||||
// 过期缓存交给定时任务清理
|
||||
return resolve(null);
|
||||
} else {
|
||||
this.logger.debug(`[cache] get ${key}`);
|
||||
return resolve(data.value);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`[cache] parse ${key} error`, error);
|
||||
return reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* memo in cache
|
||||
*/
|
||||
async memo<T>(cb: () => T, key: string, expire = 0): Promise<Awaited<T>> {
|
||||
const cacheResp = await this.get(key);
|
||||
if (cacheResp) {
|
||||
return cacheResp as Awaited<T>;
|
||||
}
|
||||
const res = await cb();
|
||||
this.set(key, res, expire);
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* 定期清理过期缓存
|
||||
*/
|
||||
startScheduleCleanJob(rule: Parameters<typeof scheduleJob>[0] = "0 30 2 * * *") {
|
||||
// 每日凌晨两点半开始处理
|
||||
return scheduleJob(rule, () => {
|
||||
this.logger.info("[cache] cleaning start");
|
||||
let total = 0,
|
||||
deleted = 0;
|
||||
this.instance
|
||||
.createReadStream()
|
||||
.on("data", (item) => {
|
||||
total++;
|
||||
try {
|
||||
const data = JSON.parse(item.value.toString()) as CacheObject;
|
||||
if (this.checkExpired(data)) {
|
||||
deleted++;
|
||||
setTimeout(() => {
|
||||
this.instance.del(item.key, (err) => {
|
||||
if (err) {
|
||||
this.logger.error(`[cache] delete err key: ${item.key}`, err);
|
||||
}
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error("[cache] parse error", err);
|
||||
}
|
||||
})
|
||||
.on("end", () => {
|
||||
this.logger.info(
|
||||
"[cache] cleaning finished, total cnt:",
|
||||
total,
|
||||
"deleted cnt:",
|
||||
deleted
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private checkExpired(cacheItem: CacheObject) {
|
||||
return cacheItem.expire && Date.now() - cacheItem.created > +cacheItem.expire * 1000;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 日志模块,生成对应格式日志输出到控制台
|
||||
*/
|
||||
import * as tracer from 'tracer';
|
||||
|
||||
const debug = process.env.DEBUG;
|
||||
|
||||
export const logger = tracer.colorConsole({
|
||||
format : "[{{timestamp}}] [{{title}}] {{message}}",
|
||||
level: debug ? 'debug' : 'info',
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* URL 路由分发
|
||||
*/
|
||||
import Router from '@koa/router';
|
||||
import NodeRSS from 'rss';
|
||||
import { RSSKoaContext, RSSKoaState } from '../types';
|
||||
import config from '../config';
|
||||
import { DomainNotFoundError, statusToHTML, UserNotFoundError } from './weibo/weibo';
|
||||
import { ThrottledError } from './throttler';
|
||||
import { logger } from './logger';
|
||||
|
||||
export class UidInvalidError extends Error {
|
||||
constructor(uid: string) {
|
||||
super(`uid: ${uid}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class DomainInvalidError extends Error {
|
||||
constructor(domain: string) {
|
||||
super(`domain: ${domain}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const registerRoutes = (
|
||||
router: Router<RSSKoaState, RSSKoaContext>
|
||||
) => {
|
||||
router.get('/rss/user/:id', async (ctx) => {
|
||||
const uid = ctx.params['id'];
|
||||
try {
|
||||
// check uid format
|
||||
if (!/^[0-9]{10}$/.test(uid)) {
|
||||
throw new UidInvalidError(uid);
|
||||
}
|
||||
|
||||
// get data
|
||||
let cacheMiss = false;
|
||||
const xmlData = await ctx.cache.memo(async () => {
|
||||
const weiboData = await ctx.weibo.fetchUserLatestWeibo(uid);
|
||||
if (weiboData) {
|
||||
// basic info
|
||||
const feed = new NodeRSS({
|
||||
site_url: "https://weibo.com/" + uid,
|
||||
feed_url: '',
|
||||
title: weiboData.screenName + '的微博',
|
||||
description: weiboData.description,
|
||||
generator: 'https://github.com/zgq354/weibo-rss',
|
||||
ttl: config.rssTTL,
|
||||
});
|
||||
// item
|
||||
weiboData.statusList?.forEach((status) => {
|
||||
if (!status) return;
|
||||
feed.item({
|
||||
title: status.status_title || (status.text ? status.text.replace(/<[^>]+>/g, '').replace(/[\n]/g, '').substr(0, 25) : null),
|
||||
description: statusToHTML(status),
|
||||
url: 'https://weibo.com/' + uid + '/' + status.bid,
|
||||
date: new Date(status.created_at),
|
||||
});
|
||||
});
|
||||
cacheMiss = true;
|
||||
return feed.xml();
|
||||
}
|
||||
}, `xml-${uid}`, config.cacheTTL.rssXml);
|
||||
|
||||
// send data
|
||||
ctx.set('Content-Type', 'text/xml');
|
||||
ctx.body = xmlData;
|
||||
|
||||
// mark hit cache
|
||||
ctx.state.hit = cacheMiss ? 0 : 1;
|
||||
} catch (error) {
|
||||
if (error instanceof UidInvalidError) {
|
||||
ctx.status = 404;
|
||||
ctx.body = `找不到用户,传入 UID 格式有误。uid: ${uid}`;
|
||||
return;
|
||||
}
|
||||
if (error instanceof UserNotFoundError) {
|
||||
ctx.status = 404;
|
||||
ctx.body = `找不到用户,可能用户仅登录可见,不支持订阅。可以通过打开 https://m.weibo.cn/u/:uid 验证(<a href="https://m.weibo.cn/u/${uid}" target="_blank">uid: ${uid}</a>)`;
|
||||
return;
|
||||
}
|
||||
if (error instanceof ThrottledError) {
|
||||
ctx.status = 503;
|
||||
ctx.body = `暂时无法拉取到数据,请稍后再试。uid: ${uid}`;
|
||||
return;
|
||||
}
|
||||
ctx.status = 500;
|
||||
ctx.body = `未知错误,需管理员检查日志。uid: ${uid}`;
|
||||
logger.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/api/domain2uid', async (ctx) => {
|
||||
const domain = ctx.request.query['domain'] as string;
|
||||
try {
|
||||
// verify
|
||||
if (!domain || !/^[A-Za-z0-9]{3,20}$/.test(domain)) {
|
||||
throw new DomainInvalidError(domain);
|
||||
}
|
||||
// start fetching
|
||||
let cacheMiss = false;
|
||||
const uid = await ctx.cache.memo(
|
||||
() => {
|
||||
cacheMiss = true;
|
||||
return ctx.weibo.fetchUIDByDomain(domain);
|
||||
},
|
||||
`dm-${domain}`,
|
||||
config.cacheTTL.apiDomain,
|
||||
);
|
||||
logger.debug(`domain: ${domain}, uid: ${uid}`);
|
||||
ctx.body = {
|
||||
success: true,
|
||||
uid
|
||||
};
|
||||
|
||||
// mark hit cache
|
||||
ctx.state.hit = cacheMiss ? 0 : 1;
|
||||
} catch (error) {
|
||||
if (error instanceof DomainInvalidError || error instanceof DomainNotFoundError) {
|
||||
ctx.status = 404;
|
||||
ctx.body = {
|
||||
success: false,
|
||||
msg: '找不到用户,可能是地址格式不正确',
|
||||
};
|
||||
return;
|
||||
}
|
||||
logger.error(error);
|
||||
ctx.status = 500;
|
||||
ctx.body = {
|
||||
success: false,
|
||||
msg: '获取数据时发生了错误'
|
||||
};
|
||||
}
|
||||
})
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import Queue from "np-queue";
|
||||
import { LoggerInterface } from "../types";
|
||||
import { logger } from "./logger";
|
||||
|
||||
export class ThrottledError extends Error {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Limit the request frequency
|
||||
*/
|
||||
export class Throttler {
|
||||
name: string = '';
|
||||
queue: Queue;
|
||||
enable: boolean;
|
||||
lastUpdateTime: number;
|
||||
retryDelayMs: number;
|
||||
logger: LoggerInterface;
|
||||
|
||||
constructor(name = '', log: LoggerInterface = logger) {
|
||||
this.name = name;
|
||||
this.logger = log;
|
||||
this.queue = new Queue({ concurrency: 1 });
|
||||
this.retryDelayMs = 600000;
|
||||
this.enable = true;
|
||||
this.lastUpdateTime = Date.now();
|
||||
}
|
||||
|
||||
checkFuncAvailable = () => {
|
||||
if (!this.enable && Date.now() - this.lastUpdateTime > this.retryDelayMs) {
|
||||
this.enable = true;
|
||||
this.logger.info(`[Throttled] reenabled ${this.name}`);
|
||||
}
|
||||
return this.enable;
|
||||
};
|
||||
|
||||
disableFunc = () => {
|
||||
this.enable = false;
|
||||
this.lastUpdateTime = Date.now();
|
||||
this.logger.info(`[Throttled] disabled ${this.name}`);
|
||||
return Promise.reject(new ThrottledError());
|
||||
};
|
||||
|
||||
runFunc = <T = any>(callback: (disable: () => Promise<void>) => any): Promise<T> => {
|
||||
if (!this.checkFuncAvailable()) {
|
||||
return Promise.reject(new ThrottledError());
|
||||
}
|
||||
return this.queue.add(async () => callback(this.disableFunc));
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
export const TIME_OUT = 3000;
|
||||
export const MOCK_UA = 'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36';
|
||||
|
||||
export const handleForbiddenErr = (err: AxiosError, cb: () => Promise<void>) => {
|
||||
if (err.response && [418, 403].includes(err.response.status)) {
|
||||
return cb();
|
||||
} else {
|
||||
return Promise.reject(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// TODO: add more tests
|
||||
import { describe, expect, test } from "@jest/globals";
|
||||
|
||||
describe('Weibo API: detailAPI', () => {
|
||||
test('TODO: add more tests', async () => {
|
||||
expect(1).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Throttler } from "../../throttler";
|
||||
import Axios from "axios";
|
||||
import { Agent } from "https";
|
||||
import { handleForbiddenErr, MOCK_UA, TIME_OUT } from "./common";
|
||||
import { logger } from "../../logger";
|
||||
import { waitMs } from "../../../utils";
|
||||
|
||||
export const createDetailAPI = () => {
|
||||
const runner = new Throttler('detail');
|
||||
const httpsAgent = new Agent({ keepAlive: true });
|
||||
const axiosInstance = Axios.create({
|
||||
timeout: TIME_OUT,
|
||||
httpsAgent
|
||||
});
|
||||
return {
|
||||
getWeiboDetail: (id: string) => runner.runFunc(async (disable) => {
|
||||
logger.debug(`[getDetail] ${id}`);
|
||||
await waitMs(Math.floor(Math.random() * 100));
|
||||
return await axiosInstance({
|
||||
method: 'get',
|
||||
url: `https://m.weibo.cn/statuses/show?id=${id}`,
|
||||
headers: {
|
||||
'Mweibo-Pwa': 1,
|
||||
'Referer': `https://m.weibo.cn/detail/${id}`,
|
||||
'User-Agent': MOCK_UA,
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
}).then(({ data }) => {
|
||||
return data.data;
|
||||
}).catch(err => handleForbiddenErr(err, disable));
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export type GetWeiboDetailFunc = ReturnType<typeof createDetailAPI>['getWeiboDetail'];
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, test } from "@jest/globals";
|
||||
import { createDomainAPI } from "./domainAPI";
|
||||
|
||||
const testDomainMapList = [
|
||||
{
|
||||
domain: 'kaifulee',
|
||||
uid: '1197161814',
|
||||
},
|
||||
{
|
||||
domain: 'taobao',
|
||||
uid: '1682454721',
|
||||
},
|
||||
{
|
||||
domain: 'tmall',
|
||||
uid: '1768198384',
|
||||
},
|
||||
];
|
||||
|
||||
describe('Domain API: domain to uid', () => {
|
||||
test('basic domain format', async () => {
|
||||
const { getUIDByDomain } = createDomainAPI();
|
||||
testDomainMapList.forEach(async (data) => {
|
||||
const resData = await getUIDByDomain(data.domain);
|
||||
expect(resData).toBe(data.uid);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Throttler } from "../../throttler";
|
||||
import Axios from "axios";
|
||||
import { Agent } from "https";
|
||||
import { handleForbiddenErr, MOCK_UA, TIME_OUT } from "./common";
|
||||
import { logger } from "../../logger";
|
||||
import { waitMs } from "../../../utils";
|
||||
|
||||
export class DomainNotFoundError extends Error {
|
||||
constructor(domain: string) {
|
||||
super(`domain: ${domain}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const createDomainAPI = () => {
|
||||
const runner = new Throttler('domain');
|
||||
const httpsAgent = new Agent({ keepAlive: true });
|
||||
const axiosInstance = Axios.create({
|
||||
timeout: TIME_OUT,
|
||||
httpsAgent
|
||||
});
|
||||
|
||||
return {
|
||||
getUIDByDomain: (domain: string) => runner.runFunc<string>(async (disable) => {
|
||||
logger.debug(`[domain] convert ${domain}`);
|
||||
await waitMs(Math.floor(Math.random() * 100));
|
||||
return await axiosInstance.get(`https://m.weibo.cn/${domain}?&jumpfrom=weibocom`, {
|
||||
headers: {
|
||||
'User-Agent': MOCK_UA,
|
||||
},
|
||||
}).then(res => {
|
||||
const uid = res.request.path.split("/u/")[1] as string;
|
||||
if (!uid) {
|
||||
throw new DomainNotFoundError(domain);
|
||||
}
|
||||
return uid;
|
||||
}).catch(err => handleForbiddenErr(err, disable));
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export type GetUIDByDomainFunc = ReturnType<typeof createDomainAPI>['getUIDByDomain'];
|
||||
@@ -0,0 +1,8 @@
|
||||
// TODO: add more tests
|
||||
import { describe, expect, test } from "@jest/globals";
|
||||
|
||||
describe('Weibo API: indexAPI', () => {
|
||||
test('TODO: add more tests', async () => {
|
||||
expect(1).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Throttler } from "../../throttler";
|
||||
import Axios from "axios";
|
||||
import { Agent } from "https";
|
||||
import { handleForbiddenErr, MOCK_UA, TIME_OUT } from "./common";
|
||||
import { WeiboStatus, WeiboUserData } from "../../../types";
|
||||
import { logger } from "../../logger";
|
||||
import { waitMs } from "../../../utils";
|
||||
|
||||
export class UserNotFoundError extends Error {
|
||||
constructor(uid: string) {
|
||||
super(`uid: ${uid}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const createIndexAPI = () => {
|
||||
const runner = new Throttler('index');
|
||||
const httpsAgent = new Agent({ keepAlive: true });
|
||||
const axiosInstance = Axios.create({
|
||||
timeout: TIME_OUT,
|
||||
httpsAgent
|
||||
});
|
||||
|
||||
return {
|
||||
getIndexUserInfo: (uid: string) => runner.runFunc<WeiboUserData>(async (disable) => {
|
||||
const resHeaders = await axiosInstance({
|
||||
"method": "POST",
|
||||
"url": "https://visitor.passport.weibo.cn/visitor/genvisitor2",
|
||||
"headers": {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": MOCK_UA,
|
||||
},
|
||||
"data": {
|
||||
"cb": "visitor_gray_callback",
|
||||
tid: '',
|
||||
from: 'weibo'
|
||||
}
|
||||
}).then(res => res.headers)
|
||||
axiosInstance.defaults.headers.common['Cookie'] = resHeaders['set-cookie'].filter(cookie => cookie.startsWith('SUB')).map(cookie => cookie.split(';')[0]).join(';');
|
||||
logger.debug(`[getInfo] ${uid}`);
|
||||
await waitMs(Math.floor(Math.random() * 100));
|
||||
return await axiosInstance({
|
||||
method: 'get',
|
||||
url: `https://m.weibo.cn/api/container/getIndex?type=uid&value=${uid}`,
|
||||
headers: {
|
||||
'Mweibo-Pwa': 1,
|
||||
'Referer': `https://m.weibo.cn/u/${uid}`,
|
||||
'User-Agent': MOCK_UA,
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
}).then(({ data }) => {
|
||||
if (data.ok !== 1) {
|
||||
return Promise.reject(new UserNotFoundError(uid));
|
||||
}
|
||||
const result = {
|
||||
uid,
|
||||
screenName: data.data.userInfo.screen_name,
|
||||
description: data.data.userInfo.description,
|
||||
containerId: data.data.tabsInfo.tabs[1].containerid,
|
||||
};
|
||||
return result;
|
||||
}).catch(err => handleForbiddenErr(err, disable));
|
||||
}),
|
||||
getWeiboContentList: (uid: string, containerId: string) => runner.runFunc<WeiboStatus[]>(async (disable) => {
|
||||
logger.debug(`[getContList] ${uid} ${containerId}`);
|
||||
await waitMs(Math.floor(Math.random() * 100));
|
||||
return await axiosInstance({
|
||||
method: 'get',
|
||||
url: `https://m.weibo.cn/api/container/getIndex?type=uid&value=${uid}&containerid=${containerId}`,
|
||||
headers: {
|
||||
'Mweibo-Pwa': 1,
|
||||
'Referer': `https://m.weibo.cn/u/${uid}`,
|
||||
'User-Agent': MOCK_UA,
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
}).then(({ data }) => {
|
||||
return data.data.cards
|
||||
.filter(item => item.mblog)
|
||||
.map(item => item.mblog);
|
||||
}).catch(err => handleForbiddenErr(err, disable));
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export type GetIndexUserInfoFunc = ReturnType<typeof createIndexAPI>['getIndexUserInfo'];
|
||||
|
||||
export type GetWeiboContentListFunc = ReturnType<typeof createIndexAPI>['getWeiboContentList'];
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, test } from "@jest/globals";
|
||||
import { createLongTextAPI } from "./longTextAPI";
|
||||
|
||||
const testDataList = [{
|
||||
id: '5093426468489917',
|
||||
text: '中微子是宇宙形成之初就存在的最古老也最原始的基本粒子',
|
||||
}, {
|
||||
id: '5093698779485067',
|
||||
text: '无论如何,「AI God」的拍卖再次引发了人们对传统艺术与数字艺术的思考',
|
||||
}];
|
||||
|
||||
describe ('Weibo API: longTextAPI', () => {
|
||||
test('fetch long text', async () => {
|
||||
const { getWeiboLongText } = createLongTextAPI();
|
||||
for (const data of testDataList) {
|
||||
const resData = await getWeiboLongText(data.id);
|
||||
expect(resData).toContain(data.text);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Throttler } from "../../throttler";
|
||||
import Axios from "axios";
|
||||
import { Agent } from "https";
|
||||
import { handleForbiddenErr, MOCK_UA, TIME_OUT } from "./common";
|
||||
import { logger } from "../../logger";
|
||||
import { waitMs } from "../../../utils";
|
||||
|
||||
export const createLongTextAPI = () => {
|
||||
const runner = new Throttler('longText');
|
||||
const httpsAgent = new Agent({ keepAlive: true });
|
||||
const axiosInstance = Axios.create({
|
||||
timeout: TIME_OUT,
|
||||
httpsAgent
|
||||
});
|
||||
return {
|
||||
getWeiboLongText: (id: string) => runner.runFunc<string>(async (disable) => {
|
||||
logger.debug(`[longText] ${id}`);
|
||||
await waitMs(Math.floor(Math.random() * 100));
|
||||
return await axiosInstance({
|
||||
method: 'get',
|
||||
url: `https://m.weibo.cn/statuses/extend?id=${id}`,
|
||||
headers: {
|
||||
'Mweibo-Pwa': 1,
|
||||
'Referer': `https://m.weibo.cn/detail/${id}`,
|
||||
'User-Agent': MOCK_UA,
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
}).then(({ data }) => {
|
||||
if (!data.data) {
|
||||
throw new Error(JSON.stringify(data));
|
||||
}
|
||||
return data.data.longTextContent;
|
||||
}).catch(err => handleForbiddenErr(err, disable));
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export type GetWeiboLongTextFunc = ReturnType<typeof createLongTextAPI>['getWeiboLongText'];
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, test } from '@jest/globals';
|
||||
import { WeiboData } from "./weibo";
|
||||
import { WeiboStatus } from '../../types';
|
||||
|
||||
const wbData = new WeiboData({
|
||||
set: async () => null,
|
||||
get: async () => null,
|
||||
memo: async <T>(cb: () => T): Promise<Awaited<T>> => await cb(),
|
||||
});
|
||||
|
||||
// TODO: more unit test about weibo data
|
||||
describe('Weibo Data: user weibo profile and list', () => {
|
||||
const TEST_UID = '5890672121';
|
||||
|
||||
// 获取用户名 + 微博列表
|
||||
test('fetch user weibo profile and list success', async () => {
|
||||
const resData = await wbData.fetchUserLatestWeibo(TEST_UID);
|
||||
expect(resData.uid).toBe(TEST_UID);
|
||||
expect(resData.screenName).toBe('搜狐新闻');
|
||||
expect(resData.statusList).toBeDefined();
|
||||
});
|
||||
|
||||
// 长文本的填充
|
||||
test('fetch weibo with long text', async () => {
|
||||
const resData = await wbData.fillStatusWithLongText({
|
||||
id: '5093426468489917',
|
||||
isLongText: true,
|
||||
} as WeiboStatus);
|
||||
expect(resData.text).toContain('中微子是宇宙形成之初就存在的最古老也最原始的基本粒子');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Weibo Data: domain to uid', () => {
|
||||
test('basic domain format', async () => {
|
||||
const resData = await wbData.fetchUIDByDomain('kaifulee');
|
||||
expect(resData).toBe('1197161814');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import config from "../../config";
|
||||
import { CacheInterface, LoggerInterface, WeiboStatus, WeiboUserData } from "../../types";
|
||||
import { logger } from "../logger";
|
||||
import { createDetailAPI, GetWeiboDetailFunc } from "./api/detailAPI";
|
||||
import { createDomainAPI, DomainNotFoundError, GetUIDByDomainFunc } from "./api/domainAPI";
|
||||
import { createIndexAPI, GetIndexUserInfoFunc, GetWeiboContentListFunc, UserNotFoundError } from "./api/indexAPI";
|
||||
import { createLongTextAPI, GetWeiboLongTextFunc } from "./api/longTextAPI";
|
||||
|
||||
export {
|
||||
DomainNotFoundError,
|
||||
UserNotFoundError,
|
||||
};
|
||||
|
||||
export class WeiboData {
|
||||
private cache: CacheInterface;
|
||||
private logger: LoggerInterface;
|
||||
private getIndexUserInfo: GetIndexUserInfoFunc;
|
||||
private getWeiboContentList: GetWeiboContentListFunc;
|
||||
private getWeiboDetail: GetWeiboDetailFunc;
|
||||
private getWeiboLongText: GetWeiboLongTextFunc;
|
||||
private getUIDByDomain: GetUIDByDomainFunc;
|
||||
|
||||
constructor(cache: CacheInterface, log: LoggerInterface = logger) {
|
||||
this.cache = cache;
|
||||
this.logger = log;
|
||||
const { getIndexUserInfo, getWeiboContentList } = createIndexAPI();
|
||||
const { getWeiboDetail } = createDetailAPI();
|
||||
const { getWeiboLongText } = createLongTextAPI();
|
||||
const { getUIDByDomain } = createDomainAPI();
|
||||
// bind to this
|
||||
Object.assign(this, {
|
||||
getIndexUserInfo,
|
||||
getWeiboContentList,
|
||||
getWeiboDetail,
|
||||
getWeiboLongText,
|
||||
getUIDByDomain,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* get user's weibo
|
||||
*/
|
||||
fetchUserLatestWeibo = async (uid: string) => {
|
||||
const indexInfo = await this.cache.memo(() => this.getIndexUserInfo(uid), `info-${uid}`, config.cacheTTL.apiIndexInfo);
|
||||
const { containerId } = indexInfo;
|
||||
const statusList = await this.cache.memo(async () => {
|
||||
const wbList = await this.getWeiboContentList(uid, containerId);
|
||||
return await Promise.all(
|
||||
wbList.map(status => this.fillStatusWithLongText(status))
|
||||
);
|
||||
}, `list-${uid}`, config.cacheTTL.apiStatusList);
|
||||
|
||||
return {
|
||||
...indexInfo,
|
||||
statusList,
|
||||
} as WeiboUserData;
|
||||
};
|
||||
|
||||
/**
|
||||
* allow failure
|
||||
*/
|
||||
fillStatusWithLongText = async (status: WeiboStatus) => {
|
||||
let newStatus = status;
|
||||
try {
|
||||
if (status.isLongText) {
|
||||
try {
|
||||
const longTextContent = await this.cache.memo(
|
||||
() => this.getWeiboLongText(status.id),
|
||||
`long-${status.id}`,
|
||||
config.cacheTTL.apiLongText,
|
||||
);
|
||||
newStatus = {
|
||||
...status,
|
||||
text: longTextContent
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(error, `uid: ${status?.user?.id}, status: ${status.id}`);
|
||||
// fallback to detail
|
||||
newStatus = await this.cache.memo(
|
||||
() => this.getWeiboDetail(status.id),
|
||||
`dt-${status.id}`,
|
||||
config.cacheTTL.apiDetail,
|
||||
);
|
||||
}
|
||||
}
|
||||
// 转发的微博全文
|
||||
if (status.retweeted_status) {
|
||||
newStatus = {
|
||||
...status,
|
||||
retweeted_status: await this.fillStatusWithLongText(status.retweeted_status),
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error, `uid: ${status?.user?.id}, status: ${status.id}`);
|
||||
}
|
||||
return newStatus;
|
||||
};
|
||||
|
||||
/**
|
||||
* domain -> uid
|
||||
*/
|
||||
fetchUIDByDomain = async (domain: string) => this.getUIDByDomain(domain);
|
||||
}
|
||||
|
||||
export const statusToHTML = (status: WeiboStatus) => {
|
||||
let tempHTML = status.text;
|
||||
// 表情转文字
|
||||
tempHTML = tempHTML.replace(/<span class="url-icon"><img alt="?(.*?)"? src=".*?" style="width:1em; height:1em;".*?\/><\/span>/g, '$1');
|
||||
// 去掉外链图标
|
||||
tempHTML = tempHTML.replace(/<span class='url-icon'><img.*?><\/span>/g, '');
|
||||
|
||||
// 转发的微博
|
||||
if (status.retweeted_status) {
|
||||
tempHTML += "<br><br>";
|
||||
// 可能有转发的微博被删除的情况
|
||||
if (status.retweeted_status.user) {
|
||||
tempHTML += '<div style="border-left: 3px solid gray; padding-left: 1em;">' +
|
||||
'转发 <a href="https://weibo.com/' + status.retweeted_status.user.id + '" target="_blank">@' + status.retweeted_status.user.screen_name + '</a>: ' +
|
||||
statusToHTML(status.retweeted_status) +
|
||||
'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// 微博配图
|
||||
if (status.pics) {
|
||||
status.pics.forEach(function (item) {
|
||||
tempHTML += "<br><br>";
|
||||
const url = config.imageCache ? (config.imageCache + encodeURIComponent(item.large.url)) : item.large.url;
|
||||
const largeUrl = config.imageCache ? (config.imageCache + encodeURIComponent(item.large.url)) : item.large.url;
|
||||
tempHTML += '<a href="' + largeUrl + '" target="_blank"><img src="' + url+ '"></a>';
|
||||
});
|
||||
}
|
||||
|
||||
return tempHTML;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Tracer } from "tracer";
|
||||
import { WeiboData } from "./modules/weibo/weibo";
|
||||
|
||||
export interface RSSKoaContext {
|
||||
cache: CacheInterface;
|
||||
weibo: WeiboData;
|
||||
}
|
||||
|
||||
export interface RSSKoaState {
|
||||
// cache hit
|
||||
hit: 0 | 1;
|
||||
}
|
||||
|
||||
export type LoggerInterface = Tracer.Logger<string>;
|
||||
|
||||
export interface CacheInterface {
|
||||
set: (key: string, value: any, expire: number) => Promise<void>;
|
||||
get: (key: string) => any;
|
||||
memo: <T>(cb: () => T, key: string, expire: number) => Promise<Awaited<T>>;
|
||||
}
|
||||
|
||||
export interface WeiboStatus {
|
||||
// eg: '4851725594528288'
|
||||
id: string;
|
||||
// eg: '4851725594528288'
|
||||
mid: string;
|
||||
// eg: 'MlHCnj00U'
|
||||
bid: string;
|
||||
// time
|
||||
created_at: string;
|
||||
// text
|
||||
text: string;
|
||||
isLongText: boolean;
|
||||
user: {
|
||||
id: number,
|
||||
screen_name: string,
|
||||
profile_url: string;
|
||||
description: string;
|
||||
// more...
|
||||
[x: string]: any;
|
||||
},
|
||||
// pics
|
||||
pic_ids: string[];
|
||||
thumbnail_pic: string;
|
||||
bmiddle_pic: string;
|
||||
original_pic: string;
|
||||
pics: {
|
||||
pid: string,
|
||||
url: string,
|
||||
size: 'orj360' | 'large',
|
||||
large: {
|
||||
size: 'orj360' | 'large',
|
||||
url: string,
|
||||
}
|
||||
}[];
|
||||
// video and other data
|
||||
page_info?: {
|
||||
type: 'video' | 'search_topic';
|
||||
// eg: 'http://t.cn/A6K3ITkN'
|
||||
url_ori: string;
|
||||
// eg: '搜狐新闻的微博视频'
|
||||
page_title: string;
|
||||
// eg: '2022触动瞬间'
|
||||
title: string;
|
||||
// eg: '搜狐新闻的微博视频'
|
||||
content1: string;
|
||||
content2: string;
|
||||
// more...
|
||||
[x: string]: any;
|
||||
};
|
||||
retweeted_status?: WeiboStatus;
|
||||
[x: string]: any;
|
||||
}
|
||||
|
||||
export interface WeiboUserData {
|
||||
uid: string,
|
||||
screenName: string,
|
||||
description: string,
|
||||
containerId?: string,
|
||||
statusList?: WeiboStatus[],
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
|
||||
export const normalizePort = (val: string) => {
|
||||
const port = parseInt(val, 10);
|
||||
if (isNaN(port)) {
|
||||
// named pipe
|
||||
return val;
|
||||
}
|
||||
if (port >= 0) {
|
||||
// port number
|
||||
return port;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const waitMs = (ms: number) => {
|
||||
return new Promise(resolve => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/",
|
||||
"rootDir": "./src/",
|
||||
"target": "es2018",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"experimentalDecorators": true,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user