├── .npmrc ├── .editorconfig ├── .prettierrc ├── .gitignore ├── blacklist.txt ├── .github └── workflows │ └── bot.yml ├── src ├── getTypes.ts ├── bot.ts ├── siteUrlVerify.ts ├── index.ts ├── config.ts └── utils.ts ├── package.json ├── LICENSE └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | # for pnpm 2 | strict-peer-dependencies=false 3 | shamefully-hoist=true 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | max_line_length = 140 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | max_line_length = 0 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 140, 3 | "singleQuote": true, 4 | "trailingComma": "es5", 5 | "proseWrap": "preserve", 6 | "semi": true, 7 | "bracketSpacing": true, 8 | "arrowParens": "avoid", 9 | "tabWidth": 2, 10 | "overrides": [ 11 | { 12 | "files": ".prettierrc", 13 | "options": { 14 | "parser": "json" 15 | } 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .grs.config.js 2 | npm-debug.log 3 | .DS_Store 4 | /release/ 5 | /debug/ 6 | /dist/ 7 | /esm 8 | /cjs 9 | /docs 10 | /config 11 | /cache 12 | tmp 13 | coverage 14 | package-lock.json 15 | yarn.lock 16 | yarn-error.log 17 | pnpm-lock.yaml 18 | .pnpm-debug.log 19 | node_modules 20 | build 21 | eslintWhitelist.json 22 | tsCheckWhiteList.json 23 | .nyc_output 24 | .husky/post-commit 25 | .env 26 | -------------------------------------------------------------------------------- /blacklist.txt: -------------------------------------------------------------------------------- 1 | # 黑名单 - 已失效站点列表 2 | https://x.com/i/broadcasts/1gqGvjeBljOGB 3 | https://kuaiyi.wps.cn/txt-translate 4 | https://www.pandagpt.io 5 | https://beatbot.fm 6 | https://mp.weixin.qq.com/s/fZtFbxkHvmyQPykc81pydw 7 | https://docs.qq.com/form/page/DQnJoYkdBVWRCT0tS 8 | https://mp.weixin.qq.com/s/fZtFbxkHvmyQPykc81pydw 9 | https://kuaiyi.wps.cn/txt-translate 10 | https://6pen.art 11 | https://chat.alpaca-bi.com 12 | https://www.cheggmate.ai 13 | https://pensoulai.com 14 | -------------------------------------------------------------------------------- /.github/workflows/bot.yml: -------------------------------------------------------------------------------- 1 | name: bot 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | - cron: '0 20 * * *' 6 | jobs: 7 | sync: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@main 11 | - uses: actions/setup-node@v3 12 | with: 13 | node-version: 18 14 | - name: bot-run 15 | run: | 16 | npm i --no-package-lock --no-audit --no-fund 17 | npm start -- --only-new 18 | env: 19 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 20 | SYNC: 1 21 | -------------------------------------------------------------------------------- /src/getTypes.ts: -------------------------------------------------------------------------------- 1 | import { config } from './config'; 2 | 3 | export function getTypes(title: string | string[], typeInfo = config.categoryInfo) { 4 | const types: string[] = []; 5 | 6 | if (title) { 7 | if (!Array.isArray(title)) title = [title]; 8 | 9 | for (const t of title) { 10 | for (const [type, value] of Object.entries(typeInfo)) { 11 | if (!types.includes(type) && (type == t || value.keywords?.some(d => t.includes(d)))) types.push(type); 12 | } 13 | } 14 | 15 | if (!types) console.log('[getTypes]未匹配到类型: ', title); 16 | } 17 | 18 | return types; 19 | } 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@lzwme/chatgpt-nav", 3 | "version": "1.0.0", 4 | "description": "ChatGPT 在线体验网站列表", 5 | "license": "MIT", 6 | "repository": "https://github.com/lzwme/chatgpt-nav.git", 7 | "author": { 8 | "name": "renxia", 9 | "email": "lzwy0820@qq.com", 10 | "url": "https://lzw.me" 11 | }, 12 | "type": "module", 13 | "scripts": { 14 | "start": "tsx src/index.ts", 15 | "update": "tsx src/index.ts --no-bot --no-url-check --only-update", 16 | "test": "echo \"Error: no test specified\" && exit 1" 17 | }, 18 | "keywords": [ 19 | "chatgpt" 20 | ], 21 | "devDependencies": { 22 | "@types/node": "^20.12.12", 23 | "tsx": "^4.10.5", 24 | "typescript": "^5.4.5" 25 | }, 26 | "dependencies": { 27 | "@lzwme/fe-utils": "^1.7.3", 28 | "dotenv": "^16.4.5", 29 | "yargs-parser": "^21.1.1" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 志文工作室 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 | -------------------------------------------------------------------------------- /src/bot.ts: -------------------------------------------------------------------------------- 1 | import { concurrency } from '@lzwme/fe-utils'; 2 | import { config } from './config'; 3 | import { getRepoForks, getUrlsFromLatestCommitComment, logger, fixSiteUrl } from './utils'; 4 | 5 | async function repoCommentBot(repo: string, maxForks = 1000) { 6 | let siteList: { [repo: string]: string[] } = {}; 7 | const list = await getRepoForks(repo, maxForks, { per_page: Math.min(maxForks, 100) }); 8 | logger.info(`[${repo}]forks repo list:`, config.debug ? list : list.length); 9 | 10 | const repoInSiteInfo = new Set(Object.values(config.siteInfo).map(d => d.repo)); 11 | let isMaxRateLimited = false; 12 | const tasks = list 13 | .filter(item => { 14 | if (/^https?:\/\/([0-9a-zA-Z\-]+\.)/.test(item.homepage)) { 15 | item.homepage = fixSiteUrl(item.homepage); 16 | if (!config.siteBlockList.has(item.homepage)) siteList[item.full_name] = [item.homepage]; 17 | } 18 | 19 | if (config.isOnlyNew && repoInSiteInfo.has(item.full_name)) return false; 20 | 21 | return !config.repoBlockMap.has(item.full_name); 22 | }) 23 | .sort((a, b) => Number(repoInSiteInfo.has(b.full_name)) - Number(repoInSiteInfo.has(a.full_name))) 24 | .map(item => async () => { 25 | if (isMaxRateLimited) return; 26 | const r = await getUrlsFromLatestCommitComment(item.full_name); 27 | if (r.ratelimit && r.remaining < 1) { 28 | isMaxRateLimited = true; 29 | logger.warn('达到最大请求限制次数', r); 30 | } 31 | if (r.message.includes('Not Found')) config.repoBlockMap.set(r.repo, ''); 32 | if (r.list.length) siteList[item.full_name] = (siteList[item.full_name] || []).concat(r.list); 33 | logger.debug(`[${item.full_name}]site list:`, r); 34 | }); 35 | 36 | await concurrency(tasks, config.ci ? 2 : 6); 37 | 38 | return siteList; 39 | } 40 | 41 | export async function repoBot(maxForks = 3000) { 42 | const result: { [repo: string]: string[] } = {}; 43 | 44 | for (const [repo, defaultInfo] of config.gptDemoRepos) { 45 | const r = await repoCommentBot(repo, maxForks); 46 | Object.assign(result, r); 47 | for (const [forkRepo, urls] of Object.entries(r)) { 48 | for (const url of urls) { 49 | if (url.startsWith('https://github.com')) continue; 50 | if (!config.siteInfo[url]) config.siteInfo[url] = Object.assign({}, defaultInfo); 51 | if (url.includes('.vercel.app')) config.siteInfo[url].needVPN = true; 52 | else config.siteInfo[url].hide = 9; 53 | if (!config.siteInfo[url].repo) config.siteInfo[url].repo = forkRepo; 54 | } 55 | } 56 | } 57 | return result; 58 | } 59 | -------------------------------------------------------------------------------- /src/siteUrlVerify.ts: -------------------------------------------------------------------------------- 1 | import { concurrency, color, httpLinkChecker } from '@lzwme/fe-utils'; 2 | import { config } from './config'; 3 | import { logger } from './utils'; 4 | import { getTypes } from './getTypes'; 5 | 6 | export function siteUrlVerify() { 7 | const knownUrlKeyWords = ['apps.apple.com', 'baidu.com', 'qq.com', 'tencent.com', 'aliyun.com']; 8 | const needVPNKeywords = ['vercel.app', 'openai.com', 'bing.com']; 9 | const isGitHubCi = (process.env.GITHUB_CI || process.env.SYNC) != null; 10 | 11 | const tasks = Object.entries(config.siteInfo).map(([url, item], idx) => async () => { 12 | if (item.type) item.type = getTypes(item.type); 13 | 14 | if (Number(item.hide) === 1) return true; 15 | if (knownUrlKeyWords.some(key => url.includes(key))) return true; 16 | 17 | if (!isGitHubCi) { 18 | // if (item.needVPN) return true; 19 | if (needVPNKeywords.some(key => url.includes(key))) return true; 20 | if (item.star! >= 3) return true; 21 | } 22 | 23 | if (item.needVerify != null && item.needVerify < 0) return true; 24 | 25 | logger.debug(`[urlVerify][${idx}] start for`, color.green(url)); 26 | const startTime = Date.now(); 27 | const r = await httpLinkChecker(url, { 28 | verify: body => /
/i.test(body), 29 | reqOptions: { timeout: 10_000, rejectUnauthorized: false, referer: new URL(url).origin }, 30 | }); 31 | 32 | if (r.code) { 33 | // 30x 为正常 34 | if (r.redirected || String(r.code).startsWith('30')) r.code = 0; 35 | 36 | // ignore TSL error 37 | if (r.errmsg.includes('network socket disconnected before secure TLS connection')) { 38 | r.code = 0; 39 | r.body = ''; 40 | } 41 | 42 | if (String(r.errmsg).startsWith('