├── .gitignore ├── juejin.png ├── xiaozhuanlan.png ├── vercel.json ├── api ├── juejin │ └── [id] │ │ └── [index].ts ├── juejin.ts ├── xiaozhuanlan │ └── [id] │ │ └── [index].ts └── xiaozhuanlan.ts ├── package.json ├── util ├── requestArticles.ts └── utils.ts ├── assets └── articleCard.ts └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .vercel 2 | -------------------------------------------------------------------------------- /juejin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flywith24/github-juejin-readme-rss/HEAD/juejin.png -------------------------------------------------------------------------------- /xiaozhuanlan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flywith24/github-juejin-readme-rss/HEAD/xiaozhuanlan.png -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "rewrites": [ 4 | { "source": "/(.*)", "destination": "/api/$1" } 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /api/juejin/[id]/[index].ts: -------------------------------------------------------------------------------- 1 | import {NowRequest, NowResponse} from '@vercel/node'; 2 | import {getJuejinArticles} from '../../../util/requestArticles'; 3 | import {generateSvg} from "../../../util/utils"; 4 | 5 | export default async (req: NowRequest, res: NowResponse) => { 6 | const { 7 | query: {id, index, width, height, hideDate, hideImage, imageUrl, imageWidth}, 8 | headers, 9 | } = req; 10 | 11 | const responseArticles = await getJuejinArticles(id); 12 | 13 | return await generateSvg(index, 14 | width, height, hideImage, hideDate, imageUrl, imageWidth, 15 | responseArticles, headers, res); 16 | }; 17 | -------------------------------------------------------------------------------- /api/juejin.ts: -------------------------------------------------------------------------------- 1 | import {NowRequest, NowResponse} from '@vercel/node'; 2 | import {getJuejinArticles} from '../util/requestArticles'; 3 | import {composeSvg} from "../util/utils"; 4 | 5 | export default async (req: NowRequest, res: NowResponse) => { 6 | const { 7 | query: {id, limit, width, height, hideDate, hideImage, imageUrl, imageWidth}, 8 | headers, 9 | } = req; 10 | 11 | let articles = [] 12 | const responseArticles = await getJuejinArticles(id); 13 | 14 | return await composeSvg(limit, 15 | width, height, hideImage, hideDate, imageUrl, imageWidth, 16 | articles, responseArticles, headers, res 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /api/xiaozhuanlan/[id]/[index].ts: -------------------------------------------------------------------------------- 1 | import {NowRequest, NowResponse} from '@vercel/node'; 2 | import {getXiaozhuanlanArticles} from '../../../util/requestArticles'; 3 | import {generateSvg} from "../../../util/utils"; 4 | 5 | export default async (req: NowRequest, res: NowResponse) => { 6 | const { 7 | query: {id, index, width, height, hideDate, hideImage, imageUrl, imageWidth}, 8 | headers, 9 | } = req; 10 | 11 | const responseArticles = await getXiaozhuanlanArticles(id); 12 | 13 | return await generateSvg(index, 14 | width, height, hideImage, hideDate, imageUrl, imageWidth, 15 | responseArticles, headers, res); 16 | }; 17 | -------------------------------------------------------------------------------- /api/xiaozhuanlan.ts: -------------------------------------------------------------------------------- 1 | import {NowRequest, NowResponse} from '@vercel/node'; 2 | import {getXiaozhuanlanArticles} from '../util/requestArticles'; 3 | import {composeSvg} from "../util/utils"; 4 | 5 | 6 | export default async (req: NowRequest, res: NowResponse) => { 7 | const { 8 | query: {id, limit, width, height, hideDate, hideImage, imageUrl, imageWidth}, 9 | headers, 10 | } = req; 11 | 12 | let articles = [] 13 | const responseArticles = await getXiaozhuanlanArticles(id); 14 | 15 | return await composeSvg(limit, 16 | width, height, hideImage, hideDate, imageUrl, imageWidth, 17 | articles, responseArticles, headers, res 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-juejin-readme-rss", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/Flywith24/github-juejin-readme-rss.git" 8 | }, 9 | "author": "Flywith24", 10 | "license": "MIT", 11 | "devDependencies": { 12 | "@types/axios": "^0.14.0", 13 | "@vercel/node": "^1.15.4" 14 | }, 15 | "dependencies": { 16 | "@vercel/build-utils": "^5.3.1", 17 | "axios": "^0.21.2", 18 | "body-parser": "^1.20.0", 19 | "cors": "^2.8.5", 20 | "express": "^4.18.1", 21 | "glob": "^8.0.3" 22 | }, 23 | "description": "", 24 | "bugs": { 25 | "url": "https://github.com/Flywith24/github-juejin-readme-rss/issues" 26 | }, 27 | "homepage": "https://github.com/Flywith24/github-juejin-readme-rss#readme" 28 | } 29 | -------------------------------------------------------------------------------- /util/requestArticles.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | // @ts-ignore 3 | import moment from 'moment'; 4 | import {convertImg} from "./utils"; 5 | 6 | const rssHost = "https://api.rss2json.com/v1/api.json?rss_url=https://rsshub.app/" 7 | const host = "https://github-readme-juejin-recent-article-flywith24.vercel.app/" 8 | export const getJuejinArticles = async (id) => { 9 | const rssUrl = String(rssHost.concat("juejin/posts/".concat(id))); 10 | const { 11 | data: {items}, 12 | } = await axios.get(rssUrl); 13 | 14 | let articles: any[] = [] 15 | 16 | const url = host.concat("juejin.png"); 17 | const result = await convertImg(url); 18 | for (const element of items) { 19 | element.thumbnail = result 20 | articles.push(element) 21 | } 22 | return articles 23 | }; 24 | 25 | export const getXiaozhuanlanArticles = async (id) => { 26 | const rssUrl = String(rssHost.concat("xiaozhuanlan/column/".concat(id))); 27 | const { 28 | data: {items}, 29 | } = await axios.get(rssUrl); 30 | 31 | let articles: any[] = [] 32 | 33 | const url = host.concat("xiaozhuanlan.png"); 34 | const result = await convertImg(url); 35 | for (const element of items) { 36 | element.thumbnail = result 37 | articles.push(element) 38 | } 39 | return articles 40 | }; 41 | -------------------------------------------------------------------------------- /assets/articleCard.ts: -------------------------------------------------------------------------------- 1 | import {convertImg, readingTimeCalc} from "../util/utils"; 2 | 3 | export const ArticleCard = async (data, hideImage?, imageUrl?, hidDate?, imageW?) => { 4 | const {link, title, pubDate, content, thumbnail} = data 5 | const readingTime = readingTimeCalc(content); 6 | const url = imageUrl !== undefined && imageUrl != null ? await convertImg(imageUrl) : thumbnail 7 | const dateStyle = hidDate === "true" ? "display: none" : "" 8 | let imageStyle = "width: " + imageW + "px;" 9 | imageStyle = hideImage === "true" ? imageStyle + " display: none" : imageStyle 10 | 11 | console.log("imageStyle " + imageStyle) 12 | return ` 13 | 15 | 16 | 18 | 82 | 83 | 84 | 85 | 86 | ${title} 87 | ${pubDate} · ${readingTime} 88 | 89 | 90 | 91 | 92 | 93 | 94 | `; 95 | }; 96 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 前言 2 | 3 | Github README 主页有很多玩法,例如显示 star 和 commit 数等 4 | 5 | 之前一直感叹:「**如果在掘金发布的文章列表能自动同步到主页就好了**」 6 | 7 | > 每当你在感叹「如果有这样一个东西就好了」的时候,请注意:其实这是你的机会。 8 | 9 | 10 | 经过一番折腾,最终 [效果如下图](https://github.com/Flywith24): 11 | 12 |  13 | 14 | # 使用 15 | 16 | [接口文档](https://www.apifox.cn/apidoc/shared-52b5efee-61ee-413f-9e79-cb229415f31a/api-35458819) 17 | 18 |  19 | ## 掘金展示最近 N 篇文章 20 | 21 | ### 格式 22 | 23 | ```http 24 | https://github-readme-juejin-recent-article-flywith24.vercel.app/juejin?id=&limit= 25 | ``` 26 | 27 | - `id`:掘金 id(掘金个人首页 url 中 user 后面的数字) 28 | - `limit`:展示的文章数量 29 | 30 | ### 示例 31 | 32 | ``` markdown 33 | [](https://juejin.cn/user/219558054476792/posts) 34 | ``` 35 | > 在此示例中,`id` 为 219558054476792,`limit` 为 2,使用超链接将图片包裹,链接地址指向掘金个人主页 36 | 37 | 38 | 39 | ### 效果 40 | 41 |  42 | 43 | ## 小专栏展示最近 N 篇文章 44 | 45 | ### 格式 46 | 47 | ```http 48 | https://github-readme-juejin-recent-article-flywith24.vercel.app/xiaozhuanlan?id=&limit= 49 | ``` 50 | 51 | - `id`:专栏 id(专栏链接最后部分) 52 | 53 |  54 | 55 | - `limit`:展示的文章数量 56 | 57 | 58 | 59 | ### 示例 60 | 61 | ``` markdown 62 | [](https://xiaozhuanlan.com/detail) 63 | ``` 64 | 65 | > 在此示例中,`id` 为 detail,`limit` 为 2,使用超链接将图片包裹,链接地址指向专栏主页 66 | > 67 | 68 | 69 | 70 | ### 效果 71 | 72 |  73 | 74 | 75 | 76 | 某些场景我们希望只展示某几篇代表文章,此时可以使用「展示特定文章」 77 | 78 | ## 展示特定文章 79 | 80 | ### 格式 81 | 82 | ``` 83 | https://github-readme-juejin-recent-article-flywith24.vercel.app/{type}/{id}/{index} 84 | ``` 85 | 86 | - `type`:`juejin` 或 `xiaozhuanlan` 87 | - `id`:规则同上 88 | - `index`:位置,**0 代表最新** 的文章 89 | 90 | 91 | 92 | ### 示例 93 | 94 | ```markdown 95 | [](https://github-readme-juejin-recent-article-flywith24.vercel.app/juejin/219558054476792/6) 96 | 97 | [](https://github-readme-juejin-recent-article-flywith24.vercel.app/xiaozhuanlan/detail/7) 98 | ``` 99 | 100 | > 在此示例中,分别展示了第 7 和 第 8 篇文章 101 | > 102 | 103 | 104 | ### 效果 105 | 106 | [](https://github-readme-juejin-recent-article-flywith24.vercel.app/juejin/219558054476792/6) 107 | 108 | [](https://github-readme-juejin-recent-article-flywith24.vercel.app/xiaozhuanlan/detail/7) 109 | 110 | 111 | 112 | # 项目链接 113 | 114 | [项目链接](https://github.com/Flywith24/github-juejin-readme-rss) 115 | 116 | 欢迎各位 `star`,`fork`, `提 PR` 117 | 118 | # 部署自己的服务 119 | 120 | [](https://vercel.com/import/git?s=https://github.com/Flywith24/github-juejin-readme-rss) 121 | 122 | 123 | # 参考 124 | 125 | 感谢这几个项目提供的思路 126 | 127 | - [RSSHub](https://github.com/DIYgod/RSSHub) 128 | 129 | - [github-readme-medium](https://github.com/omidnikrah/github-readme-medium) 130 | 131 | - [github-readme-medium-recent-article](https://github.com/bxcodec/github-readme-medium-recent-article) 132 | -------------------------------------------------------------------------------- /util/utils.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import {ArticleCard} from "../assets/articleCard"; 3 | import {NowResponse, VercelResponse} from "@vercel/node"; 4 | 5 | const ansiWordBound = (c) => ( 6 | (' ' === c) || 7 | ('\n' === c) || 8 | ('\r' === c) || 9 | ('\t' === c) 10 | ); 11 | 12 | export const readingTimeCalc = (text) => { 13 | let words = 0; 14 | let start = 0; 15 | let end = text.length - 1; 16 | let i; 17 | 18 | const wordsPerMinute = 200; 19 | 20 | while (ansiWordBound(text[start])) start++ 21 | while (ansiWordBound(text[end])) end-- 22 | 23 | for (i = start; i <= end;) { 24 | for (; i <= end && !ansiWordBound(text[i]); i++) ; 25 | words++ 26 | for (; i <= end && ansiWordBound(text[i]); i++) ; 27 | } 28 | 29 | const minutes = words / wordsPerMinute; 30 | const displayed = Math.ceil(Number(minutes.toFixed(2))); 31 | 32 | return `${displayed} min read`; 33 | } 34 | 35 | export const asyncForEach = async (array, callback) => { 36 | for (let index = 0; index < array.length; index++) { 37 | await callback(array[index], index, array) 38 | } 39 | }; 40 | 41 | 42 | export async function composeSvg( 43 | limit: string | string[], 44 | width: string | string[], 45 | height: string | string[], 46 | hideImage: string | string[], 47 | hideDate: string | string[], 48 | imageUrl: string | string[], 49 | imageWidth: string | string[], 50 | articles: any[], 51 | responseArticles: any[], 52 | headers, 53 | res: NowResponse & { 54 | send: (body: any) => VercelResponse; json: (jsonBody: any) => VercelResponse; status: (statusCode: number) => VercelResponse; redirect: (statusOrUrl: (string | number), url?: string) => VercelResponse 55 | }) { 56 | if (limit) { 57 | articles = [...responseArticles.slice(0, Number(limit))]; 58 | } else { 59 | articles = [responseArticles[0]]; 60 | } 61 | 62 | const w = width !== undefined ? String(width) : "700"; 63 | const h = height !== undefined ? Number(height) : 120 64 | const hideD = hideDate !== undefined ? hideDate : false 65 | const hideImg = hideImage !== undefined ? hideImage : false 66 | const imageW = imageWidth !== undefined ? String(imageWidth) : "150"; 67 | 68 | const dest = headers['sec-fetch-dest'] || headers['Sec-Fetch-Dest']; 69 | const accept = headers['accept']; 70 | const isImage = dest ? dest === 'image' : !/text\/html/.test(accept); 71 | 72 | let result = ``; 73 | 74 | await asyncForEach(articles, async (article, index) => { 75 | const articleCard = await ArticleCard(article, hideImg, imageUrl, hideD, imageW); 76 | result += `${articleCard}`; 77 | }); 78 | 79 | result += ``; 80 | 81 | if (isImage) { 82 | res.setHeader('Cache-Control', 's-maxage=3600, stale-while-revalidate'); 83 | res.setHeader('Content-Type', 'image/svg+xml'); 84 | return res.send(result); 85 | } 86 | 87 | res.writeHead(200, {'Content-Type': 'image/svg+xml'}); 88 | res.end(); 89 | } 90 | 91 | export async function generateSvg( 92 | index: string | string[], 93 | width: string | string[], 94 | height: string | string[], 95 | hideImage: string | string[], 96 | hideDate: string | string[], 97 | imageUrl: string | string[], 98 | imageWidth: string | string[], 99 | articles: any[], 100 | headers, 101 | res: NowResponse & { 102 | send: (body: any) => VercelResponse; json: (jsonBody: any) => VercelResponse; status: (statusCode: number) => VercelResponse; redirect: (statusOrUrl: (string | number), url?: string) => VercelResponse 103 | }) { 104 | 105 | let article 106 | if (index) { 107 | article = articles[Number(index)]; 108 | } else { 109 | article = articles[0]; 110 | } 111 | 112 | const w = width !== undefined ? String(width) : "700"; 113 | const h = height !== undefined ? String(height) : "120" 114 | const hideD = hideDate !== undefined ? hideDate : false 115 | const hideImg = hideImage !== undefined ? hideImage : false 116 | const imageW = imageWidth !== undefined ? String(imageWidth) : "150"; 117 | 118 | const dest = headers['sec-fetch-dest'] || headers['Sec-Fetch-Dest']; 119 | const accept = headers['accept']; 120 | const isImage = dest ? dest === 'image' : !/text\/html/.test(accept); 121 | 122 | let result = ``; 123 | const articleCard = await ArticleCard(article, hideImg, imageUrl, hideD, imageW); 124 | result += `${articleCard}`; 125 | result += ``; 126 | 127 | if (isImage) { 128 | res.setHeader('Cache-Control', 's-maxage=3600, stale-while-revalidate'); 129 | res.setHeader('Content-Type', 'image/svg+xml'); 130 | return res.send(result); 131 | } 132 | 133 | res.writeHead(301, {Location: article.link}); 134 | res.end(); 135 | } 136 | 137 | export async function convertImg(url: string) { 138 | const {data: thumbnailRaw} = await axios.get(url, { 139 | responseType: 'arraybuffer', 140 | }); 141 | 142 | const base64Img = Buffer.from(thumbnailRaw).toString('base64'); 143 | const imgTypeArr = url.split('.'); 144 | const imgType = imgTypeArr[imgTypeArr.length - 1]; 145 | return `data:image/${imgType};base64,${base64Img}`; 146 | } 147 | --------------------------------------------------------------------------------