├── .husky └── commit-msg ├── commitlint.config.mjs ├── doc └── images │ └── telegram.png ├── src ├── utils │ └── slugify.ts ├── bindings.d.ts ├── lib │ ├── link_preview.ts │ ├── telegram.ts │ ├── llm.ts │ └── github.ts ├── index.ts ├── api.ts └── model.ts ├── .prettierignore ├── .gitignore ├── .prettierrc ├── tsconfig.json ├── tests └── date.ts ├── package.json ├── LICENSE ├── wrangler.toml.sample └── README.md /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | pnpm dlx commitlint --edit $1 2 | -------------------------------------------------------------------------------- /commitlint.config.mjs: -------------------------------------------------------------------------------- 1 | export default { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /doc/images/telegram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethan4768/wise-favorites-worker/HEAD/doc/images/telegram.png -------------------------------------------------------------------------------- /src/utils/slugify.ts: -------------------------------------------------------------------------------- 1 | import kebabcase from "lodash.kebabcase"; 2 | 3 | export const slugify = (str: string) => kebabcase(str); 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore everything 2 | /* 3 | 4 | # Except these files & folders 5 | !/src 6 | !/public 7 | !/.github 8 | !tsconfig.json 9 | !package.json 10 | !.prettierrc 11 | !eslint.config.mjs 12 | !README.md -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .wrangler 4 | .dev.vars 5 | 6 | # Change them to your taste: 7 | package-lock.json 8 | yarn.lock 9 | pnpm-lock.yaml 10 | bun.lockb 11 | 12 | # Mac 13 | .DS_Store 14 | 15 | # config 16 | wrangler.toml 17 | 18 | .idea 19 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "semi": true, 4 | "tabWidth": 2, 5 | "printWidth": 80, 6 | "singleQuote": false, 7 | "jsxSingleQuote": false, 8 | "trailingComma": "es5", 9 | "bracketSpacing": true, 10 | "endOfLine": "lf", 11 | "plugins": [], 12 | "overrides": [] 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "Bundler", 6 | "strict": false, 7 | "lib": ["ESNext"], 8 | "types": ["@cloudflare/workers-types", "node"], 9 | "jsx": "react-jsx", 10 | "jsxImportSource": "hono/jsx" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/bindings.d.ts: -------------------------------------------------------------------------------- 1 | // noinspection SpellCheckingInspection 2 | export type Bindings = { 3 | LINKPREVEW: Record; 4 | OPENAI: Record; 5 | TELEGRAM: Record; 6 | GITHUB: Record; 7 | BEARER_TOKENS: string[]; 8 | TAGS: string[]; 9 | OVERRIDE_TITLE_DOMAINS: string[]; 10 | }; 11 | -------------------------------------------------------------------------------- /tests/date.ts: -------------------------------------------------------------------------------- 1 | import { formatISO } from "date-fns"; 2 | import { getTime } from "date-fns"; 3 | import { TZDate } from "@date-fns/tz"; 4 | 5 | const date = new Date() 6 | console.log(date.toISOString()) 7 | console.log(formatISO(date)) // 2024-10-16T23:42:06+08:00 8 | console.log(getTime(date)) // 1729093326398 in ms 9 | 10 | const tzDate = new TZDate(date, "Asia/Shanghai"); 11 | const tzDate2 = new TZDate(date, "America/Los_Angeles"); 12 | console.log(formatISO(tzDate)) 13 | console.log(formatISO(tzDate2)) 14 | -------------------------------------------------------------------------------- /src/lib/link_preview.ts: -------------------------------------------------------------------------------- 1 | export async function linkPreview( 2 | linkPreviewConfig: Record, 3 | url: string 4 | ): Promise { 5 | const apiKey = linkPreviewConfig["API_KEY"]; 6 | try { 7 | const linkPreviewUrl = `https://api.linkpreview.net/?q=${encodeURIComponent(url)}`; 8 | const response = await fetch(linkPreviewUrl, { 9 | headers: { "X-Linkpreview-Api-Key": apiKey }, 10 | }); 11 | return response.json(); 12 | } catch (error) { 13 | console.error("Cannot request LinkPreview, ", error); 14 | return null; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wise-favorites-worker", 3 | "version": "1.0.4", 4 | "private": false, 5 | "scripts": { 6 | "dev": "wrangler dev src/index.ts --port 8787", 7 | "deploy": "wrangler deploy --minify src/index.ts", 8 | "format": "prettier --write .", 9 | "prepare": "husky" 10 | }, 11 | "dependencies": { 12 | "@date-fns/tz": "^1.1.2", 13 | "date-fns": "^4.1.0", 14 | "hono": "^4.6.5", 15 | "lodash.kebabcase": "^4.1.1", 16 | "openai": "^4.67.3", 17 | "zod": "^3.23.8" 18 | }, 19 | "devDependencies": { 20 | "@cloudflare/workers-types": "^4.20241004.0", 21 | "@commitlint/cli": "^19.6.0", 22 | "@commitlint/config-conventional": "^19.6.0", 23 | "@types/node": "^22.7.5", 24 | "husky": "^9.1.7", 25 | "prettier": "3.3.3", 26 | "ts-node": "^10.9.2", 27 | "tsx": "^4.19.1", 28 | "typescript": "^5.6.3", 29 | "wrangler": "^3.80.4" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 ethan4768 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /wrangler.toml.sample: -------------------------------------------------------------------------------- 1 | name = "wise-favorites-worker" 2 | compatibility_flags = ["nodejs_compat"] 3 | compatibility_date = "2024-09-23" 4 | 5 | [observability] 6 | enabled = true 7 | 8 | #---------------------------------------- 9 | # lines below are what you should modify 10 | #---------------------------------------- 11 | 12 | [vars] 13 | 14 | # API AUTH 15 | BEARER_TOKENS = ["token_1", "token_2"] 16 | 17 | # preset tags, chatgpt will choose 2~5 tags from this list 18 | TAGS = [ 19 | "opensource", 20 | "awesome", 21 | "buildinpublic", 22 | "mactips", 23 | "iOStips", 24 | "dev", 25 | "startup", 26 | "tool", 27 | "AI", 28 | "chatgpt", 29 | "LLM", 30 | "RAG", 31 | "cloudflare", 32 | ] 33 | 34 | # LinkPreview ACCESS KEY, visit https://www.linkpreview.net/ 35 | [vars.LINKPREVEW] 36 | API_KEY = "changethis" # access key in LinkPreview 37 | 38 | # OPENAI 39 | [vars.OPENAI] 40 | API_KEY = "changethis" 41 | BASE_PATH = "https://api.openai.com/v1" 42 | MODEL = "gpt-4o-mini" 43 | 44 | # send message to telegram, need bot token & channel_id. 45 | # optional 46 | [vars.TELEGRAM] 47 | BOT_TOKEN = "changethis" 48 | CHANNEL_ID = "changethis" 49 | 50 | # send to github 51 | # optional 52 | [vars.GITHUB] 53 | ACCESS_TOKEN = "changethis" 54 | OWNER = "changethis" 55 | REPO = "changethis" -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import { bearerAuth } from "hono/bearer-auth"; 3 | import { html } from "hono/html"; 4 | import { HTTPException } from "hono/http-exception"; 5 | import { logger } from "hono/logger"; 6 | import api from "./api"; 7 | 8 | const app = new Hono(); 9 | 10 | // Mount Builtin Middleware 11 | app.use("*", logger()); 12 | // auth 13 | app.use( 14 | "/api/*", 15 | bearerAuth({ 16 | verifyToken: (token, c) => { 17 | return c.env?.BEARER_TOKENS.includes(token); 18 | }, 19 | }) 20 | ); 21 | 22 | // Custom Middleware 23 | 24 | // Add X-Response-Time header 25 | app.use("*", async (c, next) => { 26 | const start = Date.now(); 27 | await next(); 28 | const ms = Date.now() - start; 29 | c.header("X-Response-Time", `${ms}ms`); 30 | }); 31 | 32 | // Custom Not Found Message 33 | app.notFound(c => { 34 | return c.text("404 Not Found", 404); 35 | }); 36 | 37 | // Error handling 38 | app.onError((err, c) => { 39 | console.error(`${err}`); 40 | if (err instanceof HTTPException) { 41 | return err.getResponse(); 42 | } 43 | return c.text("Something Error", 500); 44 | }); 45 | 46 | // Default Page 47 | app.get("/", c => { 48 | return c.render(html`

Wise Favorite

`); 49 | }); 50 | 51 | // API 52 | app.route("/api", api); 53 | 54 | export default app; 55 | -------------------------------------------------------------------------------- /src/lib/telegram.ts: -------------------------------------------------------------------------------- 1 | import { Favorite } from "../model"; 2 | 3 | export async function postToTelegram( 4 | telegramConfig: Record, 5 | result: Favorite, 6 | ): Promise { 7 | const botToken = telegramConfig.BOT_TOKEN; 8 | const channelId = telegramConfig.CHANNEL_ID; 9 | 10 | if (!botToken || !channelId) { 11 | return false; 12 | } 13 | 14 | try { 15 | const url = `https://api.telegram.org/bot${botToken}/sendMessage`; 16 | const response = await fetch(url, { 17 | method: "POST", 18 | headers: { 19 | "Content-Type": "application/json", 20 | }, 21 | body: JSON.stringify({ 22 | chat_id: channelId, 23 | parse_mode: "MarkdownV2", 24 | text: toTelegramFormat(result), 25 | }), 26 | }); 27 | return response.status == 200; 28 | } catch (error) { 29 | console.error(error); 30 | return false; 31 | } 32 | } 33 | 34 | function toTelegramFormat(favorite: Favorite): string { 35 | const hashTags = favorite.tags 36 | .map(tag => `#${tag.replace(/ /g, "-")}`) 37 | .join(" "); 38 | const fixupXUrl = favorite.url 39 | .replace("x.com", "fixupx.com") 40 | .replace("twitter.com", "fxtwitter.com"); 41 | 42 | return `${hashTags || ""} 43 | 44 | *${escapeMarkdownV2(favorite.title)}* 45 | 46 | ${escapeMarkdownV2(favorite.description || "")} 47 | 48 | 👉 ${escapeMarkdownV2(fixupXUrl)}`; 49 | } 50 | 51 | function escapeMarkdownV2(text: string): string { 52 | const specialCharacters = /[._*[\]()`~>#\-=|{}!\\]/g; 53 | return text.replace(specialCharacters, "\\$&"); 54 | } 55 | -------------------------------------------------------------------------------- /src/api.ts: -------------------------------------------------------------------------------- 1 | import { Context, Hono } from "hono"; 2 | import { Bindings } from "./bindings"; 3 | import { sendToGithub } from "./lib/github"; 4 | import { linkPreview } from "./lib/link_preview"; 5 | import { getLLMResult } from "./lib/llm"; 6 | import { postToTelegram } from "./lib/telegram"; 7 | import { Favorite, RequestParam } from "./model"; 8 | import { slugify } from "./utils/slugify"; 9 | 10 | const api = new Hono<{ Bindings: Bindings }>(); 11 | 12 | api.post("/favorite", async c => { 13 | const params: RequestParam = await c.req.json(); 14 | console.log(params); 15 | return await post(c, params); 16 | }); 17 | 18 | async function post(c: Context<{ Bindings: Bindings }>, params: RequestParam) { 19 | const { url, title, description, content, image, timestamp, options } = params; 20 | 21 | if (!url) { 22 | return c.json({ code: 400, msg: "url param required" }, 400); 23 | } 24 | 25 | const favorite = new Favorite(url, title, description, content, image, timestamp, options); 26 | 27 | if (!title || !description) { 28 | const previewResult = await linkPreview(c.env.LINKPREVEW, url); 29 | console.log(previewResult); 30 | if ((!previewResult || !previewResult["title"]) && !title && !description) { 31 | return c.json({ 32 | code: 50001, 33 | msg: "preview failed.", 34 | data: JSON.stringify(favorite), 35 | }); 36 | } 37 | favorite.addPreviewResult(previewResult); 38 | } 39 | 40 | if (options?.llm ?? false) { 41 | const llmResult = await getLLMResult(c.env.OPENAI, c.env.TAGS, favorite); 42 | if (llmResult) { 43 | favorite.addLLMResult(llmResult); 44 | } 45 | } 46 | 47 | if (!favorite.slug) { 48 | favorite.slug = slugify(favorite.title); 49 | } 50 | 51 | if (options?.share?.telegram ?? true) { 52 | favorite.shared.telegram = await postToTelegram(c.env.TELEGRAM, favorite); 53 | } 54 | if (options?.share?.github ?? true) { 55 | favorite.shared.github = await sendToGithub(c.env.GITHUB, favorite); 56 | } 57 | 58 | const result = c.json({ 59 | code: 0, 60 | msg: "succeeded", 61 | data: favorite, 62 | }); 63 | console.log(result); 64 | return result; 65 | } 66 | 67 | export default api; 68 | -------------------------------------------------------------------------------- /src/model.ts: -------------------------------------------------------------------------------- 1 | import { parse } from "date-fns"; 2 | import { z } from "zod"; 3 | 4 | export type RequestParam = { 5 | url: string; 6 | title?: string; 7 | description?: string; 8 | content?: string; 9 | image?: string; 10 | timestamp?: string; 11 | options?: { 12 | arsp?: boolean; // Automatically retrieve and summarize posts 13 | share?: { 14 | telegram?: boolean; 15 | github?: boolean; 16 | }; 17 | llm?: boolean; 18 | }; 19 | }; 20 | 21 | export class Favorite { 22 | url: string; 23 | title: string; 24 | slug: string; 25 | description: string; 26 | content: string; 27 | arsp: boolean; 28 | image: string; 29 | tags: string[] = []; 30 | shared: { 31 | telegram?: boolean; 32 | github?: boolean; 33 | }; 34 | timestamp: Date; 35 | 36 | constructor( 37 | url: string, 38 | title: string = "", 39 | description: string = "", 40 | content: string = "", 41 | image: string, 42 | timestamp: string, 43 | options: Record, 44 | ) { 45 | this.url = url; 46 | this.title = title; 47 | this.description = description; 48 | this.content = content; 49 | this.image = image; 50 | if (timestamp) { 51 | const formatString = "yyyy-MM-dd HH:mm:ss.SSS"; 52 | this.timestamp = parse(timestamp, formatString, new Date()); 53 | } else { 54 | this.timestamp = new Date(); 55 | } 56 | this.arsp = options?.arsp ?? false; 57 | this.shared = { telegram: false, github: false }; 58 | } 59 | 60 | addPreviewResult(previewResult: JSON) { 61 | if (!this.title) { 62 | this.title = previewResult["title"]; 63 | } 64 | if (!this.description) { 65 | this.description = previewResult["description"]; 66 | } 67 | if (previewResult["image"]) { 68 | this.image = previewResult["image"]; 69 | } 70 | if (previewResult["url"]) { // 有些页面会被 forbidden 71 | this.url = previewResult["url"]; 72 | } 73 | } 74 | 75 | addLLMResult(llmResult: LLMResult) { 76 | this.tags = [...this.tags, ...llmResult.tags]; 77 | this.slug = llmResult.slug; 78 | this.title = llmResult.improved_title; 79 | this.description = llmResult.improved_description; 80 | if (!this.content || this.content === "") { 81 | this.content = llmResult.improved_content; 82 | } 83 | } 84 | } 85 | 86 | export const LLMResultSchema = z.object({ 87 | tags: z.array(z.string()), 88 | slug: z.string(), 89 | improved_title: z.string(), 90 | improved_description: z.string(), 91 | improved_content: z.string().nullable(), 92 | }); 93 | 94 | export type LLMResult = z.infer; 95 | -------------------------------------------------------------------------------- /src/lib/llm.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from "openai"; 2 | import { Favorite, LLMResult } from "../model"; 3 | import { slugify } from "../utils/slugify"; 4 | 5 | export async function getLLMResult( 6 | openAIConfig: Record, 7 | presetTags: string[], 8 | favorite: Favorite 9 | ): Promise { 10 | if (!openAIConfig["BASE_PATH"] || !openAIConfig["API_KEY"]) { 11 | return null 12 | } 13 | 14 | const openai = new OpenAI({ 15 | baseURL: openAIConfig["BASE_PATH"], 16 | apiKey: openAIConfig["API_KEY"], 17 | }); 18 | 19 | const systemPrompt = ` 20 | generate tags, slug, an improved title, an improved description, an improved content based on given URL, title, description and content 21 | 22 | tags: Select 2-5 of the most relevant tags from the following preset list: ${presetTags}, and feel free to add more if needed, every tag should not have any hyphens 23 | slug: url friendly 24 | improved_title: Respond in Chinese, retain proper nouns such as prompt, AI, LLM, should be no longer than 60 words 25 | improved_description: Respond in Chinese, retain proper nouns such as prompt, AI, LLM, should be no longer than 160 words 26 | improved_content: Generate improved content if content not provided, respond in Chinese, retain proper nouns such as prompt, AI, LLM, as detailed as possible, without missing any information 27 | 28 | Respond directly in JSON format, without using Markdown code blocks or any other formatting, the JSON schema should include tags, slug, improved_title, improved_description 29 | Example: 30 | { 31 | "tags": ["AI", "dev", "tool", "writing"], 32 | "slug": "effective-prompt-engineering", 33 | "improved_title": "微软开源 OmniParser:解析和识别屏幕上可交互图标的工具", 34 | "improved_description": "微软开源了一款可以解析和识别屏幕上可交互图标的工具:OmniParser,它能准确的识别出用户界面中的可交互图标,在解析方面优于 GPT-4V", 35 | "improved_content": "微软开源了一款可以解析和识别屏幕上可交互图标的工具:OmniParser,它能准确的识别出用户界面中的可交互图标,在解析方面优于 GPT-4V\n特点:\n1、双重识别能力,能找出界面上所有可以点击的地方,具备语义理解能力,能理解按钮或图标的具体功能\n2、可以作为插件,与 Phi-3.5-V、Llama-3.2-V 以及其他模型结合使用\n3、支持结构化输出,除了识别屏幕上的元素,还能将这些元素转换成结构化的数据\ngithub:https://github.com/microsoft/OmniParser\n项目:https://microsoft.github.io/OmniParser/" 36 | } 37 | `; 38 | 39 | const userMessage = ` 40 | url: ${favorite.url} 41 | title: ${favorite.title} 42 | description: ${favorite.description} 43 | content: 44 | --- 45 | ${favorite.content} 46 | `; 47 | 48 | try { 49 | const completion = await openai.chat.completions.create({ 50 | model: openAIConfig["MODEL"], 51 | messages: [ 52 | { 53 | role: "system", 54 | content: systemPrompt, 55 | }, 56 | { 57 | role: "user", 58 | content: userMessage, 59 | }, 60 | ], 61 | response_format: { type: "json_object" }, 62 | }); 63 | 64 | const response = completion.choices[0].message.content; 65 | console.log(response); 66 | const result: LLMResult = JSON.parse(response); 67 | result.tags = (result.tags ?? []) 68 | .map(tag => slugify(tag)) // 将空格替换为 - 69 | .filter(tag => tag.length <= 20); // 过滤掉字符数超过 20 的 tag;防止某些大模型给出不标准的答案 70 | return result; 71 | } catch (e) { 72 | console.error("Cannot request llm, ", e.message); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Wise Favorites Worker 2 | 3 | 一个提供网络收藏、AI 标签分类、跨平台分享的方案,部署在 Cloudflare workers 上。 4 | 5 | ## 功能 6 | 7 | 1. 通过 API 方便快捷收藏自己喜欢的网络内容 8 | 2. 根据 URL 获取网页基本信息,title, description, ogImage 等 9 | 3. 借助大模型给收藏打标签 10 | 4. 分享到 telegram 11 | - twitter 会实现 instant view 效果 12 | 5. 同步到 github 13 | 14 | 同步到 github 后,可参考 [https://github.com/ethan4768/blog](https://github.com/ethan4768/blog) 实现以下功能 15 | 16 | 1. [x] 自动下载网页内容,使用 17 | 2. [x] AI 总结,提取关键内容 18 | 3. [x] 自动发布到博客 19 | 4. [ ] 与收藏的内容进行对话 20 | 21 | ## 部署 22 | 23 | 1. clone 此项目,将`wrangler.toml.sample`复制为`wrangler.toml`,然后修改配置 24 | 2. 执行`pnpm run deploy`部署到 cloudflare workers 中,部署完成后留意给出的 workers 地址 25 | 3. 根据需要配置自定义域名 26 | 27 | ### Telegram 28 | 29 | 配置项在`[vars.TELEGRAM]`下,任意一项不配置时,不进行推送。 30 | 31 | 1. BOT_TOKEN: 通过 BotFather 创建 Bot,BotFather 会提供一个 token,API 请求需要使用该 token。 32 | 2. CHANNEL_ID: 33 | - 如果 channel 是公开的,那么它的 id 就是它的用户名(例如 @mychannel); 34 | - 如果 channel 是私有的,那么需要使用一些工具或者 API 来获取它的 id。 35 | 36 | ### Github 37 | 38 | 配置项在`[vars.GITHUB]`下,任意一项不配置时,不进行推送。 39 | 40 | **权限** 41 | 为安全隔离考虑,尽量创建新的 [personal access token](https://github.com/settings/personal-access-tokens/new) 42 | 43 | 1. 为了权限最小化,可以指定 repo 44 | 2. repo 权限设置,将 Actions, Contents, Workflows 三项设置为 **Read and write** 45 | 46 | ## 使用 47 | 48 | 提供 API,在此基础上根据场景有很多不同玩法 49 | 50 | ### API 方式 51 | 52 | ```shell 53 | curl --request POST \ 54 | --url https://${your cloudlfare path}/api/favorite \ 55 | --header 'Authorization: Bearer ${your token}' \ 56 | --header 'Content-Type: application/json' \ 57 | --data '{ 58 | "url": "https://docs.cohere.com/docs/prompt-engineering", 59 | "options": { 60 | "arsp": false, 61 | "share": { 62 | "telegram": false, 63 | "github": false 64 | } 65 | } 66 | }' 67 | ``` 68 | 69 | 结果示例: 70 | 71 | ```json 72 | { 73 | "code": 0, 74 | "msg": "succeeded", 75 | "data": { 76 | "url": "https://docs.cohere.com/docs/crafting-effective-prompts", 77 | "title": "高效提示词的制作:Cohere 实用指南", 78 | "slug": "crafting-effective-prompts", 79 | "description": "本页面介绍了多种制作高效提示词的方法,帮助用户在提示工程中获得最佳结果。无论是针对 AI、聊天机器人还是其他自然语言处理模型,掌握这些技巧将显著提升交互的质量和效率。", 80 | "arsp": false, 81 | "image": "https://fdr-prod-docs-files-public.s3.amazonaws.com/cohere.docs.buildwithfern.com/2024-11-07T21:19:13.731Z/assets/images/f1cc130-cohere_meta_image.jpg", 82 | "tags": [ 83 | "ai", 84 | "tool", 85 | "writing", 86 | "chatgpt", 87 | "llm" 88 | ], 89 | "shared": { 90 | "telegram": false, 91 | "github": false 92 | }, 93 | "timestamp": "2024-11-08T03:39:13.902Z" 94 | } 95 | } 96 | ``` 97 | 98 | telegram 中效果: 99 | 100 | ![telegram](./doc/images/telegram.png) 101 | 102 | ### 快捷指令 103 | 104 | 在 iPhone 上使用时,可以借助快捷指令实现。 105 | 106 | 1. 添加此快捷指令 [Share to Telegram](https://www.icloud.com/shortcuts/615b96ec27ed483f8b53bfeb117927a1) 107 | 2. 将第二个操作中地址改为你的地址,例如:https://wise-favorites.xxx.wokers.dev/api/favorite 108 | 3. 将 Header 中 Authorization 值改为`Bearer ${your token}`,注意空格。 109 | 4. 点击完成,在 Safari 或 Twitter 分享中下拉可以找到`Share to Telegram`项。第一次使用,会出现发送请求的授权,点击始终允许即可。 110 | 111 | ### Chrome 浏览器插件 112 | 113 | 使用 [https://github.com/ethan4768/wise-favorites-extension](https://github.com/ethan4768/wise-favorites-extension) 114 | 115 | ## 实现逻辑 116 | 117 | 1. 获取此链接的一些 meta 信息 118 | 2. 将这些信息喂给 LLM,由 LLM 返回 tags, slug, improved_title, improved_description 119 | 3. 推送到 Telegram, github 等 120 | 121 | 根据我的使用情况,每次请求会消耗 200 到 300 个 token。 122 | 123 | ## 使用的技术&服务 124 | 125 | - Cloudflare Worker,托管 126 | - [Hono](https://hono.dev/):简易的前端框架 127 | - [LinkPreview](https://www.linkpreview.net/) 抓取页面内容,此服务提供免费计划,无须信用卡,个人使用足够。 128 | -------------------------------------------------------------------------------- /src/lib/github.ts: -------------------------------------------------------------------------------- 1 | import { TZDate } from "@date-fns/tz"; 2 | import { format, formatISO } from "date-fns"; 3 | import { Buffer } from "node:buffer"; 4 | import { Favorite } from "../model"; 5 | 6 | export async function sendToGithub( 7 | githubConfig: Record, 8 | favorite: Favorite, 9 | ): Promise { 10 | const accessToken = githubConfig["ACCESS_TOKEN"]; 11 | const owner = githubConfig["OWNER"]; 12 | const repo = githubConfig["REPO"]; 13 | 14 | if (!accessToken || !owner || !repo) { 15 | return false; 16 | } 17 | 18 | try { 19 | const filePath = getFilepath(favorite); 20 | const response = await getContentOrCreate({ 21 | accessToken, 22 | owner, 23 | repo, 24 | filePath: filePath, 25 | }); 26 | const data = await response.json(); 27 | const content = Buffer.from(data.content ?? "", "base64").toString(); 28 | 29 | const message = getCommitMessage(favorite); 30 | const newContent = generateFileContent(favorite); 31 | const updatedContent = Buffer.from(`${newContent}\n${content}`).toString( 32 | "base64", 33 | ); 34 | 35 | const writeResult = await writeContent({ 36 | accessToken, 37 | owner, 38 | repo, 39 | filePath, 40 | previousSha: data.sha, 41 | message: message, 42 | content: updatedContent, 43 | }); 44 | if (writeResult.ok) { 45 | return true; 46 | } else { 47 | console.error(writeResult); 48 | } 49 | } catch (error) { 50 | console.error(error); 51 | } 52 | return false; 53 | } 54 | 55 | async function getContentOrCreate({ accessToken, owner, repo, filePath }) { 56 | let response = await getContent({ accessToken, owner, repo, filePath }); 57 | 58 | // create if not exists 59 | if (response.status === 404) { 60 | console.log(`[rest-api] ${filePath} does not exist, create it`); 61 | const writeResult = await writeContent({ 62 | accessToken, 63 | owner, 64 | repo, 65 | filePath, 66 | previousSha: undefined, 67 | message: `[skip ci]create file: ${filePath}`, 68 | content: "", 69 | }); 70 | if (!writeResult.ok) { 71 | throw new Error("create-contents-failed"); 72 | } 73 | } 74 | 75 | // 重新获取一次,因为更新需要 sha 76 | return getContent({ accessToken, owner, repo, filePath }); 77 | } 78 | 79 | async function writeContent({ 80 | accessToken, 81 | owner, 82 | repo, 83 | filePath, 84 | previousSha, 85 | message, 86 | content, 87 | }) { 88 | const url = `https://api.github.com/repos/${owner}/${repo}/contents/${filePath}`; 89 | 90 | return fetch(url, { 91 | method: "PUT", 92 | headers: { 93 | Authorization: `Bearer ${accessToken}`, 94 | "X-GitHub-Api-Version": "2022-11-28", 95 | Accept: "application/vnd.github+json", 96 | "User-Agent": "wise-favorites-worker", 97 | }, 98 | body: JSON.stringify({ 99 | message: message, 100 | sha: previousSha, 101 | content: content, 102 | }), 103 | }); 104 | } 105 | 106 | async function getContent({ accessToken, owner, repo, filePath }) { 107 | const url = `https://api.github.com/repos/${owner}/${repo}/contents/${filePath}`; 108 | 109 | return fetch(url, { 110 | headers: { 111 | Authorization: `Bearer ${accessToken}`, 112 | "X-GitHub-Api-Version": "2022-11-28", 113 | Accept: "application/vnd.github+json", 114 | "User-Agent": "wise-favorites-worker", 115 | }, 116 | }); 117 | } 118 | 119 | function getFilepath(favorite: Favorite) { 120 | const currentMonth = format(favorite.timestamp, "yyyy-MM"); 121 | return `src/content/bookmarks/${currentMonth}/${favorite.slug}.md`; 122 | } 123 | 124 | function getCommitMessage(favorite: Favorite) { 125 | const body = getJSON(favorite); 126 | const arsp = favorite.arsp ?? false; 127 | const actionFlag = arsp ? "[ARSP]" : "[skip actions]"; 128 | return `[API][bookmark]${actionFlag} ${favorite.title}\n\n${JSON.stringify(body)}`; 129 | } 130 | 131 | function generateFileContent(favorite: Favorite) { 132 | const metadata = favorite.arsp 133 | ? `[原文链接](${favorite.url}) | [原文内容](../raw/${favorite.slug}) | [AI 总结](../summary/${favorite.slug})` 134 | : `[原文链接](${favorite.url})`; 135 | 136 | return `--- 137 | title: ${favorite.title} 138 | slug: ${favorite.slug} 139 | description: ${favorite.description} 140 | tags: \n${favorite.tags.map(tag => ` - ${tag}`).join('\n')} 141 | pubDatetime: ${formatISO(new TZDate(favorite.timestamp, "Asia/Shanghai"))} 142 | ogImage: ${favorite.image} 143 | --- 144 | 145 | ${metadata} 146 | 147 | --- 148 | 149 | ${favorite.content} 150 | `; 151 | } 152 | 153 | function getJSON(favorite: Favorite) { 154 | const dateWithTimeZone = new TZDate(favorite.timestamp, "Asia/Shanghai"); 155 | return { 156 | title: favorite.title, 157 | url: favorite.url, 158 | slug: favorite.slug, 159 | description: favorite.description, 160 | tags: favorite.tags, 161 | image: favorite.image, 162 | timestamp: formatISO(dateWithTimeZone), 163 | }; 164 | } --------------------------------------------------------------------------------