├── .env.example
├── .eslintrc
├── .github
└── workflows
│ ├── deploy-github-pages.yml
│ └── deploy-to-vercel.yml
├── .gitignore
├── .npmrc
├── LICENSE
├── README.assets
└── image-20250413185708120.png
├── README.md
├── babel.config.js
├── docs
├── TODO.md
├── challenges
│ ├── 91porn视频播放链接加密.yml
│ ├── AkamaiBot管理器.yml
│ ├── Barracuda WAF.yml
│ ├── Citrix Bot Management.yml
│ ├── Cloudbric WAF.yml
│ ├── Cloudflare5秒盾.yml
│ ├── DataDome.yml
│ ├── F5Shape安全.yml
│ ├── Fastly Bot Management.yml
│ ├── Google reCAPTCHA v3.yml
│ ├── ImpervaBot防护.yml
│ ├── Kasada.yml
│ ├── LeetCode.yml
│ ├── Palo Alto Prisma Cloud.yml
│ ├── PerimeterX.yml
│ ├── README.md
│ ├── Radware Bot Manager.yml
│ ├── Sophos Web App Firewall.yml
│ ├── Sucuri Firewall.yml
│ ├── hCaptcha.yml
│ ├── jsjiami JS最牛加密-V7.yml
│ ├── kaitorishouten买取商店价格加密.yml
│ ├── meta.yml
│ ├── sojson加密结果逆向.yml
│ ├── 中国五矿集团有限公司供应链管理平台.yml
│ ├── 佛冈通请求头x-itouchtv-ca-key加密.yml
│ ├── 力哥爱英语开发者工具打开检测.yml
│ ├── 加速乐.yml
│ ├── 商丘市教育体育局鼠标移动设置Cookie.yml
│ ├── 国家标准全文公开系统.yml
│ ├── 小报童请求头sign.yml
│ ├── 微店登录参数分析.yml
│ ├── 成都市中小学教师继续教育网登录密码加密.yml
│ ├── 爱给网站音频播放链接加密.yml
│ ├── 瑞数.yml
│ ├── 网易易盾.yml
│ ├── 腾讯天御验证码.yml
│ └── 阿里滑块.yml
└── images
│ ├── logo.png
│ └── logo文生图提示词.md
├── eslint.config.js
├── favicon.png
├── index.html
├── package-lock.json
├── package.json
├── public
├── CC11001100-wechat-qrcode.png
├── favicon.png
└── index.html
├── src
├── App.css
├── App.tsx
├── assets
│ ├── CC11001100-wechat-qrcode.png
│ ├── favicon.png
│ └── logo.png
├── components
│ ├── AboutPage
│ │ ├── AboutCard.tsx
│ │ ├── ContactCard.tsx
│ │ ├── index.tsx
│ │ └── styles.ts
│ ├── ChallengeContributePage
│ │ ├── components
│ │ │ ├── BackupHistoryModal.tsx
│ │ │ ├── Base64UrlInput.tsx
│ │ │ ├── BasicInfo.tsx
│ │ │ ├── DescriptionFields.tsx
│ │ │ ├── DifficultySelector.tsx
│ │ │ ├── FormContainer.tsx
│ │ │ ├── FormHeader.tsx
│ │ │ ├── ResponsiveContainer.tsx
│ │ │ ├── ScrollButtons.tsx
│ │ │ ├── SolutionForm.tsx
│ │ │ ├── SolutionItem.tsx
│ │ │ ├── SolutionsSection.tsx
│ │ │ ├── TagsSelector.tsx
│ │ │ ├── UrlInput.tsx
│ │ │ ├── YamlActionButtons.tsx
│ │ │ ├── YamlImportSection.tsx
│ │ │ ├── YamlPreviewContent.tsx
│ │ │ ├── YamlPreviewSection.tsx
│ │ │ └── yaml-import
│ │ │ │ ├── FileImportTab.tsx
│ │ │ │ ├── TextImportTab.tsx
│ │ │ │ └── UrlImportTab.tsx
│ │ ├── hooks
│ │ │ ├── index.ts
│ │ │ ├── useAllTags.ts
│ │ │ ├── useAsyncOperation.ts
│ │ │ ├── useBase64UrlEncoder.d.ts
│ │ │ ├── useBase64UrlEncoder.ts
│ │ │ ├── useEventListener.ts
│ │ │ ├── useFormPersistence.ts
│ │ │ ├── useFormScrolling.ts
│ │ │ ├── useFormStyles.ts
│ │ │ ├── useFormValidator.ts
│ │ │ ├── useMarkdownEditor.ts
│ │ │ ├── useTagsSelector.ts
│ │ │ ├── useYamlBuilder.ts
│ │ │ ├── useYamlGeneration.ts
│ │ │ ├── useYamlImport.ts
│ │ │ └── useYamlParser.ts
│ │ ├── index.tsx
│ │ ├── styles.ts
│ │ ├── types.ts
│ │ └── utils
│ │ │ ├── i18nUtils.ts
│ │ │ ├── markdownStyleUtils.ts
│ │ │ ├── textUtils.ts
│ │ │ ├── urlUtils.ts
│ │ │ ├── validators.ts
│ │ │ ├── yamlChallengeUpdater.ts
│ │ │ ├── yamlCommentProcessor.ts
│ │ │ ├── yamlFormatter.ts
│ │ │ ├── yamlParser.ts
│ │ │ ├── yamlUpdater.ts
│ │ │ └── yamlUtils.ts
│ ├── ChallengeDetailPage
│ │ ├── ChallengeActions.tsx
│ │ ├── ChallengeDescription.tsx
│ │ ├── ChallengeExpiredAlert.tsx
│ │ ├── ChallengeHeader.tsx
│ │ ├── ChallengeMetadata.tsx
│ │ ├── ChallengePagination.tsx
│ │ ├── ChallengeSolutions.tsx
│ │ ├── ChallengeTags.tsx
│ │ ├── index.tsx
│ │ └── utils.ts
│ ├── ChallengeFilters.tsx
│ ├── ChallengeListPage
│ │ ├── ChallengeControls.tsx
│ │ ├── ChallengeData.ts
│ │ ├── ChallengeFilters.tsx
│ │ ├── ChallengeListItem.tsx
│ │ ├── SimpleChallengeList.tsx
│ │ ├── exports.ts
│ │ └── index.tsx
│ ├── FileViewer.js
│ ├── GitHubRibbon.tsx
│ ├── GitHubStarCounter.tsx
│ ├── HomePage
│ │ ├── ChallengeSection.tsx
│ │ ├── FeatureSection.tsx
│ │ ├── HeroSection.tsx
│ │ ├── index.tsx
│ │ └── styles.ts
│ ├── IdTag.tsx
│ ├── NavBar.tsx
│ ├── PageTitle.tsx
│ ├── PlatformTag.tsx
│ ├── SearchBox.tsx
│ ├── StarRating.tsx
│ └── TopicTag.tsx
├── gh-fork-ribbon.css
├── i18n.ts
├── i18n
│ └── utils.ts
├── index.css
├── locales
│ ├── en.ts
│ └── zh.ts
├── main.tsx
├── plugins
│ └── VirtualFileSystemPlugin
│ │ ├── challengeProcessor
│ │ ├── collector.ts
│ │ ├── index.ts
│ │ ├── processor.ts
│ │ └── types.ts
│ │ ├── fileUtils.ts
│ │ ├── imageProcessor
│ │ ├── index.ts
│ │ ├── markdownProcessor.ts
│ │ ├── types.ts
│ │ └── utils.ts
│ │ ├── index.ts
│ │ └── types.ts
├── react-app-env.d.ts
├── services
│ └── SearchService.ts
├── styles.tsx
├── styles
│ ├── dropdown.css
│ ├── github-ribbon-fix.css
│ ├── index.css
│ ├── markdown.css
│ └── star-rating.css
├── types
│ ├── challenge.ts
│ ├── challenge.ts
│ ├── vfs.ts
│ └── web-vitals.d.ts
└── vite-env.d.ts
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
├── vercel.json
└── vite.config.ts
/.env.example:
--------------------------------------------------------------------------------
1 | # 环境变量配置示例
2 | # 此文件用于说明项目需要的环境变量
3 | VITE_BAIDU_ANALYTICS_ID=your_baidu_analytics_site_id
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JSREP/crawler-leetcode/c8cc9ba6b648113632ab633ff19b80de04c2fdbe/.eslintrc
--------------------------------------------------------------------------------
/.github/workflows/deploy-github-pages.yml:
--------------------------------------------------------------------------------
1 | name: 部署GitHub Pages
2 |
3 | on:
4 | push:
5 | branches:
6 | - main # 或者是master,取决于你的主分支名称
7 |
8 | jobs:
9 | build-and-deploy:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: 检出代码
13 | uses: actions/checkout@v3
14 | with:
15 | persist-credentials: false # token将用于部署,因此我们不持久化凭证
16 |
17 | - name: 设置Node.js环境
18 | uses: actions/setup-node@v3
19 | with:
20 | node-version: '18' # 使用更新的Node.js版本
21 | cache: 'npm'
22 |
23 | - name: 安装依赖
24 | run: npm ci
25 |
26 | - name: 构建网站
27 | run: npm run build # 在package.json中定义为tsc && vite build
28 |
29 | - name: 部署到GitHub Pages
30 | uses: JamesIves/github-pages-deploy-action@v4
31 | with:
32 | branch: gh-pages # 部署到的分支
33 | folder: dist # Vite构建输出目录
34 | clean: true # 清理gh-pages分支上的旧文件
35 | token: ${{ secrets.GITHUB_TOKEN }} # GitHub自动提供的访问令牌
36 |
37 | - name: 部署完成通知
38 | run: 'echo "✅ 网站已成功部署到GitHub Pages!访问地址: https://jsrep.github.io/crawler-leetcode/"'
--------------------------------------------------------------------------------
/.github/workflows/deploy-to-vercel.yml:
--------------------------------------------------------------------------------
1 | name: Deploy to Vercel
2 |
3 | on:
4 | push:
5 | branches:
6 | - main # 或者你的默认分支名
7 | workflow_dispatch: # 允许手动触发
8 |
9 | env:
10 | VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
11 | VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
12 |
13 | jobs:
14 | deploy:
15 | runs-on: ubuntu-latest
16 |
17 | steps:
18 | - uses: actions/checkout@v4
19 | with:
20 | fetch-depth: 2
21 |
22 | - name: Setup Node.js
23 | uses: actions/setup-node@v4
24 | with:
25 | node-version: '20'
26 |
27 | - name: Install dependencies
28 | run: npm install
29 |
30 | - name: Install Vercel CLI
31 | run: npm install --global vercel@latest
32 |
33 | - name: Pull Vercel Environment Information
34 | run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
35 |
36 | - name: Build Project Artifacts
37 | run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
38 |
39 | - name: Deploy Project Artifacts to Vercel
40 | run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # 环境变量文件
16 | .env
17 | .env.local
18 | .env.development.local
19 | .env.test.local
20 | .env.production.local
21 |
22 | # Editor directories and files
23 | .vscode/*
24 | !.vscode/extensions.json
25 | .idea
26 | .DS_Store
27 | *.suo
28 | *.ntvs*
29 | *.njsproj
30 | *.sln
31 | *.sw?
32 | .vercel
33 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | # 项目根目录创建 .npmrc
2 | strict-peer-dependencies=false
3 | legacy-peer-deps=true
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 JavaScript Reverse Engineering Practice
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 |
--------------------------------------------------------------------------------
/README.assets/image-20250413185708120.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JSREP/crawler-leetcode/c8cc9ba6b648113632ab633ff19b80de04c2fdbe/README.assets/image-20250413185708120.png
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # LeetCode 爬虫挑战
2 |
3 | [](https://github.com/JSREP/crawler-leetcode/actions/workflows/deploy-github-pages.yml)
4 |
5 | 这个仓库收集了各种网站的爬虫挑战案例,展示了不同类型的反爬虫技术和解决方案。项目使用React+TypeScript开发,通过GitHub Pages进行部署。
6 |
7 | **在线访问**: [https://jsrep.github.io/crawler-leetcode/](https://jsrep.github.io/crawler-leetcode/) (需要VPN访问)
8 |
9 | 
10 |
11 | ## 项目结构
12 |
13 | ```
14 | crawler-leetcode/
15 | ├── .github/ # GitHub相关配置
16 | │ └── workflows/ # GitHub Actions工作流配置
17 | ├── docs/ # 文档和挑战定义
18 | │ └── challenges/ # 爬虫挑战YAML定义文件
19 | ├── public/ # 静态资源
20 | ├── src/ # 源代码
21 | │ ├── components/ # React组件
22 | │ ├── pages/ # 页面组件
23 | │ ├── plugins/ # 项目插件
24 | │ ├── utils/ # 工具函数
25 | │ └── App.tsx # 应用入口
26 | ├── package.json # 项目依赖
27 | └── vite.config.ts # Vite配置
28 | ```
29 |
30 | ## 爬虫挑战
31 |
32 | 所有爬虫挑战都定义在 `docs/challenges/` 目录中,使用YAML格式描述挑战的特点、难度和解决方案。详细的贡献指南请参考 [挑战贡献指南](docs/challenges/README.md)。
33 |
34 | 目前包含的挑战类型:
35 |
36 | - 验证码挑战(如reCAPTCHA、hCaptcha)
37 | - 浏览器指纹识别
38 | - JavaScript混淆与加密
39 | - API限流与保护
40 | - WebAssembly保护
41 | - 设备指纹和行为分析
42 |
43 | ## 本地开发
44 |
45 | ```bash
46 | # 克隆项目
47 | git clone https://github.com/JSREP/crawler-leetcode.git
48 | cd crawler-leetcode
49 |
50 | # 安装依赖
51 | npm install
52 |
53 | # 启动开发服务器
54 | npm run dev
55 |
56 | # 构建项目
57 | npm run build
58 |
59 | # 预览构建结果
60 | npm run preview
61 | ```
62 |
63 | ## 自动部署
64 |
65 | 本项目配置了GitHub Actions自动部署流程,当代码推送到主分支时,会自动构建并部署到GitHub Pages:
66 |
67 | 1. 检出代码
68 | 2. 设置Node.js环境
69 | 3. 安装依赖
70 | 4. 构建项目
71 | 5. 部署到gh-pages分支
72 |
73 | 你可以在 `.github/workflows/deploy-github-pages.yml` 文件中查看完整的工作流配置。
74 |
75 | ## 贡献指南
76 |
77 | 1. Fork本仓库
78 | 2. 创建新分支 (`git checkout -b feature/new-challenge`)
79 | 3. 提交更改 (`git commit -m 'Add new challenge: XXX'`)
80 | 4. 推送到分支 (`git push origin feature/new-challenge`)
81 | 5. 创建Pull Request
82 |
83 | 欢迎贡献新的爬虫挑战案例、改进文档或代码!
84 |
85 | ## 许可证
86 |
87 | MIT
88 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | '@babel/preset-env',
4 | '@babel/preset-react',
5 | '@babel/preset-typescript'
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/docs/TODO.md:
--------------------------------------------------------------------------------
1 | 作者这个字段,是要能够自动补全的,单击输入的就要把平台上所有的作者都下拉展开(按照出现次数倒序排列),然后根据用户的输入自动筛选补全,当然用户也可以不选择已有的作者,选择输入新的作者都是可以的,下拉仅仅只是为了补全,减少用户输入的工作量
2 |
3 |
4 |
5 |
6 |
7 |
8 | 检查docs/challenges下所有的yaml文件:
9 | 1. 如果缺少name_en或者name_en为空,则将name字段的值翻译为英文作为此字段的值;
10 | 2.
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/docs/challenges/91porn视频播放链接加密.yml:
--------------------------------------------------------------------------------
1 | id: 102
2 | id-alias: 91porn-videos
3 | platform: Web
4 | name: 91porn视频播放链接加密
5 | name_en: ""
6 | difficulty-level: 3
7 | description-markdown: 91porn视频播放链接加密,评3星主要在于抵制不良诱惑
8 | base64-url: aHR0cHM6Ly85MXBvcm4uY29tL3ZpZXdfdmlkZW8ucGhwP3ZpZXdrZXk9NTMyYWMxNzE3ZjI4NzY5YWUxM2EmYz02aGh5aiZ2aWV3dHlwZT0mY2F0ZWdvcnk9
9 | is-expired: false
10 | tags:
11 | - js-reverse
12 | - video
13 | solutions: []
14 | create-time: 2025-04-13 05:07:16
15 | update-time: 2025-04-13 05:07:16
16 |
--------------------------------------------------------------------------------
/docs/challenges/AkamaiBot管理器.yml:
--------------------------------------------------------------------------------
1 | # 爬虫挑战合集元数据配置文件
2 | # 用于在数据结构变更时进行版本兼容性校验,必须为整数
3 | # 当数据结构发生不兼容变更时需递增版本号
4 | version: 1
5 |
6 | # 爬虫挑战合集定义
7 | challenges:
8 | # 单个爬虫挑战定义,每个挑战都有一个唯一的id标识,id是必须的,ID必须是一个整数,并且全局唯一
9 | - id: 5
10 | # 可以给ID设置一个别名,用于在列表中显示,ID别名也可以用于访问详情页
11 | id-alias: akamai-bot
12 | # 挑战标签系统(数组格式,选填)
13 | # 用于分类和筛选,支持多个标签
14 | tags:
15 | - ai-detection
16 | - behavior-analysis
17 |
18 | # 挑战目标网站类型(枚举值,必填)
19 | # 允许值: Web / Android / iOS
20 | platform: Web
21 |
22 | # 挑战名称(必填)
23 | # 作为列表和详情页的标题,建议控制在30个字符以内
24 | name: Akamai Bot Manager
25 |
26 | # 挑战英文名称(选填)
27 | # 当用户选择英文语言时显示,不提供时将使用中文名称
28 | name_en: Akamai Bot Manager
29 |
30 | # 挑战难度评级(整数类型,必填)
31 | # 取值范围: 1-5,1表示最简单,5表示最难
32 | # 前端展示时会转换为星级显示
33 | difficulty-level: 4
34 |
35 | # Markdown格式详细描述(必选)
36 | # 当需要复杂排版时使用
37 | # 与description-markdown-path字段二选一使用,必须选其中一种方式提供描述
38 | description-markdown: |
39 | Akamai的AI驱动爬虫检测系统,可识别自动化工具的行为模式。
40 |
41 | 特点:利用机器学习分析用户行为,识别异常访问模式。
42 |
43 | 破解难点:需要模拟真实用户的浏览行为和交互方式,突破AI行为分析。
44 |
45 | # 挑战目标网站URL的base64编码
46 | base64-url: aHR0cHM6Ly93d3cuYWthbWFpLmNvbS9wcm9kdWN0cy9ib3QtbWFuYWdlcg==
47 |
48 | # 链接有效性状态(布尔值)
49 | # 标记挑战链接是否失效,true表示已失效
50 | # 失效挑战会在前端显示警告标志
51 | is-expired: false
52 |
53 | # 创建时间(ISO 8601格式)
54 | # 记录挑战首次添加时间,格式: YYYY-MM-DD HH:mm:ss
55 | # 时区默认为UTC+8
56 | create-time: 2025-03-01 00:00:04
57 |
58 | # 最后更新时间(ISO 8601格式)
59 | # 记录挑战最后修改时间,格式与create-time相同
60 | # 当任何字段变更时需同步更新此时间
61 | update-time: 2025-03-01 00:00:04
--------------------------------------------------------------------------------
/docs/challenges/Barracuda WAF.yml:
--------------------------------------------------------------------------------
1 | # 爬虫挑战合集元数据配置文件
2 | # 用于在数据结构变更时进行版本兼容性校验,必须为整数
3 | # 当数据结构发生不兼容变更时需递增版本号
4 | version: 1
5 |
6 | # 爬虫挑战合集定义
7 | challenges:
8 | # 单个爬虫挑战定义,每个挑战都有一个唯一的id标识,id是必须的,ID必须是一个整数,并且全局唯一
9 | - id: 26
10 | # 可以给ID设置一个别名,用于在列表中显示,ID别名也可以用于访问详情页
11 | id-alias: barracuda-waf
12 | # 挑战标签系统(数组格式,选填)
13 | # 用于分类和筛选,支持多个标签
14 | tags:
15 | - signature-based
16 | - ip-reputation
17 |
18 | # 挑战目标网站类型(枚举值,必填)
19 | # 允许值: Web / Android / iOS
20 | platform: Web
21 |
22 | # 挑战名称(必填)
23 | # 作为列表和详情页的标题,建议控制在30个字符以内
24 | name: Barracuda WAF
25 |
26 | # 挑战英文名称(选填)
27 | # 当用户选择英文语言时显示,不提供时将使用中文名称
28 | name_en: Barracuda WAF
29 |
30 | # 挑战难度评级(整数类型,必填)
31 | # 取值范围: 1-5,1表示最简单,5表示最难
32 | # 前端展示时会转换为星级显示
33 | difficulty-level: 3
34 |
35 | # Markdown格式详细描述(必选)
36 | # 当需要复杂排版时使用
37 | # 与description-markdown-path字段二选一使用,必须选其中一种方式提供描述
38 | description-markdown: |
39 | Barracuda基于签名的Web应用防火墙。
40 |
41 | 特点:利用预定义的签名库识别已知爬虫工具,结合IP信誉评估系统阻止恶意来源。
42 |
43 | 破解难点:需要绕过签名检测和IP信誉系统,模拟正常客户端的行为特征。
44 |
45 | # 挑战目标网站URL的base64编码
46 | base64-url: aHR0cHM6Ly93d3cuYmFycmFjdWRhLmNvbS8=
47 |
48 | # 链接有效性状态(布尔值)
49 | # 标记挑战链接是否失效,true表示已失效
50 | # 失效挑战会在前端显示警告标志
51 | is-expired: false
52 |
53 | # 创建时间(ISO 8601格式)
54 | # 记录挑战首次添加时间,格式: YYYY-MM-DD HH:mm:ss
55 | # 时区默认为UTC+8
56 | create-time: 2025-03-01 00:00:25
57 |
58 | # 最后更新时间(ISO 8601格式)
59 | # 记录挑战最后修改时间,格式与create-time相同
60 | # 当任何字段变更时需同步更新此时间
61 | update-time: 2025-03-01 00:00:25
--------------------------------------------------------------------------------
/docs/challenges/Citrix Bot Management.yml:
--------------------------------------------------------------------------------
1 | # 爬虫挑战合集元数据配置文件
2 | # 用于在数据结构变更时进行版本兼容性校验,必须为整数
3 | # 当数据结构发生不兼容变更时需递增版本号
4 | version: 1
5 |
6 | # 爬虫挑战合集定义
7 | challenges:
8 | # 单个爬虫挑战定义,每个挑战都有一个唯一的id标识,id是必须的,ID必须是一个整数,并且全局唯一
9 | - id: 25
10 | # 可以给ID设置一个别名,用于在列表中显示,ID别名也可以用于访问详情页
11 | id-alias: citrix-bot
12 | # 挑战标签系统(数组格式,选填)
13 | # 用于分类和筛选,支持多个标签
14 | tags:
15 | - api-protection
16 | - behavior-analysis
17 |
18 | # 挑战目标网站类型(枚举值,必填)
19 | # 允许值: Web / Android / iOS
20 | platform: Web
21 |
22 | # 挑战名称(必填)
23 | # 作为列表和详情页的标题,建议控制在30个字符以内
24 | name: Citrix Bot Management
25 |
26 | # 挑战英文名称(选填)
27 | # 当用户选择英文语言时显示,不提供时将使用中文名称
28 | name_en: Citrix Bot Management
29 |
30 | # 挑战难度评级(整数类型,必填)
31 | # 取值范围: 1-5,1表示最简单,5表示最难
32 | # 前端展示时会转换为星级显示
33 | difficulty-level: 4
34 |
35 | # Markdown格式详细描述(必选)
36 | # 当需要复杂排版时使用
37 | # 与description-markdown-path字段二选一使用,必须选其中一种方式提供描述
38 | description-markdown: |
39 | Citrix的API防护解决方案,包含爬虫行为分析模块。
40 |
41 | 特点:专注于API保护,通过行为分析识别异常访问模式,防止API滥用。
42 |
43 | 破解难点:需要模拟合法API调用模式,避免触发行为分析系统的告警。
44 |
45 | # 挑战目标网站URL的base64编码
46 | base64-url: aHR0cHM6Ly93d3cuY2l0cml4LmNvbS8=
47 |
48 | # 链接有效性状态(布尔值)
49 | # 标记挑战链接是否失效,true表示已失效
50 | # 失效挑战会在前端显示警告标志
51 | is-expired: false
52 |
53 | # 创建时间(ISO 8601格式)
54 | # 记录挑战首次添加时间,格式: YYYY-MM-DD HH:mm:ss
55 | # 时区默认为UTC+8
56 | create-time: 2025-03-01 00:00:24
57 |
58 | # 最后更新时间(ISO 8601格式)
59 | # 记录挑战最后修改时间,格式与create-time相同
60 | # 当任何字段变更时需同步更新此时间
61 | update-time: 2025-03-01 00:00:24
--------------------------------------------------------------------------------
/docs/challenges/Cloudbric WAF.yml:
--------------------------------------------------------------------------------
1 | # 爬虫挑战合集元数据配置文件
2 | # 用于在数据结构变更时进行版本兼容性校验,必须为整数
3 | # 当数据结构发生不兼容变更时需递增版本号
4 | version: 1
5 |
6 | # 爬虫挑战合集定义
7 | challenges:
8 | # 单个爬虫挑战定义,每个挑战都有一个唯一的id标识,id是必须的,ID必须是一个整数,并且全局唯一
9 | - id: 30
10 | # 可以给ID设置一个别名,用于在列表中显示,ID别名也可以用于访问详情页
11 | id-alias: cloudbric-waf
12 | # 挑战标签系统(数组格式,选填)
13 | # 用于分类和筛选,支持多个标签
14 | tags:
15 | - log-analysis
16 | - behavior-profiling
17 |
18 | # 挑战目标网站类型(枚举值,必填)
19 | # 允许值: Web / Android / iOS
20 | platform: Web
21 |
22 | # 挑战名称(必填)
23 | # 作为列表和详情页的标题,建议控制在30个字符以内
24 | name: Cloudbric WAF
25 |
26 | # 挑战英文名称(选填)
27 | # 当用户选择英文语言时显示,不提供时将使用中文名称
28 | name_en: Cloudbric WAF
29 |
30 | # 挑战难度评级(整数类型,必填)
31 | # 取值范围: 1-5,1表示最简单,5表示最难
32 | # 前端展示时会转换为星级显示
33 | difficulty-level: 3
34 |
35 | # Markdown格式详细描述(必选)
36 | # 当需要复杂排版时使用
37 | # 与description-markdown-path字段二选一使用,必须选其中一种方式提供描述
38 | description-markdown: |
39 | Cloudbric基于日志分析和行为建模的WAF解决方案。
40 |
41 | 特点:通过深度日志分析和用户行为建模,识别异常访问模式。
42 |
43 | 破解难点:需要模拟正常用户的行为模式,避免触发行为分析系统的异常告警。
44 |
45 | # 挑战目标网站URL的base64编码
46 | base64-url: aHR0cHM6Ly93d3cuY2xvdWRicmljLmNvbS8=
47 |
48 | # 链接有效性状态(布尔值)
49 | # 标记挑战链接是否失效,true表示已失效
50 | # 失效挑战会在前端显示警告标志
51 | is-expired: false
52 |
53 | # 创建时间(ISO 8601格式)
54 | # 记录挑战首次添加时间,格式: YYYY-MM-DD HH:mm:ss
55 | # 时区默认为UTC+8
56 | create-time: 2025-03-01 00:00:29
57 |
58 | # 最后更新时间(ISO 8601格式)
59 | # 记录挑战最后修改时间,格式与create-time相同
60 | # 当任何字段变更时需同步更新此时间
61 | update-time: 2025-03-01 00:00:29
--------------------------------------------------------------------------------
/docs/challenges/Cloudflare5秒盾.yml:
--------------------------------------------------------------------------------
1 | #
2 | # 爬虫挑战合集元数据配置文件
3 | # 用于在数据结构变更时进行版本兼容性校验,必须为整数
4 | # 当数据结构发生不兼容变更时需递增版本号
5 | version: 1
6 |
7 | # 爬虫挑战合集定义
8 | challenges:
9 | # 单个爬虫挑战定义,每个挑战都有一个唯一的id标识,id是必须的,ID必须是一个整数,并且全局唯一
10 | - id: 4
11 | # 可以给ID设置一个别名,用于在列表中显示,ID别名也可以用于访问详情页
12 | id-alias: cloudflare-5s
13 | # 挑战标签系统(数组格式,选填)
14 | # 用于分类和筛选,支持多个标签
15 | tags:
16 | - js-challenge
17 | - ip-reputation
18 |
19 | # 挑战目标网站类型(枚举值,必填)
20 | # 允许值: Web / Android / iOS
21 | platform: Web
22 |
23 | # 挑战名称(必填)
24 | # 作为列表和详情页的标题,建议控制在30个字符以内
25 | name: Cloudflare 5秒盾
26 |
27 | # 挑战英文名称(选填)
28 | # 当用户选择英文语言时显示,不提供时将使用中文名称
29 | name_en: Cloudflare 5s Challenge
30 |
31 | # 挑战难度评级(整数类型,必填)
32 | # 取值范围: 1-5,1表示最简单,5表示最难
33 | # 前端展示时会转换为星级显示
34 | difficulty-level: 4
35 |
36 | # Markdown格式详细描述(必选)
37 | # 当需要复杂排版时使用
38 | # 与description-markdown-path字段二选一使用,必须选其中一种方式提供描述
39 | description-markdown: |
40 | Cloudflare的JS挑战防护,要求客户端执行JavaScript代码后才能继续访问。
41 |
42 | 特点:利用浏览器执行JS代码验证客户端身份,通常需要等待5秒钟。
43 |
44 | 破解难点:JS代码经过混淆,每次请求动态生成,需要正确执行JS计算才能获取有效Cookie。
45 |
46 | # 挑战目标网站URL的base64编码
47 | base64-url: aHR0cHM6Ly93d3cuY2xvdWRmbGFyZS5jb20vd2Vic2l0ZS1zZWN1cml0eS9ib3QtbWFuYWdlbWVudC8=
48 |
49 | # 链接有效性状态(布尔值)
50 | # 标记挑战链接是否失效,true表示已失效
51 | # 失效挑战会在前端显示警告标志
52 | is-expired: false
53 |
54 | # 创建时间(ISO 8601格式)
55 | # 记录挑战首次添加时间,格式: YYYY-MM-DD HH:mm:ss
56 | # 时区默认为UTC+8
57 | create-time: 2025-03-01 00:00:03
58 |
59 | # 最后更新时间(ISO 8601格式)
60 | # 记录挑战最后修改时间,格式与create-time相同
61 | # 当任何字段变更时需同步更新此时间
62 | update-time: 2025-03-01 00:00:03
--------------------------------------------------------------------------------
/docs/challenges/DataDome.yml:
--------------------------------------------------------------------------------
1 | # 爬虫挑战合集元数据配置文件
2 | # 用于在数据结构变更时进行版本兼容性校验,必须为整数
3 | # 当数据结构发生不兼容变更时需递增版本号
4 | version: 1
5 |
6 | # 爬虫挑战合集定义
7 | challenges:
8 | # 单个爬虫挑战定义,每个挑战都有一个唯一的id标识,id是必须的,ID必须是一个整数,并且全局唯一
9 | - id: 9
10 | # 可以给ID设置一个别名,用于在列表中显示,ID别名也可以用于访问详情页
11 | id-alias: datadome
12 | # 挑战标签系统(数组格式,选填)
13 | # 用于分类和筛选,支持多个标签
14 | tags:
15 | - real-time-detection
16 | - js-obfuscation
17 |
18 | # 挑战目标网站类型(枚举值,必填)
19 | # 允许值: Web / Android / iOS
20 | platform: Web
21 |
22 | # 挑战名称(必填)
23 | # 作为列表和详情页的标题,建议控制在30个字符以内
24 | name: DataDome
25 |
26 | # 挑战英文名称(选填)
27 | # 当用户选择英文语言时显示,不提供时将使用中文名称
28 | name_en: DataDome
29 |
30 | # 挑战难度评级(整数类型,必填)
31 | # 取值范围: 1-5,1表示最简单,5表示最难
32 | # 前端展示时会转换为星级显示
33 | difficulty-level: 4
34 |
35 | # Markdown格式详细描述(必选)
36 | # 当需要复杂排版时使用
37 | # 与description-markdown-path字段二选一使用,必须选其中一种方式提供描述
38 | description-markdown: |
39 | 实时爬虫检测系统,结合机器学习与行为分析技术。
40 |
41 | 特点:毫秒级实时响应,基于机器学习的爬虫检测,JS混淆保护。
42 |
43 | 破解难点:需要解决JS混淆与实时检测,模拟人类行为特征。
44 |
45 | # 挑战目标网站URL的base64编码
46 | base64-url: aHR0cHM6Ly9kYXRhZG9tZS5jby9wcm9kdWN0L2JvdC1tYW5hZ2VtZW50Lw==
47 |
48 | # 链接有效性状态(布尔值)
49 | # 标记挑战链接是否失效,true表示已失效
50 | # 失效挑战会在前端显示警告标志
51 | is-expired: false
52 |
53 | # 创建时间(ISO 8601格式)
54 | # 记录挑战首次添加时间,格式: YYYY-MM-DD HH:mm:ss
55 | # 时区默认为UTC+8
56 | create-time: 2025-03-01 00:00:08
57 |
58 | # 最后更新时间(ISO 8601格式)
59 | # 记录挑战最后修改时间,格式与create-time相同
60 | # 当任何字段变更时需同步更新此时间
61 | update-time: 2025-03-01 00:00:08
--------------------------------------------------------------------------------
/docs/challenges/F5Shape安全.yml:
--------------------------------------------------------------------------------
1 | # 爬虫挑战合集元数据配置文件
2 | # 用于在数据结构变更时进行版本兼容性校验,必须为整数
3 | # 当数据结构发生不兼容变更时需递增版本号
4 | version: 1
5 |
6 | # 爬虫挑战合集定义
7 | challenges:
8 | # 单个爬虫挑战定义,每个挑战都有一个唯一的id标识,id是必须的,ID必须是一个整数,并且全局唯一
9 | - id: 10
10 | # 可以给ID设置一个别名,用于在列表中显示,ID别名也可以用于访问详情页
11 | id-alias: f5-shape
12 | # 挑战标签系统(数组格式,选填)
13 | # 用于分类和筛选,支持多个标签
14 | tags:
15 | - anti-automation
16 | - behavior-analysis
17 |
18 | # 挑战目标网站类型(枚举值,必填)
19 | # 允许值: Web / Android / iOS
20 | platform: Web
21 |
22 | # 挑战名称(必填)
23 | # 作为列表和详情页的标题,建议控制在30个字符以内
24 | name: F5 Shape Security
25 |
26 | # 挑战英文名称(选填)
27 | # 当用户选择英文语言时显示,不提供时将使用中文名称
28 | name_en: F5 Shape Security
29 |
30 | # 挑战难度评级(整数类型,必填)
31 | # 取值范围: 1-5,1表示最简单,5表示最难
32 | # 前端展示时会转换为星级显示
33 | difficulty-level: 4
34 |
35 | # Markdown格式详细描述(必选)
36 | # 当需要复杂排版时使用
37 | # 与description-markdown-path字段二选一使用,必须选其中一种方式提供描述
38 | description-markdown: |
39 | F5的反自动化解决方案,专门防护撞库和爬虫攻击。
40 |
41 | 特点:专注于防止自动化攻击,如撞库、账号盗用和数据爬取。
42 |
43 | 破解难点:需要绕过复杂的行为分析系统,模拟真实用户交互。
44 |
45 | # 挑战目标网站URL的base64编码
46 | base64-url: aHR0cHM6Ly93d3cuZjUuY29tL3Byb2R1Y3RzL3NoYXBlLXNlY3VyaXR5
47 |
48 | # 链接有效性状态(布尔值)
49 | # 标记挑战链接是否失效,true表示已失效
50 | # 失效挑战会在前端显示警告标志
51 | is-expired: false
52 |
53 | # 创建时间(ISO 8601格式)
54 | # 记录挑战首次添加时间,格式: YYYY-MM-DD HH:mm:ss
55 | # 时区默认为UTC+8
56 | create-time: 2025-03-01 00:00:09
57 |
58 | # 最后更新时间(ISO 8601格式)
59 | # 记录挑战最后修改时间,格式与create-time相同
60 | # 当任何字段变更时需同步更新此时间
61 | update-time: 2025-03-01 00:00:09
--------------------------------------------------------------------------------
/docs/challenges/Fastly Bot Management.yml:
--------------------------------------------------------------------------------
1 | # 爬虫挑战合集元数据配置文件
2 | # 用于在数据结构变更时进行版本兼容性校验,必须为整数
3 | # 当数据结构发生不兼容变更时需递增版本号
4 | version: 1
5 |
6 | # 爬虫挑战合集定义
7 | challenges:
8 | # 单个爬虫挑战定义,每个挑战都有一个唯一的id标识,id是必须的,ID必须是一个整数,并且全局唯一
9 | - id: 24
10 | # 可以给ID设置一个别名,用于在列表中显示,ID别名也可以用于访问详情页
11 | id-alias: fastly-bot
12 | # 挑战标签系统(数组格式,选填)
13 | # 用于分类和筛选,支持多个标签
14 | tags:
15 | - edge-computing
16 | - rate-limiting
17 |
18 | # 挑战目标网站类型(枚举值,必填)
19 | # 允许值: Web / Android / iOS
20 | platform: Web
21 |
22 | # 挑战名称(必填)
23 | # 作为列表和详情页的标题,建议控制在30个字符以内
24 | name: Fastly Bot Management
25 |
26 | # 挑战英文名称(选填)
27 | # 当用户选择英文语言时显示,不提供时将使用中文名称
28 | name_en: Fastly Bot Management
29 |
30 | # 挑战难度评级(整数类型,必填)
31 | # 取值范围: 1-5,1表示最简单,5表示最难
32 | # 前端展示时会转换为星级显示
33 | difficulty-level: 3
34 |
35 | # Markdown格式详细描述(必选)
36 | # 当需要复杂排版时使用
37 | # 与description-markdown-path字段二选一使用,必须选其中一种方式提供描述
38 | description-markdown: |
39 | Fastly边缘计算节点实现的爬虫流量管理。
40 |
41 | 特点:在边缘节点进行爬虫检测和流量控制,减轻源站压力,提供精准的速率限制。
42 |
43 | 破解难点:需要处理分布式边缘节点的检测机制,同时应对请求速率限制。
44 |
45 | # 挑战目标网站URL的base64编码
46 | base64-url: aHR0cHM6Ly93d3cuZmFzdGx5LmNvbS9wcm9kdWN0cy9ib3QtbWl0aWdhdGlvbg==
47 |
48 | # 链接有效性状态(布尔值)
49 | # 标记挑战链接是否失效,true表示已失效
50 | # 失效挑战会在前端显示警告标志
51 | is-expired: false
52 |
53 | # 创建时间(ISO 8601格式)
54 | # 记录挑战首次添加时间,格式: YYYY-MM-DD HH:mm:ss
55 | # 时区默认为UTC+8
56 | create-time: 2025-03-01 00:00:23
57 |
58 | # 最后更新时间(ISO 8601格式)
59 | # 记录挑战最后修改时间,格式与create-time相同
60 | # 当任何字段变更时需同步更新此时间
61 | update-time: 2025-03-01 00:00:23
--------------------------------------------------------------------------------
/docs/challenges/Google reCAPTCHA v3.yml:
--------------------------------------------------------------------------------
1 | # 爬虫挑战合集元数据配置文件
2 | # 用于在数据结构变更时进行版本兼容性校验,必须为整数
3 | # 当数据结构发生不兼容变更时需递增版本号
4 | version: 1
5 |
6 | # 爬虫挑战合集定义
7 | challenges:
8 | # 单个爬虫挑战定义,每个挑战都有一个唯一的id标识,id是必须的,ID必须是一个整数,并且全局唯一
9 | - id: 20
10 | # 可以给ID设置一个别名,用于在列表中显示,ID别名也可以用于访问详情页
11 | id-alias: recaptcha-v3
12 | # 挑战标签系统(数组格式,选填)
13 | # 用于分类和筛选,支持多个标签
14 | tags:
15 | - risk-analysis
16 | - no-captcha
17 |
18 | # 挑战目标网站类型(枚举值,必填)
19 | # 允许值: Web / Android / iOS
20 | platform: Web
21 |
22 | # 挑战名称(必填)
23 | # 作为列表和详情页的标题,建议控制在30个字符以内
24 | name: Google reCAPTCHA v3
25 |
26 | # 挑战英文名称(选填)
27 | # 当用户选择英文语言时显示,不提供时将使用中文名称
28 | name_en: Google reCAPTCHA v3
29 |
30 | # 挑战难度评级(整数类型,必填)
31 | # 取值范围: 1-5,1表示最简单,5表示最难
32 | # 前端展示时会转换为星级显示
33 | difficulty-level: 4
34 |
35 | # Markdown格式详细描述(必选)
36 | # 当需要复杂排版时使用
37 | # 与description-markdown-path字段二选一使用,必须选其中一种方式提供描述
38 | description-markdown: |
39 | Google的无感验证系统,通过用户行为计算风险分数。
40 |
41 | 特点:在后台分析用户行为给出风险评分,无需用户主动交互,体验更流畅。
42 |
43 | 破解难点:需要模拟完整的用户交互过程,包括鼠标移动、页面滚动等多维度行为特征。
44 |
45 | # 挑战目标网站URL的base64编码
46 | base64-url: aHR0cHM6Ly93d3cuZ29vZ2xlLmNvbS9yZWNhcHRjaGEv
47 |
48 | # 链接有效性状态(布尔值)
49 | # 标记挑战链接是否失效,true表示已失效
50 | # 失效挑战会在前端显示警告标志
51 | is-expired: false
52 |
53 | # 创建时间(ISO 8601格式)
54 | # 记录挑战首次添加时间,格式: YYYY-MM-DD HH:mm:ss
55 | # 时区默认为UTC+8
56 | create-time: 2025-03-01 00:00:19
57 |
58 | # 最后更新时间(ISO 8601格式)
59 | # 记录挑战最后修改时间,格式与create-time相同
60 | # 当任何字段变更时需同步更新此时间
61 | update-time: 2025-03-01 00:00:19
--------------------------------------------------------------------------------
/docs/challenges/ImpervaBot防护.yml:
--------------------------------------------------------------------------------
1 | # 爬虫挑战合集元数据配置文件
2 | # 用于在数据结构变更时进行版本兼容性校验,必须为整数
3 | # 当数据结构发生不兼容变更时需递增版本号
4 | version: 1
5 |
6 | # 爬虫挑战合集定义
7 | challenges:
8 | # 单个爬虫挑战定义,每个挑战都有一个唯一的id标识,id是必须的,ID必须是一个整数,并且全局唯一
9 | - id: 7
10 | # 可以给ID设置一个别名,用于在列表中显示,ID别名也可以用于访问详情页
11 | id-alias: imperva-bot
12 | # 挑战标签系统(数组格式,选填)
13 | # 用于分类和筛选,支持多个标签
14 | tags:
15 | - ai-detection
16 | - traffic-analysis
17 |
18 | # 挑战目标网站类型(枚举值,必填)
19 | # 允许值: Web / Android / iOS
20 | platform: Web
21 |
22 | # 挑战名称(必填)
23 | # 作为列表和详情页的标题,建议控制在30个字符以内
24 | name: Imperva Bot Protection
25 |
26 | # 挑战英文名称(选填)
27 | # 当用户选择英文语言时显示,不提供时将使用中文名称
28 | name_en: Imperva Bot Protection
29 |
30 | # 挑战难度评级(整数类型,必填)
31 | # 取值范围: 1-5,1表示最简单,5表示最难
32 | # 前端展示时会转换为星级显示
33 | difficulty-level: 4
34 |
35 | # Markdown格式详细描述(必选)
36 | # 当需要复杂排版时使用
37 | # 与description-markdown-path字段二选一使用,必须选其中一种方式提供描述
38 | description-markdown: |
39 | Imperva的高级爬虫防护,通过AI分析流量特征识别自动化工具。
40 |
41 | 特点:深度学习分析流量模式,识别恶意爬虫行为。
42 |
43 | 破解难点:需要模拟真实用户的访问模式和行为特征,绕过AI检测系统。
44 |
45 | # 挑战目标网站URL的base64编码
46 | base64-url: aHR0cHM6Ly93d3cuaW1wZXJ2YS5jb20vcHJvZHVjdHMvYm90LW1hbmFnZW1lbnQv
47 |
48 | # 链接有效性状态(布尔值)
49 | # 标记挑战链接是否失效,true表示已失效
50 | # 失效挑战会在前端显示警告标志
51 | is-expired: false
52 |
53 | # 创建时间(ISO 8601格式)
54 | # 记录挑战首次添加时间,格式: YYYY-MM-DD HH:mm:ss
55 | # 时区默认为UTC+8
56 | create-time: 2025-03-01 00:00:06
57 |
58 | # 最后更新时间(ISO 8601格式)
59 | # 记录挑战最后修改时间,格式与create-time相同
60 | # 当任何字段变更时需同步更新此时间
61 | update-time: 2025-03-01 00:00:06
--------------------------------------------------------------------------------
/docs/challenges/Kasada.yml:
--------------------------------------------------------------------------------
1 | id: 110
2 | id-alias: Kasada
3 | platform: Web
4 | name: Kasada防护
5 | name_en: ""
6 | difficulty-level: 3
7 | description-markdown: Kasada防护
8 | base64-url: aHR0cHM6Ly93d3cua2FzYWRhLmlvL3Byb2R1Y3Qv
9 | is-expired: false
10 | tags:
11 | - js-reverse
12 | solutions: []
13 | create-time: 2025-04-13 08:00:31
14 | update-time: 2025-04-13 08:00:31
15 |
--------------------------------------------------------------------------------
/docs/challenges/LeetCode.yml:
--------------------------------------------------------------------------------
1 | version: 1
2 | challenges:
3 | - id: 101
4 | id-alias: leetcode-crawler
5 | tags:
6 | - js-reverse
7 | - graphql
8 | - rate-limit
9 | - anti-crawler
10 | platform: Web
11 | name: LeetCode抓取挑战
12 | name_en: LeetCode Crawler Challenge
13 | difficulty-level: 3
14 | description-markdown: |
15 | LeetCode是一个广受欢迎的在线编程学习平台,提供了大量的编程题目和讨论区。抓取LeetCode的题目、提交、讨论等内容面临多种反爬机制。
16 |
17 | ## 特点
18 |
19 | - **GraphQL API**:LeetCode使用GraphQL API作为主要数据接口,需要构造正确的查询语句
20 | - **登录验证**:部分内容需要登录才能访问,包括完整的题目描述、提交记录等
21 | - **速率限制**:对请求频率有严格限制,过快的请求会被临时封禁
22 | - **防盗链**:图片等资源有referer检查,直接请求可能会被拒绝
23 | - **Cookie验证**:依赖多个Cookie字段进行会话验证和CSRF防护
24 |
25 | ## 破解难点
26 |
27 | - 需要正确分析和构造GraphQL查询参数
28 | - 登录流程包含多重验证,需要模拟浏览器行为
29 | - 需要实现有效的请求频率控制和错误重试机制
30 | - 一些题目内容通过JavaScript动态加载和渲染
31 | - 接口返回格式可能随时变化,需要及时调整抓取策略
32 |
33 | ## 典型抓取场景
34 |
35 | 1. 抓取题目列表和题目详情
36 | 2. 获取用户提交历史和代码
37 | 3. 爬取题目讨论和解答
38 | 4. 提取题目标签和分类信息
39 | 5. 获取竞赛历史和排名数据
40 |
41 | 需要注意的是,LeetCode的API可能会定期更新,抓取策略也需要相应调整。建议在抓取时遵循平台的robots.txt规则和使用条款。
42 | base64-url: aHR0cHM6Ly9sZWV0Y29kZS5jb20v
43 | is-expired: false
44 | solutions:
45 | - title: 使用Python和requests-html抓取LeetCode
46 | url: https://github.com/yourusername/leetcode-crawler
47 | source: GitHub
48 | author: Anonymous
49 | create-time: 2023-11-15 08:30:00
50 | update-time: 2023-11-15 08:30:00
--------------------------------------------------------------------------------
/docs/challenges/Palo Alto Prisma Cloud.yml:
--------------------------------------------------------------------------------
1 | # 爬虫挑战合集元数据配置文件
2 | # 用于在数据结构变更时进行版本兼容性校验,必须为整数
3 | # 当数据结构发生不兼容变更时需递增版本号
4 | version: 1
5 |
6 | # 爬虫挑战合集定义
7 | challenges:
8 | # 单个爬虫挑战定义,每个挑战都有一个唯一的id标识,id是必须的,ID必须是一个整数,并且全局唯一
9 | - id: 27
10 | # 可以给ID设置一个别名,用于在列表中显示,ID别名也可以用于访问详情页
11 | id-alias: paloalto-bot
12 | # 挑战标签系统(数组格式,选填)
13 | # 用于分类和筛选,支持多个标签
14 | tags:
15 | - machine-learning
16 | - api-security
17 |
18 | # 挑战目标网站类型(枚举值,必填)
19 | # 允许值: Web / Android / iOS
20 | platform: Web
21 |
22 | # 挑战名称(必填)
23 | # 作为列表和详情页的标题,建议控制在30个字符以内
24 | name: Palo Alto Prisma Cloud
25 |
26 | # 挑战英文名称(选填)
27 | # 当用户选择英文语言时显示,不提供时将使用中文名称
28 | name_en: Palo Alto Prisma Cloud
29 |
30 | # 挑战难度评级(整数类型,必填)
31 | # 取值范围: 1-5,1表示最简单,5表示最难
32 | # 前端展示时会转换为星级显示
33 | difficulty-level: 4
34 |
35 | # Markdown格式详细描述(必选)
36 | # 当需要复杂排版时使用
37 | # 与description-markdown-path字段二选一使用,必须选其中一种方式提供描述
38 | description-markdown: |
39 | Palo Alto的云安全解决方案,包含AI驱动的爬虫检测。
40 |
41 | 特点:利用机器学习技术分析API调用模式,识别自动化工具的异常行为。
42 |
43 | 破解难点:需要绕过AI行为分析系统,同时处理API安全防护措施。
44 |
45 | # 挑战目标网站URL的base64编码
46 | base64-url: aHR0cHM6Ly93d3cucGFsb2FsdG9uZXR3b3Jrcy5jb20vcHJpc21hL2Nsb3Vk
47 |
48 | # 链接有效性状态(布尔值)
49 | # 标记挑战链接是否失效,true表示已失效
50 | # 失效挑战会在前端显示警告标志
51 | is-expired: false
52 |
53 | # 创建时间(ISO 8601格式)
54 | # 记录挑战首次添加时间,格式: YYYY-MM-DD HH:mm:ss
55 | # 时区默认为UTC+8
56 | create-time: 2025-03-01 00:00:26
57 |
58 | # 最后更新时间(ISO 8601格式)
59 | # 记录挑战最后修改时间,格式与create-time相同
60 | # 当任何字段变更时需同步更新此时间
61 | update-time: 2025-03-01 00:00:26
--------------------------------------------------------------------------------
/docs/challenges/PerimeterX.yml:
--------------------------------------------------------------------------------
1 | # 爬虫挑战合集元数据配置文件
2 | # 用于在数据结构变更时进行版本兼容性校验,必须为整数
3 | # 当数据结构发生不兼容变更时需递增版本号
4 | version: 1
5 |
6 | # 爬虫挑战合集定义
7 | challenges:
8 | # 单个爬虫挑战定义,每个挑战都有一个唯一的id标识,id是必须的,ID必须是一个整数,并且全局唯一
9 | - id: 22
10 | # 可以给ID设置一个别名,用于在列表中显示,ID别名也可以用于访问详情页
11 | id-alias: perimeterx
12 | # 挑战标签系统(数组格式,选填)
13 | # 用于分类和筛选,支持多个标签
14 | tags:
15 | - behavior-analysis
16 | - device-fingerprint
17 |
18 | # 挑战目标网站类型(枚举值,必填)
19 | # 允许值: Web / Android / iOS
20 | platform: Web
21 |
22 | # 挑战名称(必填)
23 | # 作为列表和详情页的标题,建议控制在30个字符以内
24 | name: PerimeterX
25 |
26 | # 挑战英文名称(选填)
27 | # 当用户选择英文语言时显示,不提供时将使用中文名称
28 | name_en: PerimeterX
29 |
30 | # 挑战难度评级(整数类型,必填)
31 | # 取值范围: 1-5,1表示最简单,5表示最难
32 | # 前端展示时会转换为星级显示
33 | difficulty-level: 5
34 |
35 | # Markdown格式详细描述(必选)
36 | # 当需要复杂排版时使用
37 | # 与description-markdown-path字段二选一使用,必须选其中一种方式提供描述
38 | description-markdown: |
39 | 高级行为分析防护系统,通过设备指纹和用户行为模式识别爬虫。
40 |
41 | 特点:采用深度行为分析技术,结合设备指纹识别,能够精确区分人类与机器行为。
42 |
43 | 破解难点:需要全方位模拟人类行为特征,同时处理复杂的设备指纹识别挑战。
44 |
45 | # 挑战目标网站URL的base64编码
46 | base64-url: aHR0cHM6Ly93d3cucGVyaW1ldGVyeC5pby8=
47 |
48 | # 链接有效性状态(布尔值)
49 | # 标记挑战链接是否失效,true表示已失效
50 | # 失效挑战会在前端显示警告标志
51 | is-expired: false
52 |
53 | # 创建时间(ISO 8601格式)
54 | # 记录挑战首次添加时间,格式: YYYY-MM-DD HH:mm:ss
55 | # 时区默认为UTC+8
56 | create-time: 2025-03-01 00:00:21
57 |
58 | # 最后更新时间(ISO 8601格式)
59 | # 记录挑战最后修改时间,格式与create-time相同
60 | # 当任何字段变更时需同步更新此时间
61 | update-time: 2025-03-01 00:00:21
--------------------------------------------------------------------------------
/docs/challenges/README.md:
--------------------------------------------------------------------------------
1 | # 爬虫挑战合集贡献指南
2 |
3 | 本目录收集了各种网站的爬虫挑战,每个挑战都描述了一个特定网站的反爬机制和技术细节。如果你希望贡献新的挑战,请按照以下指南操作。
4 |
5 | ## 目录结构
6 |
7 | ```
8 | docs/challenges/
9 | ├── README.md # 本文件:贡献指南
10 | ├── meta.yml # 示例和元数据格式说明
11 | ├── Challenge Name 1.yml # 挑战1的YAML文件
12 | ├── Challenge Name 2.yml # 挑战2的YAML文件
13 | └── ... # 更多挑战文件
14 | ```
15 |
16 | ## 如何贡献新挑战
17 |
18 | 1. 每个爬虫挑战需要保存为一个单独的YAML文件
19 | 2. 文件名应该与挑战名称一致,例如:`Akamai Bot Manager.yml`
20 | 3. 确保你的YAML文件符合以下格式规范
21 |
22 | ## YAML文件结构
23 |
24 | 每个挑战YAML文件必须包含以下基本格式:
25 |
26 | ```yaml
27 | version: 1
28 | challenges:
29 | - id: <唯一ID>
30 | # 其他字段...
31 | ```
32 |
33 | ### 必填字段
34 |
35 | - `version`: 数据结构版本,目前固定为1
36 | - `challenges`: 包含单个挑战信息的数组(即使只有一个挑战)
37 | - `id`: 挑战的唯一ID(整数)
38 | - `id-alias`: ID的别名,用于URL和引用(字符串)
39 | - `platform`: 平台类型,目前支持:Web、Android、iOS
40 | - `name`: 挑战名称(中文)
41 | - `difficulty-level`: 难度级别(1-5,整数)
42 | - `description-markdown` 或 `description-markdown-path`: 挑战描述(选其一)
43 | - `base64-url`: 目标网站URL的base64编码
44 | - `is-expired`: 链接是否已失效(布尔值)
45 | - `create-time`: 创建时间(格式:YYYY-MM-DD HH:mm:ss)
46 | - `update-time`: 更新时间(格式:YYYY-MM-DD HH:mm:ss)
47 |
48 | ### 可选字段
49 |
50 | - `name_en`: 挑战英文名称
51 | - `tags`: 标签数组,如 ['js-reverse', 'wasm', 'jsvmp']
52 | - `description-markdown_en` 或 `description-markdown-path_en`: 英文描述
53 | - `solutions`: 解决方案数组,每个解决方案包含title、url、source和author字段
54 |
55 | ## 挑战描述建议
56 |
57 | 描述应包含以下内容:
58 | 1. 简单介绍目标网站的功能和特点
59 | 2. 详细描述其反爬机制和技术特点
60 | 3. 可能的难点和解决思路
61 | 4. 尽量客观描述,避免主观评价
62 | 5. 可以使用Markdown语法增强可读性
63 |
64 | ## 完整示例
65 |
66 | ```yaml
67 | version: 1
68 | challenges:
69 | - id: 999
70 | id-alias: example-akamai
71 | tags:
72 | - browser-fingerprint
73 | - behavior-analysis
74 | platform: Web
75 | name: Akamai Bot Manager
76 | name_en: Akamai Bot Manager
77 | difficulty-level: 5
78 | description-markdown: |
79 | Akamai Bot Manager是一种高级反爬虫解决方案,使用多层防护机制识别和阻止自动化流量。
80 |
81 | 特点:
82 | - 设备指纹识别:收集浏览器、系统和硬件特征
83 | - 行为分析:监控鼠标移动、点击模式和导航行为
84 | - 机器学习:基于历史数据识别异常行为
85 |
86 | 破解难点:
87 | - 需要准确模拟真实用户的浏览器环境
88 | - 必须生成逼真的人类行为模式
89 | - 要应对动态变化的检测算法
90 | base64-url: aHR0cHM6Ly93d3cuYWthbWFpLmNvbS8=
91 | is-expired: false
92 | create-time: 2025-03-01 00:00:01
93 | update-time: 2025-03-01 00:00:01
94 | ```
95 |
96 | ## 提交流程
97 |
98 | 1. Fork本仓库
99 | 2. 创建新分支: `git checkout -b add-new-challenge`
100 | 3. 添加你的挑战YAML文件到`docs/challenges/`目录
101 | 4. 提交修改: `git commit -m "Add new challenge: XXX"`
102 | 5. 推送到你的Fork: `git push origin add-new-challenge`
103 | 6. 创建Pull Request
104 |
105 | ## 注意事项
106 |
107 | - 确保提供的URL是合法的,且不涉及违法内容
108 | - 描述应客观准确,不包含主观评价或不恰当内容
109 | - 日期格式必须符合:`YYYY-MM-DD HH:mm:ss`
110 | - 在提交前请验证YAML格式是否正确
111 |
112 | 感谢你对爬虫挑战合集的贡献!
113 |
--------------------------------------------------------------------------------
/docs/challenges/Radware Bot Manager.yml:
--------------------------------------------------------------------------------
1 | # 爬虫挑战合集元数据配置文件
2 | # 用于在数据结构变更时进行版本兼容性校验,必须为整数
3 | # 当数据结构发生不兼容变更时需递增版本号
4 | version: 1
5 |
6 | # 爬虫挑战合集定义
7 | challenges:
8 | # 单个爬虫挑战定义,每个挑战都有一个唯一的id标识,id是必须的,ID必须是一个整数,并且全局唯一
9 | - id: 23
10 | # 可以给ID设置一个别名,用于在列表中显示,ID别名也可以用于访问详情页
11 | id-alias: radware-bot
12 | # 挑战标签系统(数组格式,选填)
13 | # 用于分类和筛选,支持多个标签
14 | tags:
15 | - signature-detection
16 | - js-challenge
17 |
18 | # 挑战目标网站类型(枚举值,必填)
19 | # 允许值: Web / Android / iOS
20 | platform: Web
21 |
22 | # 挑战名称(必填)
23 | # 作为列表和详情页的标题,建议控制在30个字符以内
24 | name: Radware Bot Manager
25 |
26 | # 挑战英文名称(选填)
27 | # 当用户选择英文语言时显示,不提供时将使用中文名称
28 | name_en: Radware Bot Manager
29 |
30 | # 挑战难度评级(整数类型,必填)
31 | # 取值范围: 1-5,1表示最简单,5表示最难
32 | # 前端展示时会转换为星级显示
33 | difficulty-level: 4
34 |
35 | # Markdown格式详细描述(必选)
36 | # 当需要复杂排版时使用
37 | # 与description-markdown-path字段二选一使用,必须选其中一种方式提供描述
38 | description-markdown: |
39 | Radware的爬虫管理系统,结合签名检测和JS挑战。
40 |
41 | 特点:通过特征签名识别已知爬虫工具,同时使用JS挑战验证客户端执行能力。
42 |
43 | 破解难点:需要绕过特征检测,同时解决JS挑战获取有效会话凭证。
44 |
45 | # 挑战目标网站URL的base64编码
46 | base64-url: aHR0cHM6Ly93d3cucmFkd2FyZS5jb20vcHJvZHVjdHMvYm90LW1hbmFnZXI=
47 |
48 | # 链接有效性状态(布尔值)
49 | # 标记挑战链接是否失效,true表示已失效
50 | # 失效挑战会在前端显示警告标志
51 | is-expired: false
52 |
53 | # 创建时间(ISO 8601格式)
54 | # 记录挑战首次添加时间,格式: YYYY-MM-DD HH:mm:ss
55 | # 时区默认为UTC+8
56 | create-time: 2025-03-01 00:00:22
57 |
58 | # 最后更新时间(ISO 8601格式)
59 | # 记录挑战最后修改时间,格式与create-time相同
60 | # 当任何字段变更时需同步更新此时间
61 | update-time: 2025-03-01 00:00:22
--------------------------------------------------------------------------------
/docs/challenges/Sophos Web App Firewall.yml:
--------------------------------------------------------------------------------
1 | # 爬虫挑战合集元数据配置文件
2 | # 用于在数据结构变更时进行版本兼容性校验,必须为整数
3 | # 当数据结构发生不兼容变更时需递增版本号
4 | version: 1
5 |
6 | # 爬虫挑战合集定义
7 | challenges:
8 | # 单个爬虫挑战定义,每个挑战都有一个唯一的id标识,id是必须的,ID必须是一个整数,并且全局唯一
9 | - id: 28
10 | # 可以给ID设置一个别名,用于在列表中显示,ID别名也可以用于访问详情页
11 | id-alias: sophos-waf
12 | # 挑战标签系统(数组格式,选填)
13 | # 用于分类和筛选,支持多个标签
14 | tags:
15 | - signature-detection
16 | - anomaly-detection
17 |
18 | # 挑战目标网站类型(枚举值,必填)
19 | # 允许值: Web / Android / iOS
20 | platform: Web
21 |
22 | # 挑战名称(必填)
23 | # 作为列表和详情页的标题,建议控制在30个字符以内
24 | name: Sophos Web App Firewall
25 |
26 | # 挑战英文名称(选填)
27 | # 当用户选择英文语言时显示,不提供时将使用中文名称
28 | name_en: Sophos Web App Firewall
29 |
30 | # 挑战难度评级(整数类型,必填)
31 | # 取值范围: 1-5,1表示最简单,5表示最难
32 | # 前端展示时会转换为星级显示
33 | difficulty-level: 3
34 |
35 | # Markdown格式详细描述(必选)
36 | # 当需要复杂排版时使用
37 | # 与description-markdown-path字段二选一使用,必须选其中一种方式提供描述
38 | description-markdown: |
39 | Sophos的Web应用防火墙,支持异常流量检测。
40 |
41 | 特点:结合特征签名和行为异常检测,识别自动化工具的访问模式。
42 |
43 | 破解难点:需要绕过特征检测,同时避免触发异常行为监测系统。
44 |
45 | # 挑战目标网站URL的base64编码
46 | base64-url: aHR0cHM6Ly93d3cuc29waG9zLmNvbS9wcm9kdWN0cy93ZWItYXBwbGljYXRpb24tZmlyZXdhbGw=
47 |
48 | # 链接有效性状态(布尔值)
49 | # 标记挑战链接是否失效,true表示已失效
50 | # 失效挑战会在前端显示警告标志
51 | is-expired: false
52 |
53 | # 创建时间(ISO 8601格式)
54 | # 记录挑战首次添加时间,格式: YYYY-MM-DD HH:mm:ss
55 | # 时区默认为UTC+8
56 | create-time: 2025-03-01 00:00:27
57 |
58 | # 最后更新时间(ISO 8601格式)
59 | # 记录挑战最后修改时间,格式与create-time相同
60 | # 当任何字段变更时需同步更新此时间
61 | update-time: 2025-03-01 00:00:27
--------------------------------------------------------------------------------
/docs/challenges/Sucuri Firewall.yml:
--------------------------------------------------------------------------------
1 | # 爬虫挑战合集元数据配置文件
2 | # 用于在数据结构变更时进行版本兼容性校验,必须为整数
3 | # 当数据结构发生不兼容变更时需递增版本号
4 | version: 1
5 |
6 | # 爬虫挑战合集定义
7 | challenges:
8 | # 单个爬虫挑战定义,每个挑战都有一个唯一的id标识,id是必须的,ID必须是一个整数,并且全局唯一
9 | - id: 29
10 | # 可以给ID设置一个别名,用于在列表中显示,ID别名也可以用于访问详情页
11 | id-alias: sucuri-waf
12 | # 挑战标签系统(数组格式,选填)
13 | # 用于分类和筛选,支持多个标签
14 | tags:
15 | - dns-level
16 | - virtual-patching
17 |
18 | # 挑战目标网站类型(枚举值,必填)
19 | # 允许值: Web / Android / iOS
20 | platform: Web
21 |
22 | # 挑战名称(必填)
23 | # 作为列表和详情页的标题,建议控制在30个字符以内
24 | name: Sucuri Firewall
25 |
26 | # 挑战英文名称(选填)
27 | # 当用户选择英文语言时显示,不提供时将使用中文名称
28 | name_en: Sucuri Firewall
29 |
30 | # 挑战难度评级(整数类型,必填)
31 | # 取值范围: 1-5,1表示最简单,5表示最难
32 | # 前端展示时会转换为星级显示
33 | difficulty-level: 3
34 |
35 | # Markdown格式详细描述(必选)
36 | # 当需要复杂排版时使用
37 | # 与description-markdown-path字段二选一使用,必须选其中一种方式提供描述
38 | description-markdown: |
39 | Sucuri的DNS级Web应用防火墙,提供虚拟补丁防护。
40 |
41 | 特点:通过DNS级别进行流量过滤,提供虚拟补丁技术快速应对新威胁。
42 |
43 | 破解难点:需要绕过DNS级防护和虚拟补丁防护机制,处理动态更新的安全规则。
44 |
45 | # 挑战目标网站URL的base64编码
46 | base64-url: aHR0cHM6Ly9zdWN1cmkubmV0L3dlYnNpdGUtZmlyZXdhbGw=
47 |
48 | # 链接有效性状态(布尔值)
49 | # 标记挑战链接是否失效,true表示已失效
50 | # 失效挑战会在前端显示警告标志
51 | is-expired: false
52 |
53 | # 创建时间(ISO 8601格式)
54 | # 记录挑战首次添加时间,格式: YYYY-MM-DD HH:mm:ss
55 | # 时区默认为UTC+8
56 | create-time: 2025-03-01 00:00:28
57 |
58 | # 最后更新时间(ISO 8601格式)
59 | # 记录挑战最后修改时间,格式与create-time相同
60 | # 当任何字段变更时需同步更新此时间
61 | update-time: 2025-03-01 00:00:28
--------------------------------------------------------------------------------
/docs/challenges/hCaptcha.yml:
--------------------------------------------------------------------------------
1 | # 爬虫挑战合集元数据配置文件
2 | # 用于在数据结构变更时进行版本兼容性校验,必须为整数
3 | # 当数据结构发生不兼容变更时需递增版本号
4 | version: 1
5 |
6 | # 爬虫挑战合集定义
7 | challenges:
8 | # 单个爬虫挑战定义,每个挑战都有一个唯一的id标识,id是必须的,ID必须是一个整数,并且全局唯一
9 | - id: 21
10 | # 可以给ID设置一个别名,用于在列表中显示,ID别名也可以用于访问详情页
11 | id-alias: hcaptcha
12 | # 挑战标签系统(数组格式,选填)
13 | # 用于分类和筛选,支持多个标签
14 | tags:
15 | - image-classification
16 | - proof-of-work
17 |
18 | # 挑战目标网站类型(枚举值,必填)
19 | # 允许值: Web / Android / iOS
20 | platform: Web
21 |
22 | # 挑战名称(必填)
23 | # 作为列表和详情页的标题,建议控制在30个字符以内
24 | name: hCaptcha
25 |
26 | # 挑战英文名称(选填)
27 | # 当用户选择英文语言时显示,不提供时将使用中文名称
28 | name_en: hCaptcha
29 |
30 | # 挑战难度评级(整数类型,必填)
31 | # 取值范围: 1-5,1表示最简单,5表示最难
32 | # 前端展示时会转换为星级显示
33 | difficulty-level: 4
34 |
35 | # Markdown格式详细描述(必选)
36 | # 当需要复杂排版时使用
37 | # 与description-markdown-path字段二选一使用,必须选其中一种方式提供描述
38 | description-markdown: |
39 | 基于图像分类的验证系统,部分版本要求解决PoW挑战。
40 |
41 | 特点:通过要求用户进行图像分类、识别特定物体来验证人机身份,部分版本使用工作量证明机制。
42 |
43 | 破解难点:需要结合计算机视觉技术处理图像识别问题,同时应对可能的工作量证明挑战。
44 |
45 | # 挑战目标网站URL的base64编码
46 | base64-url: aHR0cHM6Ly93d3cuaGNhcHRjaGEuY29tLw==
47 |
48 | # 链接有效性状态(布尔值)
49 | # 标记挑战链接是否失效,true表示已失效
50 | # 失效挑战会在前端显示警告标志
51 | is-expired: false
52 |
53 | # 创建时间(ISO 8601格式)
54 | # 记录挑战首次添加时间,格式: YYYY-MM-DD HH:mm:ss
55 | # 时区默认为UTC+8
56 | create-time: 2025-03-01 00:00:20
57 |
58 | # 最后更新时间(ISO 8601格式)
59 | # 记录挑战最后修改时间,格式与create-time相同
60 | # 当任何字段变更时需同步更新此时间
61 | update-time: 2025-03-01 00:00:20
--------------------------------------------------------------------------------
/docs/challenges/meta.yml:
--------------------------------------------------------------------------------
1 | # 爬虫挑战合集元数据配置文件
2 | # 用于在数据结构变更时进行版本兼容性校验,必须为整数
3 | # 当数据结构发生不兼容变更时需递增版本号
4 | version: 1
5 |
6 | # 爬虫挑战合集定义
7 | challenges:
8 | # 单个爬虫挑战定义,每个挑战都有一个唯一的id标识,id是必须的,ID必须是一个整数,并且全局唯一
9 | - id: 0
10 | # 可以给ID设置一个别名,用于在列表中显示,ID别名也可以用于访问详情页
11 | id-alias: example
12 | # 挑战标签系统(数组格式,选填)
13 | # 用于分类和筛选,支持多个标签
14 | # 建议使用有意义的标签,例如防护机制类型、技术特点等
15 | tags:
16 | - js-reverse
17 | - wasm
18 | - jsvmp
19 |
20 | # 挑战目标网站类型(枚举值,必填)
21 | # 允许值: Web / Android / iOS / WeChat-MiniProgram / Electron / Windows-Native / Mac-Native / Linux-Native
22 | platform: Web
23 |
24 | # 挑战名称(必填)
25 | # 作为列表和详情页的标题,建议控制在30个字符以内
26 | name: 示例挑战
27 |
28 | # 挑战英文名称(选填)
29 | # 当用户选择英文语言时显示,不提供时将使用中文名称
30 | name_en: Example Challenge
31 |
32 | # 挑战难度评级(整数类型,必填)
33 | # 取值范围: 1-5,1表示最简单,5表示最难
34 | # 前端展示时会转换为星级显示
35 | difficulty-level: 1
36 |
37 | # Markdown格式详细描述(必选)
38 | # 当需要复杂排版时使用
39 | # 与description-markdown-path字段二选一使用,必须选其中一种方式提供描述
40 | # 建议包含:简介、特点、破解难点等内容
41 | description-markdown: |
42 | 这是一个示例挑战,用于演示YAML格式的正确写法。
43 |
44 | 特点:包含了JavaScript混淆、WebAssembly和JS虚拟机保护技术。
45 |
46 | 破解难点:需要分析混淆的JS代码,理解WebAssembly运行机制,处理JS虚拟机保护。
47 |
48 | # 英文版Markdown格式详细描述(选填)
49 | # 当用户选择英文语言时显示,与description-markdown-path_en字段二选一使用
50 | # 不提供英文描述时将使用中文描述
51 | description-markdown_en: |
52 | This is an example challenge to demonstrate the correct YAML format.
53 |
54 | Features: Includes JavaScript obfuscation, WebAssembly and JS virtual machine protection.
55 |
56 | Challenges: Requires analyzing obfuscated JS code, understanding WebAssembly mechanisms, and handling JS virtual machine protection.
57 |
58 | # Markdown文件路径(推荐,但与上面的字段二选一)
59 | # 指向包含完整挑战描述的Markdown文件,支持图片等复杂内容
60 | # 路径应为相对于项目根目录的相对路径
61 | # 与description-markdown字段二选一使用
62 | # description-markdown-path: contents/example/description.md
63 |
64 | # 英文版Markdown文件路径(选填)
65 | # 指向包含英文版完整挑战描述的Markdown文件
66 | # 与description-markdown_en字段二选一使用
67 | # 当用户选择英文语言时显示此内容
68 | # description-markdown-path_en: contents/example/description_en.md
69 |
70 | # 挑战目标网站URL的base64编码(必填)
71 | # 使用base64编码是为了避免直接暴露URL
72 | # 可以使用在线工具将URL转换为base64格式
73 | base64-url: aHR0cHM6Ly9leGFtcGxlLmNvbS8=
74 |
75 | # 链接有效性状态(布尔值,必填)
76 | # 标记挑战链接是否失效,true表示已失效
77 | # 失效挑战会在前端显示警告标志
78 | is-expired: false
79 |
80 | # 解决方案集合(数组,选填)
81 | # 每个解决方案应包含以下字段:
82 | # title: 解决方案标题(必填)
83 | # url: 解决方案链接(完整URL)
84 | # source: 来源平台(如GitHub、博客等)
85 | # author: 贡献者名称(可选)
86 | solutions:
87 | - title: 示例解决方案
88 | url: https://example.com/solution
89 | source: GitHub
90 | author: 贡献者
91 |
92 | # 创建时间(ISO 8601格式,必填)
93 | # 记录挑战首次添加时间,格式: YYYY-MM-DD HH:mm:ss
94 | # 时区默认为UTC+8
95 | create-time: 2025-03-01 20:42:17
96 |
97 | # 最后更新时间(ISO 8601格式,必填)
98 | # 记录挑战最后修改时间,格式与create-time相同
99 | # 当任何字段变更时需同步更新此时间
100 | update-time: 2025-03-01 20:42:17
101 |
102 | # 是否忽略该挑战
103 | # 设置为true时,该挑战不会在列表中显示,并且编译构建时也会忽略不会被打包
104 | ignored: false
--------------------------------------------------------------------------------
/docs/challenges/力哥爱英语开发者工具打开检测.yml:
--------------------------------------------------------------------------------
1 | id: 108
2 | id-alias: ""
3 | platform: Web
4 | name: 力哥爱英语开发者工具打开检测
5 | name_en: ""
6 | difficulty-level: 1
7 | description-markdown: 按F12和打开开发者工具之后都有防护
8 | base64-url: aHR0cHM6Ly9pZW5nbGlzaDUyMS5jb20v
9 | is-expired: false
10 | tags:
11 | - js-reverse
12 | solutions: []
13 | create-time: 2025-04-13 07:45:34
14 | update-time: 2025-04-13 07:45:34
15 |
--------------------------------------------------------------------------------
/docs/challenges/加速乐.yml:
--------------------------------------------------------------------------------
1 | # 爬虫挑战合集元数据配置文件
2 | # 用于在数据结构变更时进行版本兼容性校验,必须为整数
3 | # 当数据结构发生不兼容变更时需递增版本号
4 | version: 1
5 |
6 | # 爬虫挑战合集定义
7 | challenges:
8 | # 单个爬虫挑战定义,每个挑战都有一个唯一的id标识,id是必须的,ID必须是一个整数,并且全局唯一
9 | - id: 33
10 | # 可以给ID设置一个别名,用于在列表中显示,ID别名也可以用于访问详情页
11 | id-alias: jsl
12 | # 挑战标签系统(数组格式,选填)
13 | # 用于分类和筛选,支持多个标签
14 | # 示例: ["js-reverse", "wasm", "jsvmp"]
15 | tags:
16 | - js-reverse
17 |
18 | # 挑战目标网站类型(枚举值,必填)
19 | # 允许值: Web / Android / iOS
20 | platform: Web
21 |
22 | # 挑战名称(必填)
23 | # 作为列表和详情页的标题,建议控制在30个字符以内
24 | name: 加速乐
25 |
26 | # 挑战英文名称(选填)
27 | # 当用户选择英文语言时显示,不提供时将使用中文名称
28 | name_en: JSL
29 |
30 | # 挑战难度评级(整数类型,必填)
31 | # 取值范围: 1-5,1表示最简单,5表示最难
32 | # 前端展示时会转换为星级显示
33 | difficulty-level: 2
34 |
35 | # Markdown格式详细描述(必选)
36 | # 当需要复杂排版时使用
37 | # 与description-markdown-path字段二选一使用,必须选其中一种方式提供描述
38 | description-markdown: |
39 | 特点:采用 三次请求+动态Cookie 机制(AAEncode+OB混淆),每次访问需解密JS生成有效Cookie15。
40 |
41 | 破解难点:动态加密算法、多层Cookie校验、JS混淆代码难以逆向。
42 |
43 | # 英文版Markdown格式详细描述(选填)
44 | # 当用户选择英文语言时显示,与description-markdown-path_en字段二选一使用
45 | # 不提供英文描述时将使用中文描述
46 | description-markdown_en: "JSL"
47 |
48 | # Markdown文件路径(推荐)
49 | # 指向包含完整挑战描述的Markdown文件,支持图片等复杂内容
50 | # 路径应为相对于项目根目录的相对路径
51 | # 与description-markdown字段二选一使用
52 | # description-markdown-path: contents/example/description.md
53 |
54 | # 英文版Markdown文件路径(选填)
55 | # 指向包含英文版完整挑战描述的Markdown文件
56 | # 与description-markdown_en字段二选一使用
57 | # 当用户选择英文语言时显示此内容
58 | # description-markdown-path_en: contents/example/description_en.md
59 |
60 | # 挑战目标网站URL的base64编码
61 | base64-url: aHR0cHM6Ly93d3cuanNsLmNvbS5jbi8=
62 |
63 | # 链接有效性状态(布尔值)
64 | # 标记挑战链接是否失效,true表示已失效
65 | # 失效挑战会在前端显示警告标志
66 | is-expired: false
67 |
68 | # 解决方案集合(数组)
69 | # 每个解决方案应包含以下字段:
70 | # title: 解决方案标题(必填)
71 | # url: 解决方案链接(完整URL)
72 | # source: 来源平台(如GitHub、博客等)
73 | # author: 贡献者名称(可选)
74 | # solutions:
75 | # - title: JS逆向解决方案
76 | # url: http://fake.com
77 | # source: fake
78 | # author: fake
79 |
80 | # 创建时间(ISO 8601格式)
81 | # 记录挑战首次添加时间,格式: YYYY-MM-DD HH:mm:ss
82 | # 时区默认为UTC+8
83 | create-time: 2025-04-12 10:00:00
84 |
85 | # 最后更新时间(ISO 8601格式)
86 | # 记录挑战最后修改时间,格式与create-time相同
87 | # 当任何字段变更时需同步更新此时间
88 | update-time: 2025-04-12 10:00:00
89 |
--------------------------------------------------------------------------------
/docs/challenges/商丘市教育体育局鼠标移动设置Cookie.yml:
--------------------------------------------------------------------------------
1 | id: 106
2 | id-alias: ""
3 | platform: Web
4 | name: 商丘市教育体育局鼠标移动设置Cookie
5 | name_en: ""
6 | difficulty-level: 1
7 | description-markdown: |+
8 |
9 | 第一次访问网站的时候,让移动鼠标,移动鼠标的时候会设置cookie。
10 |
11 | 如果不是第一次访问,隐身模式访问即可。
12 |
13 |
14 |
15 | base64-url: aHR0cHM6Ly9qeXR5ai5zaGFuZ3FpdS5nb3YuY24vendnay9mZHpkZ2tuci96ZmNnMzFzcXNqeXR5ai96YmdnMzFzcXNqeXR5ag==
16 | is-expired: false
17 | tags:
18 | - js-reverse
19 | solutions: []
20 | create-time: 2025-04-13 07:29:54
21 | update-time: 2025-04-13 07:29:54
22 |
--------------------------------------------------------------------------------
/docs/challenges/瑞数.yml:
--------------------------------------------------------------------------------
1 | # 爬虫挑战合集元数据配置文件
2 | # 用于在数据结构变更时进行版本兼容性校验,必须为整数
3 | # 当数据结构发生不兼容变更时需递增版本号
4 | version: 1
5 |
6 | # 爬虫挑战合集定义
7 | challenges:
8 | # 单个爬虫挑战定义,每个挑战都有一个唯一的id标识,id是必须的,ID必须是一个整数,并且全局唯一
9 | - id: 32
10 | # 可以给ID设置一个别名,用于在列表中显示,ID别名也可以用于访问详情页
11 | id-alias: river
12 | # 挑战标签系统(数组格式,选填)
13 | # 用于分类和筛选,支持多个标签
14 | # 示例: ["js-reverse", "wasm", "jsvmp"]
15 | tags:
16 | - js-reverse
17 |
18 | # 挑战目标网站类型(枚举值,必填)
19 | # 允许值: Web / Android / iOS
20 | platform: Web
21 |
22 | # 挑战名称(必填)
23 | # 作为列表和详情页的标题,建议控制在30个字符以内
24 | name: 瑞数动态安全(River Security)
25 |
26 | # 挑战英文名称(选填)
27 | # 当用户选择英文语言时显示,不提供时将使用中文名称
28 | name_en: Test Challenge
29 |
30 | # 挑战难度评级(整数类型,必填)
31 | # 取值范围: 1-5,1表示最简单,5表示最难
32 | # 前端展示时会转换为星级显示
33 | difficulty-level: 3
34 |
35 | # Markdown格式详细描述(必选)
36 | # 当需要复杂排版时使用
37 | # 与description-markdown-path字段二选一使用,必须选其中一种方式提供描述
38 | description-markdown: |
39 | 瑞数动态安全(River Security)
40 |
41 | 特点:基于行为分析和轨迹检测,要求用户手动滑动滑块,结合AI识别是否为自动化工具8。
42 |
43 | 破解难点:检测鼠标轨迹、加速度、浏览器指纹等,普通模拟滑动难以通过。
44 |
45 | # 英文版Markdown格式详细描述(选填)
46 | # 当用户选择英文语言时显示,与description-markdown-path_en字段二选一使用
47 | # 不提供英文描述时将使用中文描述
48 | description-markdown_en: "Test Challenge: A sample challenge for breaking through JavaScript anti-crawling mechanisms"
49 |
50 | # Markdown文件路径(推荐)
51 | # 指向包含完整挑战描述的Markdown文件,支持图片等复杂内容
52 | # 路径应为相对于项目根目录的相对路径
53 | # 与description-markdown字段二选一使用
54 | # description-markdown-path: contents/example/description.md
55 |
56 | # 英文版Markdown文件路径(选填)
57 | # 指向包含英文版完整挑战描述的Markdown文件
58 | # 与description-markdown_en字段二选一使用
59 | # 当用户选择英文语言时显示此内容
60 | # description-markdown-path_en: contents/example/description_en.md
61 |
62 | # 挑战目标网站URL的base64编码
63 | base64-url: aHR0cHM6Ly93d3cucmlzdW4uY29tL3Byb2R1Y3RzL3dhZi8=
64 |
65 | # 链接有效性状态(布尔值)
66 | # 标记挑战链接是否失效,true表示已失效
67 | # 失效挑战会在前端显示警告标志
68 | is-expired: false
69 |
70 | # 解决方案集合(数组)
71 | # 每个解决方案应包含以下字段:
72 | # title: 解决方案标题(必填)
73 | # url: 解决方案链接(完整URL)
74 | # source: 来源平台(如GitHub、博客等)
75 | # author: 贡献者名称(可选)
76 | solutions:
77 | - title: JS逆向解决方案
78 | url: http://fake.com
79 | source: fake
80 | author: fake
81 |
82 | # 创建时间(ISO 8601格式)
83 | # 记录挑战首次添加时间,格式: YYYY-MM-DD HH:mm:ss
84 | # 时区默认为UTC+8
85 | create-time: 2025-03-01 20:42:17
86 |
87 | # 最后更新时间(ISO 8601格式)
88 | # 记录挑战最后修改时间,格式与create-time相同
89 | # 当任何字段变更时需同步更新此时间
90 | update-time: 2025-03-01 20:42:17
91 |
--------------------------------------------------------------------------------
/docs/challenges/网易易盾.yml:
--------------------------------------------------------------------------------
1 | # 爬虫挑战合集元数据配置文件
2 | # 用于在数据结构变更时进行版本兼容性校验,必须为整数
3 | # 当数据结构发生不兼容变更时需递增版本号
4 | version: 1
5 |
6 | # 爬虫挑战合集定义
7 | challenges:
8 | # 单个爬虫挑战定义,每个挑战都有一个唯一的id标识,id是必须的,ID必须是一个整数,并且全局唯一
9 | - id: 6
10 | # 可以给ID设置一个别名,用于在列表中显示,ID别名也可以用于访问详情页
11 | id-alias: netease-yidun
12 | # 挑战标签系统(数组格式,选填)
13 | # 用于分类和筛选,支持多个标签
14 | tags:
15 | - device-fingerprint
16 | - slider-captcha
17 |
18 | # 挑战目标网站类型(枚举值,必填)
19 | # 允许值: Web / Android / iOS
20 | platform: Web
21 |
22 | # 挑战名称(必填)
23 | # 作为列表和详情页的标题,建议控制在30个字符以内
24 | name: 网易易盾
25 |
26 | # 挑战英文名称(选填)
27 | # 当用户选择英文语言时显示,不提供时将使用中文名称
28 | name_en: NetEase Yidun
29 |
30 | # 挑战难度评级(整数类型,必填)
31 | # 取值范围: 1-5,1表示最简单,5表示最难
32 | # 前端展示时会转换为星级显示
33 | difficulty-level: 3
34 |
35 | # Markdown格式详细描述(必选)
36 | # 当需要复杂排版时使用
37 | # 与description-markdown-path字段二选一使用,必须选其中一种方式提供描述
38 | description-markdown: |
39 | 网易推出的综合防护方案,包含滑块验证、设备指纹检测等技术。
40 |
41 | 特点:多种验证方式结合,设备指纹识别与行为分析相结合。
42 |
43 | 破解难点:需要绕过多层验证机制,处理动态变化的验证逻辑。
44 |
45 | base64-url: aHR0cHM6Ly9kdW4uMTYzLmNvbS8=
46 |
47 | # 链接有效性状态(布尔值)
48 | # 标记挑战链接是否失效,true表示已失效
49 | # 失效挑战会在前端显示警告标志
50 | is-expired: false
51 |
52 | # 创建时间(ISO 8601格式)
53 | # 记录挑战首次添加时间,格式: YYYY-MM-DD HH:mm:ss
54 | # 时区默认为UTC+8
55 | create-time: 2025-03-01 00:00:05
56 |
57 | # 最后更新时间(ISO 8601格式)
58 | # 记录挑战最后修改时间,格式与create-time相同
59 | # 当任何字段变更时需同步更新此时间
60 | update-time: 2025-03-01 00:00:05
--------------------------------------------------------------------------------
/docs/challenges/腾讯天御验证码.yml:
--------------------------------------------------------------------------------
1 | # 爬虫挑战合集元数据配置文件
2 | # 用于在数据结构变更时进行版本兼容性校验,必须为整数
3 | # 当数据结构发生不兼容变更时需递增版本号
4 | version: 1
5 |
6 | # 爬虫挑战合集定义
7 | challenges:
8 | # 单个爬虫挑战定义,每个挑战都有一个唯一的id标识,id是必须的,ID必须是一个整数,并且全局唯一
9 | - id: 8
10 | # 可以给ID设置一个别名,用于在列表中显示,ID别名也可以用于访问详情页
11 | id-alias: tencent-captcha
12 | # 挑战标签系统(数组格式,选填)
13 | # 用于分类和筛选,支持多个标签
14 | tags:
15 | - no-captcha
16 | - behavior-analysis
17 |
18 | # 挑战目标网站类型(枚举值,必填)
19 | # 允许值: Web / Android / iOS
20 | platform: Web
21 |
22 | # 挑战名称(必填)
23 | # 作为列表和详情页的标题,建议控制在30个字符以内
24 | name: 腾讯天御验证码
25 |
26 | # 挑战英文名称(选填)
27 | # 当用户选择英文语言时显示,不提供时将使用中文名称
28 | name_en: Tencent Tianyu Captcha
29 |
30 | # 挑战难度评级(整数类型,必填)
31 | # 取值范围: 1-5,1表示最简单,5表示最难
32 | # 前端展示时会转换为星级显示
33 | difficulty-level: 3
34 |
35 | # Markdown格式详细描述(必选)
36 | # 当需要复杂排版时使用
37 | # 与description-markdown-path字段二选一使用,必须选其中一种方式提供描述
38 | description-markdown: |
39 | 腾讯推出的智能验证方案,通过行为分析实现无感验证。
40 |
41 | 特点:无需用户主动操作,通过后台行为分析判断是否为机器人。
42 |
43 | 破解难点:需要模拟真实用户的行为特征,通过行为分析系统的判断。
44 |
45 | # 挑战目标网站URL的base64编码
46 | base64-url: aHR0cHM6Ly9jbG91ZC50ZW5jZW50LmNvbS9wcm9kdWN0L3RpYW55dWNhcHRjaGE=
47 |
48 | # 链接有效性状态(布尔值)
49 | # 标记挑战链接是否失效,true表示已失效
50 | # 失效挑战会在前端显示警告标志
51 | is-expired: false
52 |
53 | # 创建时间(ISO 8601格式)
54 | # 记录挑战首次添加时间,格式: YYYY-MM-DD HH:mm:ss
55 | # 时区默认为UTC+8
56 | create-time: 2025-03-01 00:00:07
57 |
58 | # 最后更新时间(ISO 8601格式)
59 | # 记录挑战最后修改时间,格式与create-time相同
60 | # 当任何字段变更时需同步更新此时间
61 | update-time: 2025-03-01 00:00:07
--------------------------------------------------------------------------------
/docs/challenges/阿里滑块.yml:
--------------------------------------------------------------------------------
1 | # 爬虫挑战合集元数据配置文件
2 | # 用于在数据结构变更时进行版本兼容性校验,必须为整数
3 | # 当数据结构发生不兼容变更时需递增版本号
4 | version: 1
5 |
6 | # 爬虫挑战合集定义
7 | challenges:
8 | # 单个爬虫挑战定义,每个挑战都有一个唯一的id标识,id是必须的,ID必须是一个整数,并且全局唯一
9 | - id: 31
10 | # 可以给ID设置一个别名,用于在列表中显示,ID别名也可以用于访问详情页
11 | id-alias: ali-slider
12 | # 挑战标签系统(数组格式,选填)
13 | # 用于分类和筛选,支持多个标签
14 | # 示例: ["js-reverse", "wasm", "jsvmp"]
15 | tags:
16 | - js-reverse
17 | - jsvmp
18 |
19 | # 挑战目标网站类型(枚举值,必填)
20 | # 允许值: Web / Android / iOS
21 | platform: Web
22 |
23 | # 挑战名称(必填)
24 | # 作为列表和详情页的标题,建议控制在30个字符以内
25 | name: 阿里云盾滑块验证
26 |
27 | # 挑战英文名称(选填)
28 | # 当用户选择英文语言时显示,不提供时将使用中文名称
29 | name_en: Ali Slider
30 |
31 | # 挑战难度评级(整数类型,必填)
32 | # 取值范围: 1-5,1表示最简单,5表示最难
33 | # 前端展示时会转换为星级显示
34 | difficulty-level: 4
35 |
36 | # Markdown格式详细描述(必选)
37 | # 当需要复杂排版时使用
38 | # 与description-markdown-path字段二选一使用,必须选其中一种方式提供描述
39 | description-markdown: |
40 | 特点:基于行为分析和轨迹检测,要求用户手动滑动滑块,结合AI识别是否为自动化工具8。
41 |
42 | 破解难点:检测鼠标轨迹、加速度、浏览器指纹等,普通模拟滑动难以通过。
43 |
44 | ### 案例网站
45 | - [https://login.taobao.com/havanaone/login/login.htm](https://login.taobao.com/havanaone/login/login.htm)
46 | - [https://home.51cto.com/index/?from_service=icv&reback=https://www.51cto.com/](https://home.51cto.com/index/?from_service=icv&reback=https://www.51cto.com/)
47 | - [https://wf.pub/reader?ft=https%3A%2F%2Fkjzl.wfpub.cn%2Fap%2FgetPdf%3Fid%3D75%26type%3Dperio%26title%3D%E7%A7%91%E6%8A%80%E7%BA%B5%E8%A7%88%202022%E5%B9%B4-122%E6%9C%9F&pageNeed=2&rangeNeed=0%20https://36kr.com/%20%20%E7%99%BB%E5%BD%95](https://wf.pub/reader?ft=https%3A%2F%2Fkjzl.wfpub.cn%2Fap%2FgetPdf%3Fid%3D75%26type%3Dperio%26title%3D%E7%A7%91%E6%8A%80%E7%BA%B5%E8%A7%88%202022%E5%B9%B4-122%E6%9C%9F&pageNeed=2&rangeNeed=0%20https://36kr.com/%20%20%E7%99%BB%E5%BD%95)
48 | - [https://newrank.cn/user/login](https://newrank.cn/user/login)
49 | - [https://www.xiachufang.com/auth/login/](https://www.xiachufang.com/auth/login/)
50 | - [https://www.alizhaopin.com/login_personal.htm](https://www.alizhaopin.com/login_personal.htm)
51 | - [https://account.wps.cn/](https://account.wps.cn/)
52 | - [https://sspai.com/](https://sspai.com/)
53 | - [https://data.xiguaji.com/](https://data.xiguaji.com/)
54 | description-markdown_en: Ali Slider
55 |
56 | # Markdown文件路径(推荐)
57 | # 指向包含完整挑战描述的Markdown文件,支持图片等复杂内容
58 | # 路径应为相对于项目根目录的相对路径
59 | # 与description-markdown字段二选一使用
60 | # description-markdown-path: contents/example/description.md
61 |
62 | # 英文版Markdown文件路径(选填)
63 | # 指向包含英文版完整挑战描述的Markdown文件
64 | # 与description-markdown_en字段二选一使用
65 | # 当用户选择英文语言时显示此内容
66 | # description-markdown-path_en: contents/example/description_en.md
67 |
68 | # 挑战目标网站URL的base64编码
69 | base64-url: aHR0cHM6Ly9kbHAuZ2hzLmNuL3Ivdz9jbWQ9Y29tLnF3aW5ncy5hcHBzLmdocy5nd2Ntcy5zZWFyY2gmdHlwZT0x
70 |
71 | # 链接有效性状态(布尔值)
72 | # 标记挑战链接是否失效,true表示已失效
73 | # 失效挑战会在前端显示警告标志
74 | is-expired: false
75 |
76 | # 解决方案集合(数组)
77 | # 每个解决方案应包含以下字段:
78 | # title: 解决方案标题(必填)
79 | # url: 解决方案链接(完整URL)
80 | # source: 来源平台(如GitHub、博客等)
81 | # author: 贡献者名称(可选)
82 | # solutions:
83 | # - title: JS逆向解决方案
84 | # url: http://fake.com
85 | # source: fake
86 | # author: fake
87 |
88 | # 创建时间(ISO 8601格式)
89 | # 记录挑战首次添加时间,格式: YYYY-MM-DD HH:mm:ss
90 | # 时区默认为UTC+8
91 | create-time: 2025-04-12 10:00:00
92 |
93 | # 最后更新时间(ISO 8601格式)
94 | # 记录挑战最后修改时间,格式与create-time相同
95 | # 当任何字段变更时需同步更新此时间
96 | update-time: 2025-04-13 10:36:24
97 | # 测试热更新
98 | # 再次测试
99 | # 测试热更新3
100 | # 最终测试热更新
101 |
--------------------------------------------------------------------------------
/docs/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JSREP/crawler-leetcode/c8cc9ba6b648113632ab633ff19b80de04c2fdbe/docs/images/logo.png
--------------------------------------------------------------------------------
/docs/images/logo文生图提示词.md:
--------------------------------------------------------------------------------
1 | # Logo 生成提示词
2 |
3 | ## 中文提示词
4 |
5 | 设计一个简约而现代的爬虫技术挑战平台标志。核心元素为一个几何化的蜘蛛图形,具有编程代码线条风格。蜘蛛身体部分可融入代码符号或网络节点元素,腿部设计成数据连接线或代码路径。标志使用渐变色,从深蓝色过渡到科技紫色。整体设计需要简洁、扁平、专业,便于在不同尺寸下识别,适合作为网站和应用程序的图标。图像背景应为透明,尺寸为512x512像素,矢量风格,确保边缘清晰锐利。
6 |
7 | ## 英文提示词
8 |
9 | Design a minimalist and modern logo for a Web Crawler Challenge Platform. The core element should be a geometric spider figure with coding line art style. The spider's body can incorporate code symbols or network node elements, with legs designed as data connection lines or code paths. Use a gradient color scheme transitioning from deep blue to tech purple. The overall design should be clean, flat, professional, and easily recognizable at different sizes, suitable for website and application icons. The image background should be transparent, size 512x512 pixels, vector style, ensuring sharp and clean edges.
10 |
11 | ## 风格参考关键词
12 |
13 | - 极简设计
14 | - 几何图形
15 | - 线条艺术
16 | - 编程符号
17 | - 网络节点
18 | - 扁平化2.0
19 | - 渐变色
20 | - 矢量图标
21 | - 高识别度
22 | - 可扩展性强
23 | - 透明背景
24 | - 数据可视化元素
25 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js'
2 | import globals from 'globals'
3 | import reactHooks from 'eslint-plugin-react-hooks'
4 | import reactRefresh from 'eslint-plugin-react-refresh'
5 | import tseslint from 'typescript-eslint'
6 |
7 | export default tseslint.config(
8 | { ignores: ['dist'] },
9 | {
10 | extends: [js.configs.recommended, ...tseslint.configs.recommended],
11 | files: ['**/*.{ts,tsx}'],
12 | languageOptions: {
13 | ecmaVersion: 2020,
14 | globals: globals.browser,
15 | },
16 | plugins: {
17 | 'react-hooks': reactHooks,
18 | 'react-refresh': reactRefresh,
19 | },
20 | rules: {
21 | ...reactHooks.configs.recommended.rules,
22 | 'react-refresh/only-export-components': [
23 | 'warn',
24 | { allowConstantExport: true },
25 | ],
26 | },
27 | },
28 | )
29 |
--------------------------------------------------------------------------------
/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JSREP/crawler-leetcode/c8cc9ba6b648113632ab633ff19b80de04c2fdbe/favicon.png
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | 爬虫技术挑战平台
8 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "crawler-leetcode",
3 | "version": "0.1.0",
4 | "private": true,
5 | "type": "module",
6 | "homepage": "https://jsrep.github.io/crawler-leetcode",
7 | "scripts": {
8 | "dev": "vite",
9 | "build": "tsc && vite build",
10 | "lint": "eslint .",
11 | "preview": "vite preview",
12 | "predeploy": "npm run build",
13 | "deploy": "gh-pages -d dist"
14 | },
15 | "dependencies": {
16 | "@ant-design/icons": "^5.6.1",
17 | "@types/markdown-it": "^14.1.2",
18 | "@types/react-beautiful-dnd": "^13.1.8",
19 | "@types/react-responsive": "^8.0.8",
20 | "antd": "^5.16.4",
21 | "fuse.js": "^7.1.0",
22 | "i18next": "^24.2.2",
23 | "i18next-browser-languagedetector": "^8.0.4",
24 | "markdown-it": "^14.1.0",
25 | "react": "^18.2.0",
26 | "react-beautiful-dnd": "^13.1.1",
27 | "react-dom": "^18.2.0",
28 | "react-ga4": "^2.1.0",
29 | "react-i18next": "^15.4.1",
30 | "react-markdown": "^10.1.0",
31 | "react-markdown-editor-lite": "^1.3.4",
32 | "react-responsive": "^10.0.1",
33 | "react-router-dom": "^6.23.1",
34 | "react-syntax-highlighter": "^15.6.1",
35 | "rehype-raw": "^7.0.0",
36 | "web-vitals": "^4.2.4",
37 | "yaml": "^2.3.4"
38 | },
39 | "devDependencies": {
40 | "@faker-js/faker": "^9.6.0",
41 | "@types/js-yaml": "^4.0.9",
42 | "@types/node": "^22.14.0",
43 | "@types/react": "^18.2.67",
44 | "@types/react-dom": "^18.2.22",
45 | "@types/react-syntax-highlighter": "^15.5.13",
46 | "@typescript-eslint/eslint-plugin": "5.62.0",
47 | "@typescript-eslint/parser": "5.62.0",
48 | "@vitejs/plugin-react": "^4.3.4",
49 | "chokidar": "^4.0.3",
50 | "eslint": "^8.57.0",
51 | "eslint-plugin-react": "^7.37.4",
52 | "gh-pages": "^6.3.0",
53 | "js-yaml": "^4.1.0",
54 | "typescript": "^4.9.5",
55 | "vite": "^6.2.6"
56 | },
57 | "browserslist": {
58 | "production": [
59 | ">0.2%",
60 | "not dead",
61 | "not op_mini all"
62 | ],
63 | "development": [
64 | "last 1 chrome version",
65 | "last 1 firefox version",
66 | "last 1 safari version"
67 | ]
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/public/CC11001100-wechat-qrcode.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JSREP/crawler-leetcode/c8cc9ba6b648113632ab633ff19b80de04c2fdbe/public/CC11001100-wechat-qrcode.png
--------------------------------------------------------------------------------
/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JSREP/crawler-leetcode/c8cc9ba6b648113632ab633ff19b80de04c2fdbe/public/favicon.png
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 | 爬虫 LeetCode
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/src/App.css:
--------------------------------------------------------------------------------
1 | #root {
2 | width: 100%;
3 | min-height: 100vh;
4 | display: flex;
5 | flex-direction: column;
6 | }
7 |
8 | .app-container {
9 | flex: 1;
10 | width: 100%;
11 | margin: 0 auto;
12 | padding: 0 0;
13 | }
14 |
15 | .page-content {
16 | padding: 2rem 0;
17 | }
18 |
19 | .ant-layout-header {
20 | background: #fff;
21 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
22 | }
23 |
24 | .ant-menu-horizontal {
25 | line-height: 64px;
26 | }
27 |
28 | /* 导航菜单项样式 */
29 | .ant-menu-item {
30 | font-size: 16px;
31 | font-weight: 500;
32 | padding: 0 16px !important;
33 | }
34 |
35 | .ant-menu-item-selected {
36 | color: #42b983 !important;
37 | }
38 |
39 | .ant-menu-item-selected::after {
40 | background-color: #42b983 !important;
41 | height: 3px !important;
42 | }
43 |
44 | /* 导航栏容器宽度 */
45 | .navbar-container {
46 | max-width: 1000px;
47 | margin: 0 auto;
48 | }
49 |
50 | /* 防止菜单项显示为省略号 */
51 | .ant-menu-horizontal:not(.ant-menu-dark) > .ant-menu-item,
52 | .ant-menu-horizontal:not(.ant-menu-dark) > .ant-menu-submenu {
53 | padding: 0 20px;
54 | }
55 |
56 | .ant-card {
57 | margin-bottom: 1rem;
58 | }
59 |
60 | .logo {
61 | height: 6em;
62 | padding: 1.5em;
63 | will-change: filter;
64 | transition: filter 300ms;
65 | }
66 | .logo:hover {
67 | filter: drop-shadow(0 0 2em #646cffaa);
68 | }
69 | .logo.react:hover {
70 | filter: drop-shadow(0 0 2em #61dafbaa);
71 | }
72 |
73 | @keyframes logo-spin {
74 | from {
75 | transform: rotate(0deg);
76 | }
77 | to {
78 | transform: rotate(360deg);
79 | }
80 | }
81 |
82 | @media (prefers-reduced-motion: no-preference) {
83 | a:nth-of-type(2) .logo {
84 | animation: logo-spin infinite 20s linear;
85 | }
86 | }
87 |
88 | .card {
89 | padding: 2em;
90 | }
91 |
92 | .read-the-docs {
93 | color: #888;
94 | }
95 |
96 | /* 移动端响应式样式 */
97 | @media (max-width: 768px) {
98 | /* 导航栏调整 */
99 | .navbar-container {
100 | width: 100%;
101 | padding: 0 16px;
102 | }
103 |
104 | /* 移动端内容区域调整 */
105 | .content-wrapper {
106 | padding: 10px 0 !important;
107 | }
108 |
109 | /* 移动端卡片样式调整 */
110 | .ant-card {
111 | border-radius: 8px;
112 | }
113 |
114 | /* 移动端表格调整 */
115 | .ant-table {
116 | font-size: 12px;
117 | }
118 |
119 | /* 移动端表单样式 */
120 | .ant-form-item-label {
121 | padding-bottom: 4px;
122 | }
123 |
124 | /* 移动端按钮样式 */
125 | .ant-btn {
126 | font-size: 14px;
127 | height: 32px;
128 | padding: 0 15px;
129 | }
130 |
131 | /* 防止溢出 */
132 | .mobile-container {
133 | overflow-x: hidden;
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import {HashRouter, BrowserRouter, Route, Routes} from 'react-router-dom'
2 | import './App.css'
3 | import NavBar from './components/NavBar'
4 | import HomePage from './components/HomePage'
5 | import ChallengeListPage from './components/ChallengeListPage'
6 | import ChallengeDetailPage from './components/ChallengeDetailPage'
7 | import ChallengeContributePage from './components/ChallengeContributePage'
8 | import AboutPage from './components/AboutPage'
9 | import GitHubRibbon from './components/GitHubRibbon'
10 | import PageTitle from './components/PageTitle'
11 | import './gh-fork-ribbon.css';
12 | import './styles/github-ribbon-fix.css';
13 |
14 | // 根据环境选择路由器
15 | const Router = import.meta.env.VERCEL ? BrowserRouter : HashRouter;
16 |
17 | const App = () => {
18 | return (
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | }/>
27 | }/>
28 | }/>
29 | }/>
30 | }/>
31 | }/>
32 |
33 |
34 |
35 |
36 | )
37 | }
38 |
39 | export default App
--------------------------------------------------------------------------------
/src/assets/CC11001100-wechat-qrcode.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JSREP/crawler-leetcode/c8cc9ba6b648113632ab633ff19b80de04c2fdbe/src/assets/CC11001100-wechat-qrcode.png
--------------------------------------------------------------------------------
/src/assets/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JSREP/crawler-leetcode/c8cc9ba6b648113632ab633ff19b80de04c2fdbe/src/assets/favicon.png
--------------------------------------------------------------------------------
/src/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JSREP/crawler-leetcode/c8cc9ba6b648113632ab633ff19b80de04c2fdbe/src/assets/logo.png
--------------------------------------------------------------------------------
/src/components/AboutPage/AboutCard.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Card, Typography } from 'antd';
3 | import { useTranslation } from 'react-i18next';
4 | import { cardStyle, textStyle } from './styles';
5 |
6 | const { Text } = Typography;
7 |
8 | /**
9 | * 关于爬虫LeetCode的描述卡片组件
10 | */
11 | const AboutCard: React.FC = () => {
12 | const { t } = useTranslation();
13 |
14 | return (
15 |
21 |
22 | {t('about.description')}
23 |
24 |
25 | );
26 | };
27 |
28 | export default AboutCard;
--------------------------------------------------------------------------------
/src/components/AboutPage/ContactCard.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Card, Space, Tag, Typography, Image, Divider } from 'antd';
3 | import { useTranslation } from 'react-i18next';
4 | import { GithubOutlined, StarFilled, WechatOutlined } from '@ant-design/icons';
5 | import { cardStyle, githubIconStyle, githubLinkStyle, starTagStyle, textStyle } from './styles';
6 | import wechatQrcode from '../../assets/CC11001100-wechat-qrcode.png';
7 |
8 | const { Text, Link } = Typography;
9 |
10 | interface ContactCardProps {
11 | repoStars: number | string | null;
12 | }
13 |
14 | /**
15 | * 联系我们卡片组件
16 | */
17 | const ContactCard: React.FC = ({ repoStars }) => {
18 | const { t } = useTranslation();
19 |
20 | return (
21 |
27 |
28 |
29 | {t('about.contact.email')}:CC11001100@qq.com
30 |
31 |
32 |
33 |
34 |
39 | JSREP/crawler-leetcode
40 |
41 |
42 | {repoStars !== null ? (
43 | }
45 | style={starTagStyle}
46 | >
47 | {repoStars}
48 |
49 | ) : (
50 | {t('about.contact.loading')}
51 | )}
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | 扫码加微信,拉你进逆向技术讨论群
60 |
61 | 加好友时请备注【逆向】,方便我知道你是从这里来的~
62 |
69 |
70 |
71 |
72 | );
73 | };
74 |
75 | export default ContactCard;
--------------------------------------------------------------------------------
/src/components/AboutPage/index.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { Typography } from 'antd';
3 | import { useTranslation } from 'react-i18next';
4 | import AboutCard from './AboutCard';
5 | import ContactCard from './ContactCard';
6 | import { containerStyle, contentStyle, titleDividerStyle, titleStyle } from './styles';
7 |
8 | const { Title } = Typography;
9 |
10 | /**
11 | * 关于页面组件
12 | * 包含"关于爬虫LeetCode"和"联系我们"两个部分
13 | */
14 | const AboutPage = () => {
15 | const { t } = useTranslation();
16 | const [repoStars, setRepoStars] = useState(null);
17 |
18 | // 获取GitHub仓库star数量
19 | useEffect(() => {
20 | const fetchRepoStars = async () => {
21 | try {
22 | const response = await fetch('https://api.github.com/repos/JSREP/crawler-leetcode');
23 | if (response.ok) {
24 | const data = await response.json();
25 | setRepoStars(data.stargazers_count);
26 | } else {
27 | console.error(`Failed to fetch: ${response.status}`);
28 | setRepoStars('获取失败');
29 | }
30 | } catch (error) {
31 | console.error('Error:', error);
32 | setRepoStars('获取失败');
33 | }
34 | };
35 | fetchRepoStars();
36 | }, []);
37 |
38 | return (
39 |
40 |
41 | {t('about.title')}
42 |
43 |
44 |
45 |
46 | {/* 关于爬虫LeetCode */}
47 |
48 |
49 | {/* 联系我们 */}
50 |
51 |
52 |
53 | );
54 | };
55 |
56 | export default AboutPage;
--------------------------------------------------------------------------------
/src/components/AboutPage/styles.ts:
--------------------------------------------------------------------------------
1 | import { CSSProperties } from 'react';
2 |
3 | // 页面容器样式
4 | export const containerStyle: CSSProperties = {
5 | padding: 24,
6 | background: '#f8f9fa',
7 | minHeight: '100vh'
8 | };
9 |
10 | // 标题和分割线样式
11 | export const titleStyle: CSSProperties = {
12 | textAlign: 'center',
13 | marginBottom: 48
14 | };
15 |
16 | // 标题下方的装饰线样式
17 | export const titleDividerStyle: CSSProperties = {
18 | height: 4,
19 | background: 'linear-gradient(to right, #42b983, #3eaf7c)',
20 | width: 100,
21 | margin: '10px auto 0'
22 | };
23 |
24 | // 内容区域样式
25 | export const contentStyle: CSSProperties = {
26 | maxWidth: 1000,
27 | margin: '0 auto'
28 | };
29 |
30 | // 卡片公共样式
31 | export const cardStyle: CSSProperties = {
32 | boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
33 | transition: 'all 0.3s',
34 | marginBottom: 32
35 | };
36 |
37 | // 卡片文本样式
38 | export const textStyle: CSSProperties = {
39 | color: '#4a5568',
40 | lineHeight: 1.8,
41 | fontSize: '16px',
42 | display: 'block'
43 | };
44 |
45 | // GitHub图标样式
46 | export const githubIconStyle: CSSProperties = {
47 | color: '#4a5568',
48 | fontSize: '18px'
49 | };
50 |
51 | // GitHub链接样式
52 | export const githubLinkStyle: CSSProperties = {
53 | color: '#42b983',
54 | fontSize: '16px'
55 | };
56 |
57 | // Star标签样式
58 | export const starTagStyle: CSSProperties = {
59 | background: '#42b983',
60 | color: '#fff',
61 | borderRadius: 20,
62 | marginLeft: 8
63 | };
--------------------------------------------------------------------------------
/src/components/ChallengeContributePage/components/BackupHistoryModal.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Modal, List, Button, Space, Empty, Typography, Divider } from 'antd';
3 | import { ReloadOutlined, HistoryOutlined, DeleteOutlined } from '@ant-design/icons';
4 |
5 | const { Text, Title } = Typography;
6 |
7 | interface BackupHistoryModalProps {
8 | visible: boolean;
9 | onClose: () => void;
10 | backupOptions: { label: string; value: string }[];
11 | onRecover: (timestamp: string) => void;
12 | }
13 |
14 | /**
15 | * 备份历史模态框组件
16 | * 用于显示和恢复历史备份
17 | */
18 | const BackupHistoryModal: React.FC = ({
19 | visible,
20 | onClose,
21 | backupOptions,
22 | onRecover
23 | }) => {
24 | return (
25 |
28 |
29 | 备份历史
30 |
31 | }
32 | open={visible}
33 | onCancel={onClose}
34 | footer={[
35 |
38 | ]}
39 | width={600}
40 | >
41 | {backupOptions.length === 0 ? (
42 |
43 | ) : (
44 | <>
45 |
46 | 您可以恢复以下任一备份。点击"恢复"按钮将用所选备份替换当前表单数据。
47 |
48 |
49 |
50 |
51 | (
55 | }
62 | onClick={() => onRecover(item.value)}
63 | >
64 | 恢复
65 |
66 | ]}
67 | >
68 | }
70 | title={备份时间: {item.label}}
71 | description="点击恢复按钮将用此备份替换当前表单数据。此操作不可撤销。"
72 | />
73 |
74 | )}
75 | />
76 | >
77 | )}
78 |
79 | );
80 | };
81 |
82 | export default BackupHistoryModal;
--------------------------------------------------------------------------------
/src/components/ChallengeContributePage/components/BasicInfo.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Form, Input, InputNumber, Radio, FormInstance } from 'antd';
3 | import { SectionProps } from '../types';
4 |
5 | interface BasicInfoProps {
6 | form: FormInstance;
7 | }
8 |
9 | /**
10 | * 挑战基本信息表单部分
11 | */
12 | const BasicInfo: React.FC = ({ form }) => {
13 | return (
14 | <>
15 |
21 |
22 |
23 |
24 |
29 |
30 |
31 |
32 |
37 |
38 | Web
39 | Android
40 | iOS
41 |
42 |
43 |
44 |
49 |
50 |
51 |
52 |
56 |
57 |
58 | >
59 | );
60 | };
61 |
62 | export default BasicInfo;
--------------------------------------------------------------------------------
/src/components/ChallengeContributePage/components/DescriptionFields.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Suspense, lazy, useState, useEffect } from 'react';
3 | import { Form, Spin } from 'antd';
4 | import { SectionProps } from '../types';
5 | // 导入已抽取的验证规则
6 | import { descriptionValidators } from '../utils/validators';
7 | // 导入钩子
8 | import { useMarkdownEditor } from '../hooks';
9 | import { markdownEditorStyles, customEditorStyles, getEditorConfig } from '../utils/markdownStyleUtils';
10 |
11 | // 懒加载Markdown编辑器组件
12 | const MdEditor = lazy(() =>
13 | import('react-markdown-editor-lite').then(module => {
14 | // 同时导入样式
15 | import('react-markdown-editor-lite/lib/index.css');
16 | return { default: module.default };
17 | })
18 | );
19 |
20 | // 编辑器加载中占位组件
21 | const EditorLoading = () => (
22 |
23 |
24 |
25 | );
26 |
27 | /**
28 | * 描述字段组件
29 | * 提供中英文双语Markdown编辑功能
30 | */
31 | const DescriptionFields: React.FC = ({ form }) => {
32 | // 状态管理
33 | const [isEditorReady, setIsEditorReady] = useState(false);
34 |
35 | // 使用自定义hook管理编辑器状态
36 | const {
37 | markdownRenderer,
38 | editorChineseRef,
39 | editorEnglishRef,
40 | styleRef,
41 | handleChineseEditorChange,
42 | handleEnglishEditorChange,
43 | handleImageUpload
44 | } = useMarkdownEditor({
45 | form,
46 | chineseFieldName: 'descriptionMarkdown',
47 | englishFieldName: 'descriptionMarkdownEn'
48 | });
49 |
50 | // 添加自定义样式
51 | useEffect(() => {
52 | // 插入自定义样式到head中
53 | const styleElement = document.createElement('style');
54 | styleElement.innerHTML = markdownEditorStyles + customEditorStyles;
55 | document.head.appendChild(styleElement);
56 |
57 | // 标记编辑器准备就绪
58 | setIsEditorReady(true);
59 |
60 | // 组件卸载时移除样式
61 | return () => {
62 | if (styleElement && document.head.contains(styleElement)) {
63 | document.head.removeChild(styleElement);
64 | }
65 | };
66 | }, []);
67 |
68 | // 编辑器配置
69 | const editorConfig = React.useMemo(() => getEditorConfig(), []);
70 |
71 | return (
72 | <>
73 |
78 | }>
79 | {isEditorReady && (
80 | markdownRenderer.render(text)}
84 | onChange={handleChineseEditorChange}
85 | placeholder="请使用Markdown格式输入题目描述,支持图片、代码块等。可以直接粘贴图片!"
86 | config={editorConfig}
87 | onImageUpload={handleImageUpload}
88 | />
89 | )}
90 |
91 |
92 |
93 |
97 | }>
98 | {isEditorReady && (
99 | markdownRenderer.render(text)}
103 | onChange={handleEnglishEditorChange}
104 | placeholder="请使用Markdown格式输入英文题目描述(可选),英文版将在用户切换语言时显示。可以直接粘贴图片!"
105 | config={editorConfig}
106 | onImageUpload={handleImageUpload}
107 | />
108 | )}
109 |
110 |
111 | >
112 | );
113 | };
114 |
115 | export default DescriptionFields;
--------------------------------------------------------------------------------
/src/components/ChallengeContributePage/components/DifficultySelector.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { useState, useEffect } from 'react';
3 | import { Form, Select, FormInstance } from 'antd';
4 | import StarRating from '../../StarRating';
5 |
6 | const { Option } = Select;
7 |
8 | interface DifficultySelectorProps {
9 | form: FormInstance;
10 | value?: number;
11 | onChange?: (value: number) => void;
12 | }
13 |
14 | /**
15 | * 难度选择组件
16 | */
17 | const DifficultySelector: React.FC = ({ form, value, onChange }) => {
18 | const [currentDifficulty, setCurrentDifficulty] = useState(value || 1);
19 |
20 | // 监听表单中难度级别的变化
21 | useEffect(() => {
22 | const formDifficulty = form.getFieldValue('difficultyLevel');
23 | if (formDifficulty && formDifficulty !== currentDifficulty) {
24 | setCurrentDifficulty(formDifficulty);
25 | }
26 | }, [form, currentDifficulty]);
27 |
28 | // 难度级别变化处理
29 | const handleDifficultyChange = (value: number) => {
30 | setCurrentDifficulty(value);
31 | form.setFieldsValue({ difficultyLevel: value });
32 |
33 | if (onChange) {
34 | onChange(value);
35 | }
36 | };
37 |
38 | // 空函数,只用于让星星显示为可点击状态
39 | const handleStarClick = () => {};
40 |
41 | return (
42 |
47 |
106 |
107 | );
108 | };
109 |
110 | export default DifficultySelector;
--------------------------------------------------------------------------------
/src/components/ChallengeContributePage/components/ResponsiveContainer.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { useEffect, useState } from 'react';
3 | import { styles } from '../styles';
4 |
5 | interface ResponsiveContainerProps {
6 | children: React.ReactNode;
7 | mobileBreakpoint?: number;
8 | }
9 |
10 | /**
11 | * 响应式容器组件
12 | * 根据屏幕尺寸自动调整布局样式
13 | */
14 | const ResponsiveContainer: React.FC = ({
15 | children,
16 | mobileBreakpoint = 768 // 默认移动端断点为768px
17 | }) => {
18 | const [isMobile, setIsMobile] = useState(false);
19 |
20 | // 监听窗口尺寸变化
21 | useEffect(() => {
22 | const checkScreenSize = () => {
23 | setIsMobile(window.innerWidth < mobileBreakpoint);
24 | };
25 |
26 | // 初始检查
27 | checkScreenSize();
28 |
29 | // 添加resize事件监听
30 | window.addEventListener('resize', checkScreenSize);
31 |
32 | // 清理
33 | return () => {
34 | window.removeEventListener('resize', checkScreenSize);
35 | };
36 | }, [mobileBreakpoint]);
37 |
38 | // 根据屏幕尺寸选择样式
39 | const containerStyle = isMobile
40 | ? { ...styles.container, ...styles.mobileContainer }
41 | : styles.container;
42 |
43 | return (
44 |
45 | {children}
46 |
47 | );
48 | };
49 |
50 | export default ResponsiveContainer;
--------------------------------------------------------------------------------
/src/components/ChallengeContributePage/components/ScrollButtons.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Button, Tooltip } from 'antd';
3 | import { VerticalAlignTopOutlined, VerticalAlignBottomOutlined } from '@ant-design/icons';
4 |
5 | /**
6 | * 滚动控制按钮组件,用于快速跳转到页面顶部或底部
7 | */
8 | const ScrollButtons: React.FC = () => {
9 | // 滚动到页面顶部
10 | const scrollToTop = () => {
11 | window.scrollTo({
12 | top: 0,
13 | behavior: 'smooth'
14 | });
15 | };
16 |
17 | // 滚动到页面底部
18 | const scrollToBottom = () => {
19 | window.scrollTo({
20 | top: document.documentElement.scrollHeight,
21 | behavior: 'smooth'
22 | });
23 | };
24 |
25 | // 按钮样式
26 | const buttonStyle = {
27 | width: '40px',
28 | height: '40px',
29 | borderRadius: '50%',
30 | display: 'flex',
31 | justifyContent: 'center',
32 | alignItems: 'center',
33 | boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
34 | border: 'none'
35 | };
36 |
37 | return (
38 |
47 |
48 | }
50 | onClick={scrollToTop}
51 | style={buttonStyle}
52 | type="primary"
53 | shape="circle"
54 | />
55 |
56 |
57 | }
59 | onClick={scrollToBottom}
60 | style={buttonStyle}
61 | type="primary"
62 | shape="circle"
63 | />
64 |
65 |
66 | );
67 | };
68 |
69 | export default ScrollButtons;
--------------------------------------------------------------------------------
/src/components/ChallengeContributePage/components/SolutionItem.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Button, Space, Tooltip } from 'antd';
3 | import { EditOutlined, DeleteOutlined, DragOutlined } from '@ant-design/icons';
4 | import { Solution } from '../types';
5 | import { memo } from 'react';
6 |
7 | interface SolutionItemProps {
8 | solution: Solution;
9 | index: number;
10 | onEdit: (index: number) => void;
11 | onRemove: (index: number) => void;
12 | isDraggable?: boolean;
13 | }
14 |
15 | /**
16 | * 参考资料项目组件
17 | */
18 | const SolutionItem: React.FC = memo(({
19 | solution,
20 | index,
21 | onEdit,
22 | onRemove,
23 | isDraggable = false
24 | }) => {
25 | const containerStyle = {
26 | marginBottom: '16px',
27 | padding: '8px',
28 | border: '1px solid #f0f0f0',
29 | borderRadius: '4px',
30 | transition: 'box-shadow 0.2s, transform 0.1s',
31 | background: '#fff',
32 | boxShadow: isDraggable ? '0 1px 2px rgba(0,0,0,0.1)' : 'none',
33 | cursor: isDraggable ? 'grab' : 'default',
34 | position: 'relative' as const,
35 | };
36 |
37 | return (
38 |
39 |
40 |
41 | {isDraggable && (
42 |
43 |
51 |
52 | )}
53 |
54 | 标题: {solution.title}
55 | {solution.source && <> | 来源: {solution.source}>}
56 | {solution.author && <> | 作者: {solution.author}>}
57 |
58 |
59 |
60 | } size="small" onClick={() => onEdit(index)} type="text">
61 | 编辑
62 |
63 | } size="small" danger onClick={() => onRemove(index)} type="text">
64 | 删除
65 |
66 |
67 |
68 |
71 |
72 | );
73 | });
74 |
75 | export default SolutionItem;
--------------------------------------------------------------------------------
/src/components/ChallengeContributePage/components/UrlInput.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { FormInstance } from 'antd';
3 | import Base64UrlInput from './Base64UrlInput';
4 |
5 | interface UrlInputProps {
6 | form: FormInstance;
7 | onChange?: (value: string) => void;
8 | }
9 |
10 | /**
11 | * URL输入组件
12 | * 包装Base64UrlInput,提供URL输入和编码功能
13 | */
14 | const UrlInput: React.FC = ({ form, onChange }) => {
15 | return (
16 |
20 | );
21 | };
22 |
23 | export default UrlInput;
--------------------------------------------------------------------------------
/src/components/ChallengeContributePage/components/YamlActionButtons.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Button, message } from 'antd';
3 | import { CopyOutlined, DownloadOutlined } from '@ant-design/icons';
4 |
5 | interface YamlActionButtonsProps {
6 | yamlOutput: string;
7 | onCopyYaml: () => void;
8 | onDownloadYaml: () => void;
9 | }
10 |
11 | /**
12 | * YAML操作按钮组件
13 | */
14 | const YamlActionButtons: React.FC = ({
15 | yamlOutput,
16 | onCopyYaml,
17 | onDownloadYaml
18 | }) => {
19 | // 处理点击复制按钮
20 | const handleCopyClick = () => {
21 | if (!yamlOutput) {
22 | message.warning('请先生成YAML', 2);
23 | return;
24 | }
25 | onCopyYaml();
26 | };
27 |
28 | // 处理点击下载按钮
29 | const handleDownloadClick = () => {
30 | if (!yamlOutput) {
31 | message.warning('请先生成YAML', 2);
32 | return;
33 | }
34 | onDownloadYaml();
35 | };
36 |
37 | return (
38 | <>
39 | }
41 | onClick={handleCopyClick}
42 | >
43 | 复制YAML
44 |
45 |
46 | }
48 | onClick={handleDownloadClick}
49 | >
50 | 下载YAML
51 |
52 | >
53 | );
54 | };
55 |
56 | export default YamlActionButtons;
--------------------------------------------------------------------------------
/src/components/ChallengeContributePage/components/YamlPreviewContent.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
3 | import { tomorrow } from 'react-syntax-highlighter/dist/esm/styles/prism';
4 | import { Alert } from 'antd';
5 |
6 | interface YamlPreviewContentProps {
7 | yamlOutput: string;
8 | }
9 |
10 | /**
11 | * YAML内容预览组件
12 | */
13 | const YamlPreviewContent: React.FC = ({ yamlOutput }) => {
14 | if (!yamlOutput) {
15 | return (
16 |
22 | );
23 | }
24 |
25 | return (
26 |
33 |
44 | {yamlOutput}
45 |
46 |
47 | );
48 | };
49 |
50 | export default YamlPreviewContent;
--------------------------------------------------------------------------------
/src/components/ChallengeContributePage/components/yaml-import/FileImportTab.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Upload, message } from 'antd';
3 | import { FileTextOutlined } from '@ant-design/icons';
4 | import { RcFile } from 'antd/es/upload';
5 | import * as jsYaml from 'js-yaml';
6 |
7 | interface FileImportTabProps {
8 | onImport: (content: string) => void;
9 | setLoading: (loading: boolean) => void;
10 | }
11 |
12 | /**
13 | * 文件导入标签页组件
14 | */
15 | const FileImportTab: React.FC = ({ onImport, setLoading }) => {
16 | // 验证YAML内容
17 | const validateYaml = (content: string): boolean => {
18 | try {
19 | jsYaml.load(content);
20 | return true;
21 | } catch (error) {
22 | console.error('YAML解析失败:', error);
23 | message.error('YAML格式错误,请检查内容');
24 | return false;
25 | }
26 | };
27 |
28 | // 处理文件导入
29 | const handleFileImport = (file: RcFile) => {
30 | setLoading(true);
31 | const reader = new FileReader();
32 |
33 | reader.onload = (e: ProgressEvent) => {
34 | try {
35 | const content = e.target?.result as string;
36 | if (validateYaml(content)) {
37 | onImport(content);
38 | message.success('YAML文件导入成功');
39 | }
40 | } catch (error) {
41 | console.error('读取文件失败:', error);
42 | message.error('读取文件失败');
43 | } finally {
44 | setLoading(false);
45 | }
46 | };
47 |
48 | reader.onerror = () => {
49 | message.error('文件读取失败');
50 | setLoading(false);
51 | };
52 |
53 | reader.readAsText(file);
54 |
55 | // 阻止自动上传
56 | return false;
57 | };
58 |
59 | return (
60 |
61 |
67 |
68 |
69 |
70 | 点击或拖拽文件到此区域上传
71 |
72 | 支持 .yml 或 .yaml 格式文件
73 |
74 |
75 |
76 |
77 | 点击或拖拽YAML文件到上方区域。支持单个挑战或包含多个挑战的集合文件(将导入第一个挑战)。
78 |
79 |
80 |
81 | );
82 | };
83 |
84 | export default FileImportTab;
--------------------------------------------------------------------------------
/src/components/ChallengeContributePage/components/yaml-import/TextImportTab.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Input, Space, message } from 'antd';
3 | import * as jsYaml from 'js-yaml';
4 |
5 | const { TextArea } = Input;
6 |
7 | interface TextImportTabProps {
8 | yamlText: string;
9 | setYamlText: (text: string) => void;
10 | onImport: (content: string) => void;
11 | setLoading: (loading: boolean) => void;
12 | }
13 |
14 | /**
15 | * 文本粘贴导入标签页组件
16 | */
17 | const TextImportTab: React.FC = ({
18 | yamlText,
19 | setYamlText,
20 | onImport,
21 | setLoading
22 | }) => {
23 | // 验证YAML内容
24 | const validateYaml = (content: string): boolean => {
25 | try {
26 | jsYaml.load(content);
27 | return true;
28 | } catch (error) {
29 | console.error('YAML解析失败:', error);
30 | message.error('YAML格式错误,请检查内容');
31 | return false;
32 | }
33 | };
34 |
35 | // 处理文本导入
36 | const handleTextImport = () => {
37 | if (!yamlText.trim()) {
38 | message.error('请粘贴YAML内容');
39 | return;
40 | }
41 |
42 | setLoading(true);
43 | try {
44 | if (validateYaml(yamlText)) {
45 | onImport(yamlText);
46 | message.success('粘贴的YAML导入成功');
47 | }
48 | } finally {
49 | setLoading(false);
50 | }
51 | };
52 |
53 | React.useEffect(() => {
54 | // 将handleTextImport方法暴露给父组件
55 | const handleEvent = (event: CustomEvent) => {
56 | if (event.detail?.action === 'import-text') {
57 | handleTextImport();
58 | }
59 | };
60 |
61 | window.addEventListener('yaml-import-action', handleEvent as EventListener);
62 |
63 | return () => {
64 | window.removeEventListener('yaml-import-action', handleEvent as EventListener);
65 | };
66 | }, [yamlText]);
67 |
68 | return (
69 |
92 | );
93 | };
94 |
95 | export default TextImportTab;
--------------------------------------------------------------------------------
/src/components/ChallengeContributePage/components/yaml-import/UrlImportTab.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Input, Space, message } from 'antd';
3 | import { LinkOutlined } from '@ant-design/icons';
4 | import * as jsYaml from 'js-yaml';
5 |
6 | interface UrlImportTabProps {
7 | yamlUrl: string;
8 | setYamlUrl: (url: string) => void;
9 | onImport: (content: string) => void;
10 | setLoading: (loading: boolean) => void;
11 | }
12 |
13 | /**
14 | * URL导入标签页组件
15 | */
16 | const UrlImportTab: React.FC = ({
17 | yamlUrl,
18 | setYamlUrl,
19 | onImport,
20 | setLoading
21 | }) => {
22 | // 验证YAML内容
23 | const validateYaml = (content: string): boolean => {
24 | try {
25 | jsYaml.load(content);
26 | return true;
27 | } catch (error) {
28 | console.error('YAML解析失败:', error);
29 | message.error('YAML格式错误,请检查内容');
30 | return false;
31 | }
32 | };
33 |
34 | // 处理URL导入
35 | const handleUrlImport = async () => {
36 | if (!yamlUrl.trim()) {
37 | message.error('请输入有效的URL');
38 | return;
39 | }
40 |
41 | setLoading(true);
42 | try {
43 | // 使用代理避免CORS问题
44 | const proxyUrl = `https://cors-anywhere.herokuapp.com/${yamlUrl}`;
45 | const response = await fetch(proxyUrl);
46 |
47 | if (!response.ok) {
48 | throw new Error(`HTTP错误 ${response.status}`);
49 | }
50 |
51 | const content = await response.text();
52 | if (validateYaml(content)) {
53 | onImport(content);
54 | message.success('从URL导入YAML成功');
55 | }
56 | } catch (error) {
57 | console.error('从URL获取YAML失败:', error);
58 | message.error('无法从URL获取YAML,请确保URL可访问且包含有效的YAML');
59 | } finally {
60 | setLoading(false);
61 | }
62 | };
63 |
64 | React.useEffect(() => {
65 | // 将handleUrlImport方法暴露给父组件
66 | const handleEvent = (event: CustomEvent) => {
67 | if (event.detail?.action === 'import-url') {
68 | handleUrlImport();
69 | }
70 | };
71 |
72 | window.addEventListener('yaml-import-action', handleEvent as EventListener);
73 |
74 | return () => {
75 | window.removeEventListener('yaml-import-action', handleEvent as EventListener);
76 | };
77 | }, [yamlUrl]);
78 |
79 | return (
80 |
81 |
82 | ) => setYamlUrl(e.target.value)}
86 | prefix={}
87 | allowClear
88 | />
89 |
90 |
91 | 输入包含YAML内容的文件URL,点击底部的【导入】按钮获取并解析内容。
92 | 支持单个挑战或包含多个挑战的集合文件(将导入第一个挑战)。
93 |
94 |
95 |
96 |
97 | );
98 | };
99 |
100 | export default UrlImportTab;
--------------------------------------------------------------------------------
/src/components/ChallengeContributePage/hooks/index.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 统一导出所有custom hooks
3 | */
4 | export { useBase64UrlEncoder } from './useBase64UrlEncoder';
5 | export { useFormPersistence } from './useFormPersistence';
6 | export { useMarkdownEditor } from './useMarkdownEditor';
7 | export { useYamlGeneration } from './useYamlGeneration';
8 | export { useYamlImport } from './useYamlImport';
9 | export { useYamlParser } from './useYamlParser';
10 | export { useTagsSelector } from './useTagsSelector';
11 | export { useFormScrolling } from './useFormScrolling';
12 | export { useAsyncOperation } from './useAsyncOperation';
13 | export { useFormStyles } from './useFormStyles';
14 | export { useAllTags, useTagsWithFrequency } from './useAllTags';
15 | export {
16 | useEventListener,
17 | dispatchCustomEvent,
18 | dispatchFormValueUpdated,
19 | dispatchTagsUpdated,
20 | dispatchBase64UrlUpdated,
21 | FORM_VALUE_UPDATED,
22 | TAGS_UPDATED,
23 | BASE64_URL_UPDATED
24 | } from './useEventListener';
--------------------------------------------------------------------------------
/src/components/ChallengeContributePage/hooks/useAllTags.ts:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react';
2 | import { challenges } from '../../ChallengeListPage/ChallengeData';
3 |
4 | /**
5 | * 获取标签使用频率的接口
6 | */
7 | export interface TagFrequency {
8 | tag: string;
9 | count: number;
10 | }
11 |
12 | /**
13 | * 从所有挑战中提取唯一标签的Hook
14 | * @returns 所有唯一的标签数组
15 | */
16 | export const useAllTags = (): string[] => {
17 | return useMemo(() => {
18 | // 如果challenges未定义或为空,返回空数组
19 | if (!challenges || challenges.length === 0) {
20 | return [];
21 | }
22 |
23 | // 收集所有标签并去重
24 | const allTags = new Set();
25 |
26 | challenges.forEach(challenge => {
27 | if (challenge.tags && Array.isArray(challenge.tags)) {
28 | challenge.tags.forEach(tag => {
29 | if (tag && typeof tag === 'string') {
30 | allTags.add(tag);
31 | }
32 | });
33 | }
34 | });
35 |
36 | // 将Set转换为数组并按字母顺序排序
37 | return Array.from(allTags).sort((a, b) =>
38 | a.localeCompare(b, undefined, { sensitivity: 'base' })
39 | );
40 | }, [challenges]);
41 | };
42 |
43 | /**
44 | * 从所有挑战中提取标签使用频率的Hook
45 | * @returns 所有标签及其使用频率的数组,按频率降序排序
46 | */
47 | export const useTagsWithFrequency = (): TagFrequency[] => {
48 | return useMemo(() => {
49 | // 如果challenges未定义或为空,返回空数组
50 | if (!challenges || challenges.length === 0) {
51 | return [];
52 | }
53 |
54 | // 统计每个标签出现的频率
55 | const tagFrequency: Record = {};
56 |
57 | challenges.forEach(challenge => {
58 | if (challenge.tags && Array.isArray(challenge.tags)) {
59 | challenge.tags.forEach(tag => {
60 | if (tag && typeof tag === 'string') {
61 | tagFrequency[tag] = (tagFrequency[tag] || 0) + 1;
62 | }
63 | });
64 | }
65 | });
66 |
67 | // 转换为数组并按频率降序排序
68 | return Object.entries(tagFrequency)
69 | .map(([tag, count]) => ({ tag, count }))
70 | .sort((a, b) => b.count - a.count);
71 | }, [challenges]);
72 | };
--------------------------------------------------------------------------------
/src/components/ChallengeContributePage/hooks/useAsyncOperation.ts:
--------------------------------------------------------------------------------
1 | import { useState, useCallback } from 'react';
2 | import { message } from 'antd';
3 |
4 | interface AsyncOperationState {
5 | data: T | null;
6 | loading: boolean;
7 | error: Error | null;
8 | }
9 |
10 | interface AsyncOperationOptions {
11 | onSuccess?: (data: T) => void;
12 | onError?: (error: Error) => void;
13 | showSuccessMessage?: boolean | string;
14 | showErrorMessage?: boolean | string;
15 | }
16 |
17 | /**
18 | * 异步操作管理 Hook
19 | * @param initialData 初始数据
20 | * @param options 配置选项
21 | */
22 | export const useAsyncOperation = (
23 | initialData: T | null = null,
24 | options: AsyncOperationOptions = {}
25 | ) => {
26 | const [state, setState] = useState>({
27 | data: initialData,
28 | loading: false,
29 | error: null
30 | });
31 |
32 | /**
33 | * 执行异步操作
34 | * @param asyncFn 异步函数
35 | * @param operationOptions 本次操作的特定选项,会覆盖全局选项
36 | */
37 | const execute = useCallback(
38 | async (
39 | asyncFn: () => Promise,
40 | operationOptions?: AsyncOperationOptions
41 | ) => {
42 | // 合并全局选项和本次操作特定选项
43 | const mergedOptions = { ...options, ...operationOptions };
44 | const {
45 | onSuccess,
46 | onError,
47 | showSuccessMessage,
48 | showErrorMessage
49 | } = mergedOptions;
50 |
51 | setState(prevState => ({ ...prevState, loading: true, error: null }));
52 |
53 | try {
54 | const result = await asyncFn();
55 | setState({ data: result, loading: false, error: null });
56 |
57 | // 显示成功消息
58 | if (showSuccessMessage) {
59 | const messageText =
60 | typeof showSuccessMessage === 'string'
61 | ? showSuccessMessage
62 | : '操作成功';
63 | message.success(messageText);
64 | }
65 |
66 | // 调用成功回调
67 | if (onSuccess) {
68 | onSuccess(result);
69 | }
70 |
71 | return result;
72 | } catch (err) {
73 | const error = err instanceof Error ? err : new Error(String(err));
74 | setState({ data: null, loading: false, error });
75 |
76 | // 显示错误消息
77 | if (showErrorMessage) {
78 | const messageText =
79 | typeof showErrorMessage === 'string'
80 | ? showErrorMessage
81 | : `操作失败: ${error.message}`;
82 | message.error(messageText);
83 | }
84 |
85 | // 调用错误回调
86 | if (onError) {
87 | onError(error);
88 | }
89 |
90 | throw error;
91 | }
92 | },
93 | [options]
94 | );
95 |
96 | /**
97 | * 重置状态
98 | */
99 | const reset = useCallback(() => {
100 | setState({
101 | data: initialData,
102 | loading: false,
103 | error: null
104 | });
105 | }, [initialData]);
106 |
107 | return {
108 | ...state,
109 | execute,
110 | reset
111 | };
112 | };
113 |
114 | export default useAsyncOperation;
--------------------------------------------------------------------------------
/src/components/ChallengeContributePage/hooks/useBase64UrlEncoder.d.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Base64 URL编码器Hook的类型声明
3 | */
4 | export interface Base64UrlEncoder {
5 | /**
6 | * 将URL编码为Base64格式
7 | * @param url 明文URL
8 | * @returns base64编码的URL
9 | */
10 | encodeUrl: (url: string) => string;
11 |
12 | /**
13 | * 将Base64编码解码为明文URL
14 | * @param base64 Base64编码的URL
15 | * @returns 解码后的明文URL
16 | */
17 | decodeUrl: (base64: string) => string;
18 |
19 | /**
20 | * 确保值是base64格式
21 | * @param value 要检查的值
22 | * @returns 始终返回base64格式的值
23 | */
24 | ensureBase64Format: (value: string) => string;
25 |
26 | /**
27 | * 检查URL是否已经是Base64编码
28 | * @param url 要检查的URL
29 | * @returns 是否为Base64编码
30 | */
31 | isBase64Encoded: (url: string) => boolean;
32 | }
33 |
34 | /**
35 | * Base64 URL编码器Hook
36 | * 提供URL编码和解码功能
37 | */
38 | export function useBase64UrlEncoder(): Base64UrlEncoder;
39 |
40 | /**
41 | * 将URL编码为Base64格式
42 | * @param url 明文URL
43 | * @returns base64编码的URL
44 | */
45 | export function encodeUrl(url: string): string;
46 |
47 | /**
48 | * 将Base64编码解码为明文URL
49 | * @param base64 Base64编码的URL
50 | * @returns 解码后的明文URL
51 | */
52 | export function decodeUrl(base64: string): string;
53 |
54 | /**
55 | * 检查URL是否已经是Base64编码
56 | * @param url 要检查的URL
57 | * @returns 是否为Base64编码
58 | */
59 | export function isBase64Encoded(url: string): boolean;
60 |
61 | /**
62 | * 确保URL是Base64编码的形式
63 | * @param url 原始URL(可能是明文或已编码)
64 | * @param encodeFunc 用于编码的函数
65 | * @returns Base64编码后的URL
66 | */
67 | export function ensureBase64Format(url: string, encodeFunc: (url: string) => string): string;
--------------------------------------------------------------------------------
/src/components/ChallengeContributePage/hooks/useEventListener.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useCallback } from 'react';
2 |
3 | type EventHandler = (event: CustomEvent) => void;
4 |
5 | /**
6 | * 自定义 Hook,用于管理全局事件监听
7 | * @param eventName 要监听的事件名
8 | * @param handler 事件处理函数
9 | * @param dependencies 依赖项数组,用于优化 useEffect 的调用
10 | */
11 | export function useEventListener(
12 | eventName: string,
13 | handler: EventHandler,
14 | dependencies: any[] = []
15 | ): void {
16 | // 包装处理函数,确保引用稳定性
17 | const stableHandler = useCallback(handler, dependencies);
18 |
19 | useEffect(() => {
20 | // 添加事件监听
21 | window.addEventListener(eventName, stableHandler as EventListener);
22 |
23 | // 清理函数,移除事件监听
24 | return () => {
25 | window.removeEventListener(eventName, stableHandler as EventListener);
26 | };
27 | }, [eventName, stableHandler]);
28 | }
29 |
30 | /**
31 | * 触发自定义事件
32 | * @param eventName 要触发的事件名
33 | * @param detail 事件附带的数据
34 | */
35 | export function dispatchCustomEvent(eventName: string, detail?: T): void {
36 | const event = new CustomEvent(eventName, { detail });
37 | window.dispatchEvent(event);
38 | }
39 |
40 | /**
41 | * 表单值更新事件常量
42 | */
43 | export const FORM_VALUE_UPDATED = 'form-value-updated';
44 |
45 | /**
46 | * 标签更新事件常量
47 | */
48 | export const TAGS_UPDATED = 'tags-updated';
49 |
50 | /**
51 | * Base64 URL 更新事件常量
52 | */
53 | export const BASE64_URL_UPDATED = 'base64-url-updated';
54 |
55 | /**
56 | * 触发表单值更新事件
57 | */
58 | export function dispatchFormValueUpdated(): void {
59 | dispatchCustomEvent(FORM_VALUE_UPDATED);
60 | }
61 |
62 | /**
63 | * 触发标签更新事件
64 | * @param tags 更新后的标签数组
65 | */
66 | export function dispatchTagsUpdated(tags: string[]): void {
67 | dispatchCustomEvent(TAGS_UPDATED, { tags });
68 | }
69 |
70 | /**
71 | * 触发 Base64 URL 更新事件
72 | * @param base64Url 更新后的 Base64 URL
73 | */
74 | export function dispatchBase64UrlUpdated(base64Url: string): void {
75 | dispatchCustomEvent(BASE64_URL_UPDATED, { base64Url });
76 | }
--------------------------------------------------------------------------------
/src/components/ChallengeContributePage/hooks/useFormScrolling.ts:
--------------------------------------------------------------------------------
1 | import { useCallback } from 'react';
2 | import { FormInstance } from 'antd';
3 |
4 | /**
5 | * 表单字段滚动与高亮 Hook
6 | * @param form 表单实例
7 | */
8 | export const useFormScrolling = (form: FormInstance) => {
9 | /**
10 | * 滚动到指定表单字段并高亮显示
11 | * @param fieldName 字段名
12 | */
13 | const scrollToField = useCallback((fieldName: string) => {
14 | // 映射字段名到更具体的元素ID或选择器
15 | const fieldMapping: Record = {
16 | 'name': 'input[id$=name]',
17 | 'difficultyLevel': '[id$=difficultyLevel]',
18 | 'description': '.editor-container', // markdown编辑器容器
19 | 'base64Url': 'input[id$=base64Url]',
20 | 'tags': '[id$=tags]',
21 | // 添加其他字段的映射
22 | };
23 |
24 | // 尝试查找元素
25 | try {
26 | const selector = fieldMapping[fieldName] || `[id$=${fieldName}]`;
27 | const element = document.querySelector(selector);
28 |
29 | if (element) {
30 | // 滚动到元素
31 | element.scrollIntoView({ behavior: 'smooth', block: 'center' });
32 |
33 | // 尝试聚焦元素(如果是输入控件)
34 | if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
35 | setTimeout(() => element.focus(), 500);
36 | }
37 |
38 | // 高亮显示元素
39 | element.classList.add('field-highlight');
40 | setTimeout(() => element.classList.remove('field-highlight'), 3000);
41 | } else {
42 | console.warn(`找不到字段 ${fieldName} 对应的DOM元素`);
43 |
44 | // 回退方案:使用表单API滚动到字段
45 | form.scrollToField(fieldName);
46 | }
47 | } catch (error) {
48 | console.error('滚动到字段时出错:', error);
49 | // 回退方案
50 | form.scrollToField(fieldName);
51 | }
52 | }, [form]);
53 |
54 | /**
55 | * 高亮显示特定DOM元素
56 | * @param element 要高亮的DOM元素
57 | * @param duration 高亮持续时间(毫秒)
58 | */
59 | const highlightElement = useCallback((element: Element, duration: number = 3000) => {
60 | element.classList.add('field-highlight');
61 | setTimeout(() => element.classList.remove('field-highlight'), duration);
62 | }, []);
63 |
64 | return {
65 | scrollToField,
66 | highlightElement
67 | };
68 | };
69 |
70 | export default useFormScrolling;
--------------------------------------------------------------------------------
/src/components/ChallengeContributePage/hooks/useFormStyles.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 |
3 | /**
4 | * 自定义Hook:应用表单样式
5 | * 为表单添加全局CSS样式
6 | */
7 | export const useFormStyles = () => {
8 | useEffect(() => {
9 | // 添加表单全局样式
10 | const style = document.createElement('style');
11 | style.innerHTML = `
12 | .ant-form-item-label > label {
13 | font-weight: 500;
14 | }
15 |
16 | .form-section {
17 | margin-bottom: 24px;
18 | padding: 20px;
19 | background: #fff;
20 | border-radius: 8px;
21 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
22 | }
23 |
24 | .form-section-title {
25 | font-size: 18px;
26 | font-weight: 600;
27 | margin-bottom: 16px;
28 | color: #222;
29 | border-bottom: 1px solid #f0f0f0;
30 | padding-bottom: 12px;
31 | }
32 |
33 | .form-section-subtitle {
34 | font-size: 14px;
35 | color: #666;
36 | margin-bottom: 16px;
37 | }
38 |
39 | .form-footer {
40 | padding: 16px 0;
41 | margin-top: 20px;
42 | text-align: center;
43 | }
44 |
45 | .required-field::after {
46 | content: '*';
47 | color: #ff4d4f;
48 | margin-left: 4px;
49 | }
50 | `;
51 |
52 | document.head.appendChild(style);
53 |
54 | // 清理函数
55 | return () => {
56 | document.head.removeChild(style);
57 | };
58 | }, []);
59 | };
60 |
61 | export default useFormStyles;
--------------------------------------------------------------------------------
/src/components/ChallengeContributePage/hooks/useFormValidator.ts:
--------------------------------------------------------------------------------
1 | import { useCallback } from 'react';
2 | import { FormValues } from '../types';
3 |
4 | interface ValidationResult {
5 | isValid: boolean;
6 | errors: {
7 | field: string;
8 | message: string;
9 | }[];
10 | }
11 |
12 | /**
13 | * 自定义Hook:表单验证
14 | * 提供表单字段验证功能
15 | */
16 | export const useFormValidator = () => {
17 | /**
18 | * 验证表单数据
19 | * @param values 表单值
20 | * @returns 验证结果
21 | */
22 | const validateForm = useCallback((values: FormValues): ValidationResult => {
23 | const errors: { field: string; message: string }[] = [];
24 |
25 | // 验证基本信息
26 | if (!values.id) {
27 | errors.push({ field: 'id', message: '题目ID不能为空' });
28 | } else if (!/^\d+$/.test(values.id)) {
29 | errors.push({ field: 'id', message: '题目ID必须为数字' });
30 | }
31 |
32 | if (!values.platform) {
33 | errors.push({ field: 'platform', message: '请选择题目平台' });
34 | }
35 |
36 | if (!values.nameZh && !values.nameEn) {
37 | errors.push({ field: 'nameZh', message: '中文名称和英文名称至少填写一项' });
38 | errors.push({ field: 'nameEn', message: '中文名称和英文名称至少填写一项' });
39 | }
40 |
41 | // 验证难度
42 | if (!values.difficulty) {
43 | errors.push({ field: 'difficulty', message: '请选择题目难度' });
44 | }
45 |
46 | // 验证描述内容
47 | if (!values.contentZh && !values.contentEn) {
48 | errors.push({ field: 'contentZh', message: '中文内容和英文内容至少填写一项' });
49 | errors.push({ field: 'contentEn', message: '中文内容和英文内容至少填写一项' });
50 | }
51 |
52 | return {
53 | isValid: errors.length === 0,
54 | errors
55 | };
56 | }, []);
57 |
58 | /**
59 | * 高亮显示错误字段
60 | * @param fieldName 字段名称
61 | */
62 | const highlightField = useCallback((fieldName: string) => {
63 | // 查找字段对应的DOM元素
64 | const field = document.querySelector(`[data-field="${fieldName}"]`);
65 | if (field) {
66 | field.scrollIntoView({ behavior: 'smooth', block: 'center' });
67 | field.classList.add('field-error');
68 |
69 | // 3秒后移除高亮
70 | setTimeout(() => {
71 | field.classList.remove('field-error');
72 | }, 3000);
73 | }
74 | }, []);
75 |
76 | return {
77 | validateForm,
78 | highlightField
79 | };
80 | };
--------------------------------------------------------------------------------
/src/components/ChallengeContributePage/hooks/useYamlImport.ts:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { FormInstance, message } from 'antd';
3 | import { ChallengeFormData } from '../types';
4 | import { parseYamlString } from '../utils/yamlUtils';
5 | import { dispatchCustomEvent } from './useEventListener';
6 |
7 | interface UseYamlImportProps {
8 | form: FormInstance;
9 | calculateNextId: () => number;
10 | }
11 |
12 | /**
13 | * YAML导入逻辑钩子
14 | */
15 | export const useYamlImport = ({ form, calculateNextId }: UseYamlImportProps) => {
16 | // 处理YAML导入
17 | const handleImportYaml = (yamlContent: string) => {
18 | try {
19 | console.log('开始导入YAML, 长度:', yamlContent.length, '字节');
20 |
21 | // 使用新的YAML解析函数
22 | const formData = parseYamlString(yamlContent);
23 |
24 | if (!formData) {
25 | throw new Error('解析YAML失败');
26 | }
27 |
28 | // 如果缺少ID,使用自动生成的ID
29 | if (formData.id === undefined || formData.id === null) {
30 | console.warn('导入的YAML缺少id字段,将使用自动生成的ID');
31 | formData.id = calculateNextId();
32 | }
33 |
34 | // 更新时间字段为当前时间
35 | const currentDateTime = new Date().toISOString().replace('T', ' ').substring(0, 19);
36 | formData.updateTime = currentDateTime;
37 |
38 | console.log('正在设置表单值');
39 |
40 | // 先重置表单
41 | form.resetFields();
42 |
43 | // 设置解析后的值
44 | form.setFieldsValue(formData);
45 |
46 | // 手动触发表单字段的值变更事件,确保所有组件获取到最新值
47 | // 发送描述字段更新事件
48 | dispatchCustomEvent('description-updated', {
49 | description: formData.description || formData.descriptionMarkdown,
50 | descriptionEn: formData.descriptionEn || formData.descriptionMarkdownEn
51 | });
52 |
53 | // 处理base64Url
54 | if (formData.base64Url) {
55 | dispatchCustomEvent('base64-url-updated', {
56 | base64Url: formData.base64Url.toString()
57 | });
58 | }
59 |
60 | // 处理标签
61 | if (formData.tags && Array.isArray(formData.tags)) {
62 | dispatchCustomEvent('tags-updated', { tags: formData.tags });
63 | }
64 |
65 | // 处理参考资料
66 | if (formData.solutions && Array.isArray(formData.solutions)) {
67 | dispatchCustomEvent('solutions-updated', { solutions: formData.solutions });
68 | }
69 |
70 | // 显示成功信息
71 | message.success('YAML数据已成功导入到表单');
72 | return formData;
73 | } catch (error) {
74 | console.error('解析YAML失败:', error);
75 | message.error(`YAML导入失败: ${error instanceof Error ? error.message : '未知错误'}`);
76 | return false;
77 | }
78 | };
79 |
80 | return {
81 | handleImportYaml
82 | };
83 | };
--------------------------------------------------------------------------------
/src/components/ChallengeContributePage/styles.ts:
--------------------------------------------------------------------------------
1 | import { CSSProperties } from 'react';
2 |
3 | /**
4 | * 题目贡献页面样式
5 | */
6 | export const styles: Record = {
7 | container: {
8 | maxWidth: 1000,
9 | margin: '0 auto',
10 | padding: '20px 16px'
11 | },
12 | alert: {
13 | marginBottom: 24
14 | },
15 | form: {
16 | width: '100%'
17 | },
18 | formItem: {
19 | marginBottom: 16
20 | },
21 | submitButton: {
22 | marginTop: 24
23 | },
24 | previewContainer: {
25 | border: '1px solid #d9d9d9',
26 | padding: 16,
27 | borderRadius: 4,
28 | backgroundColor: '#f5f5f5',
29 | marginBottom: 16
30 | },
31 | tagContainer: {
32 | marginBottom: 8
33 | },
34 | solutionContainer: {
35 | marginBottom: 16,
36 | border: '1px dashed #d9d9d9',
37 | padding: 16
38 | },
39 | // 步骤标题
40 | stepTitle: {
41 | fontSize: 18,
42 | fontWeight: 600,
43 | marginBottom: 24,
44 | borderBottom: '1px solid #f0f0f0',
45 | paddingBottom: 12
46 | },
47 | // 步骤说明
48 | stepDescription: {
49 | fontSize: 14,
50 | color: '#666',
51 | marginBottom: 24
52 | },
53 | // 卡片样式
54 | stepCard: {
55 | marginBottom: 24,
56 | boxShadow: '0 1px 5px rgba(0,0,0,0.05)',
57 | borderRadius: 8
58 | },
59 | // 卡片标题
60 | cardTitle: {
61 | display: 'flex',
62 | alignItems: 'center',
63 | gap: 8,
64 | marginBottom: 16
65 | },
66 | // 表单分组标题
67 | sectionTitle: {
68 | fontSize: 16,
69 | fontWeight: 500,
70 | marginBottom: 16,
71 | color: '#1890ff'
72 | },
73 | // 底部固定按钮区
74 | footerButtons: {
75 | boxShadow: '0 -2px 8px rgba(0,0,0,0.15)',
76 | padding: '16px 24px',
77 | display: 'flex',
78 | justifyContent: 'space-between',
79 | backgroundColor: '#fff',
80 | borderRadius: '0 0 8px 8px',
81 | width: '100%',
82 | maxWidth: 1000,
83 | margin: '0 auto'
84 | },
85 | // 响应式设计 - 移动端适配
86 | mobileContainer: {
87 | padding: '16px 12px'
88 | },
89 | // YAML预览区样式
90 | yamlPreview: {
91 | fontFamily: 'monospace',
92 | backgroundColor: '#f5f5f5',
93 | padding: '16px',
94 | borderRadius: '4px',
95 | whiteSpace: 'pre-wrap',
96 | maxHeight: '400px',
97 | overflowY: 'auto',
98 | fontSize: '13px',
99 | border: '1px solid #e8e8e8'
100 | },
101 | // 强调文本
102 | highlight: {
103 | color: '#1890ff',
104 | fontWeight: 500
105 | },
106 | // 分隔线样式
107 | divider: {
108 | margin: '32px 0'
109 | },
110 | // 表单区块容器
111 | formSection: {
112 | marginBottom: 40
113 | },
114 | // 表单区块内部内容
115 | formSectionContent: {
116 | padding: '0 16px'
117 | },
118 | // 表单底部区域
119 | formBottom: {
120 | marginTop: 40,
121 | marginBottom: 80 // 给底部固定按钮留空间
122 | },
123 | // 进度指示器容器
124 | progressContainer: {
125 | marginBottom: 24,
126 | padding: '12px 16px',
127 | backgroundColor: '#f9f9f9',
128 | borderRadius: '8px',
129 | boxShadow: '0 1px 2px rgba(0,0,0,0.03)'
130 | }
131 | };
--------------------------------------------------------------------------------
/src/components/ChallengeContributePage/types.ts:
--------------------------------------------------------------------------------
1 | import { FormInstance } from 'antd';
2 |
3 | export interface Solution {
4 | /**
5 | * 参考资料标题
6 | */
7 | title: string;
8 |
9 | /**
10 | * 参考资料链接
11 | */
12 | url: string;
13 |
14 | /**
15 | * 来源平台
16 | */
17 | source?: string;
18 |
19 | /**
20 | * 作者
21 | */
22 | author?: string;
23 | }
24 |
25 | export interface ChallengeFormData {
26 | id?: number | null;
27 | idAlias?: string;
28 | platform?: 'Web' | 'Android' | 'iOS';
29 | name?: string;
30 | nameEn?: string;
31 | difficultyLevel?: number;
32 | description?: string;
33 | descriptionEn?: string;
34 | // 兼容markdown编辑器
35 | descriptionMarkdown?: string;
36 | descriptionMarkdownEn?: string;
37 | // 目标网站URL的base64编码
38 | base64Url?: string;
39 | // 链接是否失效标志
40 | isExpired?: boolean;
41 | example?: string;
42 | tags?: string[];
43 | solutions?: {
44 | title: string;
45 | url: string;
46 | source?: string;
47 | author?: string;
48 | }[];
49 | // 测试用例
50 | testCases?: string[];
51 | // YAML注释
52 | comments?: string[];
53 | // 原始YAML文本(用于保留注释)
54 | rawYaml?: string;
55 | // 创建时间
56 | createTime?: string;
57 | // 更新时间
58 | updateTime?: string;
59 | }
60 |
61 | export interface ChallengeData {
62 | id: number | null;
63 | 'id-alias'?: string;
64 | tags: string[];
65 | platform: 'Web' | 'Android' | 'iOS';
66 | name: string;
67 | name_en?: string;
68 | 'difficulty-level': number;
69 | 'description-markdown': string;
70 | 'description-markdown_en'?: string;
71 | 'base64-url': string;
72 | 'is-expired': boolean;
73 | solutions: {
74 | title: string;
75 | url: string;
76 | source?: string;
77 | author?: string;
78 | }[];
79 | 'create-time': string;
80 | 'update-time': string;
81 | }
82 |
83 | export type DifficultyLevel = 1 | 2 | 3 | 4 | 5;
84 |
85 | export interface SectionProps {
86 | form: FormInstance;
87 | onChange?: (values: any) => void;
88 | }
--------------------------------------------------------------------------------
/src/components/ChallengeContributePage/utils/i18nUtils.ts:
--------------------------------------------------------------------------------
1 | import { i18n } from 'i18next';
2 |
3 | /**
4 | * 格式化日期
5 | * @param date 日期对象或时间戳
6 | * @param i18nInstance i18next实例
7 | * @returns 格式化后的日期字符串
8 | */
9 | export const formatDate = (date: Date | number, i18nInstance: i18n): string => {
10 | const dateObj = typeof date === 'number' ? new Date(date) : date;
11 | const options: Intl.DateTimeFormatOptions = {
12 | year: 'numeric',
13 | month: 'long',
14 | day: 'numeric',
15 | hour: '2-digit',
16 | minute: '2-digit',
17 | };
18 |
19 | return dateObj.toLocaleString(i18nInstance.language, options);
20 | };
21 |
22 | /**
23 | * 格式化数字
24 | * @param num 数字
25 | * @param i18nInstance i18next实例
26 | * @returns 格式化后的数字字符串
27 | */
28 | export const formatNumber = (num: number, i18nInstance: i18n): string => {
29 | return new Intl.NumberFormat(i18nInstance.language).format(num);
30 | };
31 |
32 | /**
33 | * 格式化文件大小
34 | * @param bytes 字节数
35 | * @param i18nInstance i18next实例
36 | * @returns 格式化后的文件大小字符串
37 | */
38 | export const formatFileSize = (bytes: number, i18nInstance: i18n): string => {
39 | if (bytes === 0) return '0 B';
40 |
41 | const k = 1024;
42 | const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
43 | const i = Math.floor(Math.log(bytes) / Math.log(k));
44 |
45 | return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
46 | };
47 |
48 | /**
49 | * 格式化持续时间
50 | * @param milliseconds 毫秒数
51 | * @param i18nInstance i18next实例
52 | * @returns 格式化后的持续时间字符串
53 | */
54 | export const formatDuration = (milliseconds: number, i18nInstance: i18n): string => {
55 | const seconds = Math.floor(milliseconds / 1000);
56 | const minutes = Math.floor(seconds / 60);
57 | const hours = Math.floor(minutes / 60);
58 | const days = Math.floor(hours / 24);
59 |
60 | const parts = [];
61 |
62 | if (days > 0) {
63 | parts.push(`${days}${i18nInstance.t('time.days')}`);
64 | }
65 | if (hours % 24 > 0) {
66 | parts.push(`${hours % 24}${i18nInstance.t('time.hours')}`);
67 | }
68 | if (minutes % 60 > 0) {
69 | parts.push(`${minutes % 60}${i18nInstance.t('time.minutes')}`);
70 | }
71 | if (seconds % 60 > 0) {
72 | parts.push(`${seconds % 60}${i18nInstance.t('time.seconds')}`);
73 | }
74 |
75 | return parts.join(' ');
76 | };
77 |
78 | /**
79 | * 格式化相对时间
80 | * @param date 日期对象或时间戳
81 | * @param i18nInstance i18next实例
82 | * @returns 格式化后的相对时间字符串
83 | */
84 | export const formatRelativeTime = (date: Date | number, i18nInstance: i18n): string => {
85 | const dateObj = typeof date === 'number' ? new Date(date) : date;
86 | const now = new Date();
87 | const diffInSeconds = Math.floor((now.getTime() - dateObj.getTime()) / 1000);
88 |
89 | if (diffInSeconds < 60) {
90 | return i18nInstance.t('time.justNow');
91 | }
92 |
93 | const diffInMinutes = Math.floor(diffInSeconds / 60);
94 | if (diffInMinutes < 60) {
95 | return i18nInstance.t('time.minutesAgo', { count: diffInMinutes });
96 | }
97 |
98 | const diffInHours = Math.floor(diffInMinutes / 60);
99 | if (diffInHours < 24) {
100 | return i18nInstance.t('time.hoursAgo', { count: diffInHours });
101 | }
102 |
103 | const diffInDays = Math.floor(diffInHours / 24);
104 | if (diffInDays < 30) {
105 | return i18nInstance.t('time.daysAgo', { count: diffInDays });
106 | }
107 |
108 | const diffInMonths = Math.floor(diffInDays / 30);
109 | if (diffInMonths < 12) {
110 | return i18nInstance.t('time.monthsAgo', { count: diffInMonths });
111 | }
112 |
113 | const diffInYears = Math.floor(diffInMonths / 12);
114 | return i18nInstance.t('time.yearsAgo', { count: diffInYears });
115 | };
--------------------------------------------------------------------------------
/src/components/ChallengeContributePage/utils/markdownStyleUtils.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Markdown编辑器样式工具
3 | * 提供Markdown编辑器所需的所有样式定义
4 | */
5 |
6 | // 编辑器主样式
7 | export const markdownEditorStyles = `
8 | /* 在全屏模式下隐藏GitHub徽标 */
9 | .rc-md-editor.full ~ .github-fork-ribbon-wrapper,
10 | .rc-md-editor.full ~ div > .github-fork-ribbon-wrapper,
11 | .rc-md-editor.full ~ * > .github-fork-ribbon-wrapper,
12 | .rc-md-editor.full ~ * > * > .github-fork-ribbon-wrapper {
13 | display: none !important;
14 | opacity: 0 !important;
15 | visibility: hidden !important;
16 | pointer-events: none !important;
17 | z-index: -1 !important;
18 | }
19 |
20 | /* 修复全屏控制按钮样式 */
21 | .rc-md-editor .full-screen {
22 | z-index: 9999 !important;
23 | }
24 |
25 | /* 在全屏模式下添加自己的样式 */
26 | .rc-md-editor.full {
27 | position: fixed !important;
28 | top: 0 !important;
29 | left: 0 !important;
30 | right: 0 !important;
31 | bottom: 0 !important;
32 | width: 100vw !important;
33 | height: 100vh !important;
34 | z-index: 9999 !important;
35 | background: white !important;
36 | }
37 |
38 | /* 确保全屏模式下编辑器在最上层 */
39 | .rc-md-editor.full .rc-md-navigation {
40 | z-index: 10000 !important;
41 | }
42 |
43 | /* 简化base64图片显示 */
44 | .rc-md-editor .editor-container .sec-md .input {
45 | position: relative;
46 | }
47 |
48 | .rc-md-editor .editor-container .sec-md .input img[src^="data:"] {
49 | display: none;
50 | }
51 |
52 | .rc-md-editor .editor-container .sec-md .input p:has(img[src^="data:"]),
53 | .rc-md-editor .editor-container .sec-md .input p > img[src^="data:"] {
54 | position: relative;
55 | display: inline-block;
56 | min-width: 50px;
57 | min-height: 30px;
58 | background: #f0f0f0;
59 | border-radius: 4px;
60 | margin: 4px 0;
61 | }
62 |
63 | .rc-md-editor .editor-container .sec-md .input p:has(img[src^="data:"]):before,
64 | .rc-md-editor .editor-container .sec-md .input p > img[src^="data:"]:before {
65 | content: "[图片]";
66 | position: absolute;
67 | left: 0;
68 | top: 0;
69 | padding: 2px 8px;
70 | color: #666;
71 | font-size: 12px;
72 | background: #f0f0f0;
73 | border-radius: 4px;
74 | z-index: 1;
75 | }
76 | `;
77 |
78 | // 编辑器预览区样式
79 | export const customEditorStyles = `
80 | /* 在编辑器中简化base64显示 */
81 | .rc-md-editor .editor-container .sec-md .input {
82 | position: relative;
83 | }
84 |
85 | .rc-md-editor .editor-container .sec-md .input {
86 | font-size: 14px;
87 | line-height: 1.6;
88 | }
89 |
90 | /* 使用CSS省略号效果 */
91 | .rc-md-editor .editor-container .sec-md .input a[href^="data:image"] {
92 | display: inline-block;
93 | max-width: 100px;
94 | overflow: hidden;
95 | text-overflow: ellipsis;
96 | white-space: nowrap;
97 | vertical-align: bottom;
98 | }
99 |
100 | /* 添加提示 */
101 | .rc-md-editor .editor-container .sec-md .input a[href^="data:image"]:hover::after {
102 | content: "[图片数据已省略]";
103 | position: absolute;
104 | background: #f0f0f0;
105 | padding: 2px 6px;
106 | border-radius: 4px;
107 | font-size: 12px;
108 | color: #666;
109 | margin-left: 8px;
110 | }
111 | `;
112 |
113 | // 编辑器配置
114 | export const getEditorConfig = () => ({
115 | view: {
116 | menu: true,
117 | md: true,
118 | html: true
119 | },
120 | canView: {
121 | menu: true,
122 | md: true,
123 | html: true,
124 | fullScreen: true,
125 | hideMenu: true
126 | },
127 | plugins: ['full-screen'],
128 | imageAccept: '.jpg,.jpeg,.png,.gif',
129 | onImageUpload: true
130 | });
--------------------------------------------------------------------------------
/src/components/ChallengeContributePage/utils/textUtils.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 文本处理工具函数
3 | */
4 |
5 | /**
6 | * 安全地将任何类型的值转换为字符串
7 | * 处理null、undefined、对象等各种类型
8 | * @param value 任意类型的值
9 | * @returns 转换后的字符串
10 | */
11 | export const safeToString = (value: any): string => {
12 | if (value === null || value === undefined) {
13 | return '';
14 | }
15 |
16 | // 如果已经是字符串类型,直接返回
17 | if (typeof value === 'string') {
18 | return value;
19 | }
20 |
21 | // 如果是对象类型
22 | if (typeof value === 'object') {
23 | try {
24 | // 检查是否是编辑器的特定数据格式
25 | if (value.text && typeof value.text === 'string') {
26 | return value.text;
27 | }
28 |
29 | // 检查是否包含html或markdown属性(有些编辑器组件可能使用这种结构)
30 | if (value.markdown && typeof value.markdown === 'string') {
31 | return value.markdown;
32 | }
33 | if (value.html && typeof value.html === 'string') {
34 | return value.html;
35 | }
36 |
37 | // 检查是否有特定的数据结构
38 | if (value.content && typeof value.content === 'string') {
39 | return value.content;
40 | }
41 |
42 | // 检查是否有toString方法且不是默认的Object.toString
43 | if (value.toString && typeof value.toString === 'function' && value.toString() !== '[object Object]') {
44 | return value.toString();
45 | }
46 |
47 | // 最后尝试JSON序列化,但忽略循环引用的对象
48 | try {
49 | const jsonStr = JSON.stringify(value);
50 | return jsonStr === '{}' ? '' : jsonStr;
51 | } catch (jsonError) {
52 | console.warn('无法将对象转换为JSON字符串:', jsonError);
53 | return '';
54 | }
55 | } catch (e) {
56 | console.error('无法将对象转换为字符串', e);
57 | return '';
58 | }
59 | }
60 |
61 | // 基本类型直接转字符串
62 | return String(value);
63 | };
64 |
65 | /**
66 | * 检查字符串是否为"[object Object]"
67 | * @param text 要检查的字符串
68 | * @returns 是否为无效对象文本
69 | */
70 | export const isInvalidObjectString = (text: string): boolean => {
71 | return text === '[object Object]';
72 | };
73 |
74 | /**
75 | * 安全地获取字符串长度,处理null和undefined
76 | * @param text 要获取长度的字符串
77 | * @returns 字符串长度
78 | */
79 | export const safeStringLength = (text: any): number => {
80 | const str = safeToString(text);
81 | return str.length;
82 | };
--------------------------------------------------------------------------------
/src/components/ChallengeContributePage/utils/validators.ts:
--------------------------------------------------------------------------------
1 | import { Rule } from 'antd/es/form';
2 | import { ChallengeFormData } from '../types';
3 |
4 | /**
5 | * 集中管理表单验证规则
6 | */
7 |
8 | // 基本必填验证
9 | export const requiredRule = (message: string): Rule => ({
10 | required: true,
11 | message
12 | });
13 |
14 | // ID验证规则
15 | export const idValidators = [
16 | requiredRule('请输入挑战ID'),
17 | {
18 | pattern: /^\d+$/,
19 | message: 'ID必须是数字'
20 | }
21 | ];
22 |
23 | // 名称验证规则
24 | export const nameValidators = [
25 | requiredRule('请输入挑战名称'),
26 | {
27 | min: 3,
28 | message: '名称至少需要3个字符'
29 | }
30 | ];
31 |
32 | // 名称英文验证规则 (非必填)
33 | export const nameEnValidators = [
34 | {
35 | min: 3,
36 | message: '英文名称至少需要3个字符'
37 | }
38 | ];
39 |
40 | // URL验证器工厂函数
41 | export const createUrlValidators = (decodeFunc: (value: string) => string): Rule[] => {
42 | return [
43 | requiredRule('请输入URL'),
44 | {
45 | validator: async (_: any, value: string) => {
46 | if (!value) return Promise.resolve();
47 |
48 | try {
49 | // 尝试解码,如果是base64格式
50 | const url = decodeFunc ? decodeFunc(value) : value;
51 |
52 | // 检查URL格式
53 | if (!url.startsWith('http://') && !url.startsWith('https://')) {
54 | return Promise.reject('URL必须以http://或https://开头');
55 | }
56 |
57 | // 尝试构造URL对象验证URL格式
58 | try {
59 | new URL(url);
60 | return Promise.resolve();
61 | } catch (e) {
62 | return Promise.reject('URL格式不正确');
63 | }
64 | } catch (error) {
65 | return Promise.reject('URL格式不正确,请检查输入');
66 | }
67 | }
68 | }
69 | ];
70 | };
71 |
72 | // 标签验证
73 | export const tagsValidators = [
74 | {
75 | validator: (_: any, value: string[]) => {
76 | if (!value || value.length === 0) {
77 | return Promise.reject('请至少添加一个标签');
78 | }
79 | return Promise.resolve();
80 | }
81 | }
82 | ];
83 |
84 | // 参考资料验证
85 | export const solutionsValidator = {
86 | validator: (_: any, value: any) => {
87 | if (!value || value.length === 0) {
88 | // 参考资料是可选的,允许为空
89 | return Promise.resolve();
90 | }
91 |
92 | if (!Array.isArray(value)) {
93 | return Promise.reject('参考资料必须是数组');
94 | }
95 |
96 | // 检查每个参考资料的必填字段
97 | for (let i = 0; i < value.length; i++) {
98 | const solution = value[i];
99 | if (!solution.title || !solution.title.trim()) {
100 | return Promise.reject(`第${i+1}个参考资料缺少标题`);
101 | }
102 | if (!solution.url || !solution.url.trim()) {
103 | return Promise.reject(`第${i+1}个参考资料缺少URL`);
104 | }
105 |
106 | // 验证URL格式
107 | if (!solution.url.startsWith('http://') && !solution.url.startsWith('https://')) {
108 | return Promise.reject(`第${i+1}个参考资料的URL必须以http://或https://开头`);
109 | }
110 | }
111 |
112 | return Promise.resolve();
113 | }
114 | };
115 |
116 | // 描述验证
117 | export const descriptionValidators = [
118 | requiredRule('请输入挑战描述'),
119 | {
120 | min: 10,
121 | message: '描述至少需要10个字符'
122 | }
123 | ];
124 |
125 | // 难度验证
126 | export const difficultyValidators = [
127 | requiredRule('请选择难度级别')
128 | ];
--------------------------------------------------------------------------------
/src/components/ChallengeContributePage/utils/yamlCommentProcessor.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * YAML 注释处理模块
3 | * 用于提取、保留和恢复 YAML 文件中的注释
4 | */
5 |
6 | /**
7 | * 从YAML字符串中提取文件级和文档级注释
8 | * @param yamlString YAML原始字符串
9 | * @returns 提取的注释部分和内容开始索引
10 | */
11 | export function extractYamlComments(yamlString: string): {
12 | headerComments: string;
13 | documentComments: { [key: string]: string[] };
14 | contentStartIndex: number;
15 | } {
16 | const lines = yamlString.split('\n');
17 | const headerLines: string[] = [];
18 | let contentStartIndex = 0;
19 |
20 | // 提取文件顶部注释
21 | for (let i = 0; i < lines.length; i++) {
22 | const line = lines[i];
23 | if (line.trim() === '' || line.trim().startsWith('#')) {
24 | headerLines.push(line);
25 | contentStartIndex = i + 1;
26 | } else {
27 | break;
28 | }
29 | }
30 |
31 | // 提取文档中的字段级注释
32 | const documentComments: { [key: string]: string[] } = {};
33 | let currentField: string | null = null;
34 | let commentBuffer: string[] = [];
35 |
36 | for (let i = contentStartIndex; i < lines.length; i++) {
37 | const line = lines[i].trim();
38 |
39 | if (line.startsWith('#')) {
40 | // 这是一行注释
41 | commentBuffer.push(lines[i]);
42 | } else if (line === '') {
43 | // 空行,跳过
44 | continue;
45 | } else {
46 | // 这是一个字段行
47 | const fieldMatch = line.match(/^(\s*)([a-zA-Z0-9_-]+):/);
48 | if (fieldMatch) {
49 | const [, indent, fieldName] = fieldMatch;
50 |
51 | // 如果有待处理的注释,将其与当前字段关联
52 | if (commentBuffer.length > 0) {
53 | documentComments[fieldName] = [...commentBuffer];
54 | commentBuffer = [];
55 | }
56 |
57 | currentField = fieldName;
58 | } else {
59 | // 不是字段开始,可能是复杂结构的一部分
60 | // 清空注释缓冲区,因为我们不知道应该将其关联到哪个字段
61 | commentBuffer = [];
62 | }
63 | }
64 | }
65 |
66 | return {
67 | headerComments: headerLines.join('\n'),
68 | documentComments,
69 | contentStartIndex
70 | };
71 | }
72 |
73 | /**
74 | * 提取单个挑战的字段注释
75 | * @param challengeText 挑战文本
76 | * @returns 字段与注释的映射
77 | */
78 | export function extractChallengeFieldComments(challengeText: string): { [key: string]: string[] } {
79 | const lines = challengeText.split('\n');
80 | const challengeWithComments: { [key: string]: string[] } = {};
81 | let currentField: string | null = null;
82 | let commentBuffer: string[] = [];
83 |
84 | // 从挑战文本中提取字段和对应的注释
85 | for (let i = 0; i < lines.length; i++) {
86 | const line = lines[i];
87 | const trimmedLine = line.trim();
88 |
89 | // 跳过第一行(挑战开始行)
90 | if (i === 0) continue;
91 |
92 | if (trimmedLine.startsWith('#')) {
93 | // 这是注释行,添加到缓冲区
94 | commentBuffer.push(line);
95 | } else if (trimmedLine === '') {
96 | // 空行,跳过
97 | continue;
98 | } else {
99 | // 检查是否是字段行
100 | const fieldMatch = line.match(/^(\s*)([a-zA-Z0-9_-]+):/);
101 | if (fieldMatch) {
102 | // 这是一个字段
103 | const fieldName = fieldMatch[2];
104 |
105 | // 如果有待处理的注释,关联到这个字段
106 | if (commentBuffer.length > 0) {
107 | challengeWithComments[fieldName] = [...commentBuffer];
108 | commentBuffer = [];
109 | }
110 |
111 | currentField = fieldName;
112 | } else {
113 | // 不是字段开始,可能是复杂字段的一部分
114 | // 我们保留这些注释,可能是块级注释或值的一部分
115 | if (commentBuffer.length > 0 && currentField) {
116 | // 如果已经有这个字段的注释,添加到现有注释中
117 | if (challengeWithComments[currentField]) {
118 | challengeWithComments[currentField].push(...commentBuffer);
119 | } else {
120 | challengeWithComments[currentField] = [...commentBuffer];
121 | }
122 | commentBuffer = [];
123 | }
124 | }
125 | }
126 | }
127 |
128 | return challengeWithComments;
129 | }
--------------------------------------------------------------------------------
/src/components/ChallengeContributePage/utils/yamlParser.ts:
--------------------------------------------------------------------------------
1 | import * as YAML from 'yaml';
2 |
3 | /**
4 | * 从YAML字符串中提取文件级和文档级注释
5 | * @param yamlString YAML原始字符串
6 | * @returns 提取的注释部分和内容开始索引
7 | */
8 | export function extractYamlComments(yamlString: string): {
9 | headerComments: string;
10 | documentComments: { [key: string]: string[] };
11 | contentStartIndex: number;
12 | } {
13 | const lines = yamlString.split('\n');
14 | const headerLines: string[] = [];
15 | let contentStartIndex = 0;
16 |
17 | // 提取文件顶部注释
18 | for (let i = 0; i < lines.length; i++) {
19 | const line = lines[i];
20 | if (line.trim() === '' || line.trim().startsWith('#')) {
21 | headerLines.push(line);
22 | contentStartIndex = i + 1;
23 | } else {
24 | break;
25 | }
26 | }
27 |
28 | // 提取文档中的字段级注释
29 | const documentComments: { [key: string]: string[] } = {};
30 | let currentField: string | null = null;
31 | let commentBuffer: string[] = [];
32 |
33 | for (let i = contentStartIndex; i < lines.length; i++) {
34 | const line = lines[i].trim();
35 |
36 | if (line.startsWith('#')) {
37 | // 这是一行注释
38 | commentBuffer.push(lines[i]);
39 | } else if (line === '') {
40 | // 空行,跳过
41 | continue;
42 | } else {
43 | // 这是一个字段行
44 | const fieldMatch = line.match(/^(\s*)([a-zA-Z0-9_-]+):/);
45 | if (fieldMatch) {
46 | const [, indent, fieldName] = fieldMatch;
47 |
48 | // 如果有待处理的注释,将其与当前字段关联
49 | if (commentBuffer.length > 0) {
50 | documentComments[fieldName] = [...commentBuffer];
51 | commentBuffer = [];
52 | }
53 |
54 | currentField = fieldName;
55 | } else {
56 | // 不是字段开始,可能是复杂结构的一部分
57 | // 清空注释缓冲区,因为我们不知道应该将其关联到哪个字段
58 | commentBuffer = [];
59 | }
60 | }
61 | }
62 |
63 | return {
64 | headerComments: headerLines.join('\n'),
65 | documentComments,
66 | contentStartIndex
67 | };
68 | }
69 |
70 | /**
71 | * 在YAML字符串中保留注释并更新值
72 | * @param originalYaml 原始YAML字符串
73 | * @param updatedValues 更新后的值对象
74 | * @returns 更新后但保留注释的YAML字符串
75 | */
76 | export function preserveCommentsInYaml(originalYaml: string, updatedValues: any): string {
77 | // 提取注释
78 | const { headerComments, documentComments, contentStartIndex } = extractYamlComments(originalYaml);
79 |
80 | // 生成更新后的YAML内容
81 | const updatedContent = YAML.stringify(updatedValues, {
82 | indent: 2,
83 | lineWidth: -1
84 | });
85 |
86 | // 将顶部注释添加回去
87 | let result = headerComments;
88 | if (headerComments && !headerComments.endsWith('\n')) {
89 | result += '\n';
90 | }
91 |
92 | // 将更新后的内容添加回去
93 | result += updatedContent;
94 |
95 | return result;
96 | }
97 |
98 | /**
99 | * 格式化YAML值,处理多行文本和特殊字符
100 | * @param value 要格式化的值
101 | * @returns 格式化后的字符串
102 | */
103 | export function formatYamlValue(value: any): string {
104 | if (value === null || value === undefined) {
105 | return '';
106 | }
107 |
108 | // 如果是字符串,需要处理多行文本和特殊字符
109 | if (typeof value === 'string') {
110 | // 检查是否包含换行符
111 | if (value.includes('\n')) {
112 | // 多行文本使用块样式(|)
113 | return `|\n ${value.split('\n').join('\n ')}`;
114 | }
115 |
116 | // 检查是否包含特殊字符,如引号、冒号等
117 | if (/[:"'{}\[\]#&*!|<>=%@`]/.test(value) || /^\s|\s$/.test(value)) {
118 | // 使用引号包裹包含特殊字符的字符串
119 | return `"${value.replace(/"/g, '\\"')}"`;
120 | }
121 | } else if (Array.isArray(value)) {
122 | // 数组格式化
123 | return value.map(item => formatYamlValue(item)).join(', ');
124 | } else if (typeof value === 'object') {
125 | // 对象格式化为YAML子块
126 | const formatted = Object.entries(value)
127 | .map(([k, v]) => `${k}: ${formatYamlValue(v)}`)
128 | .join('\n ');
129 | return `{\n ${formatted}\n}`;
130 | }
131 |
132 | // 其他类型直接转字符串
133 | return String(value);
134 | }
--------------------------------------------------------------------------------
/src/components/ChallengeContributePage/utils/yamlUtils.ts:
--------------------------------------------------------------------------------
1 | import * as YAML from 'yaml';
2 | import { ChallengeFormData, ChallengeData, Solution } from '../types';
3 |
4 | /**
5 | * YAML 处理工具统一导出模块
6 | */
7 |
8 | // 从各个模块导出函数
9 | export * from './yamlCommentProcessor';
10 | export * from './yamlFormatter';
11 | export * from './yamlChallengeUpdater';
12 |
13 | /**
14 | * 将 YAML 数据转换为表单数据
15 | * @param yamlData YAML 数据对象
16 | * @returns 表单数据对象
17 | */
18 | export function yamlToFormData(yamlData: any): Partial {
19 | if (!yamlData) return {};
20 |
21 | const result: Record = {};
22 |
23 | // 映射特定字段
24 | const fieldsMap: Record = {
25 | 'id': 'id',
26 | 'id-alias': 'idAlias',
27 | 'platform': 'platform',
28 | 'name': 'name',
29 | 'name_en': 'nameEn',
30 | 'difficulty-level': 'difficultyLevel',
31 | 'description-markdown': 'description',
32 | 'description-markdown_en': 'descriptionEn',
33 | 'base64-url': 'base64Url',
34 | 'is-expired': 'isExpired',
35 | 'tags': 'tags',
36 | 'solutions': 'solutions',
37 | 'create-time': 'createTime',
38 | 'update-time': 'updateTime'
39 | };
40 |
41 | // 转换基础字段
42 | Object.entries(fieldsMap).forEach(([yamlKey, formKey]) => {
43 | if (yamlData[yamlKey] !== undefined) {
44 | result[formKey] = yamlData[yamlKey];
45 | }
46 | });
47 |
48 | // 保存原始 YAML 数据,用于保留注释
49 | result.rawYaml = yamlData;
50 |
51 | return result;
52 | }
53 |
54 | /**
55 | * 将表单数据转换为 YAML 数据
56 | * @param formData 表单数据对象
57 | * @returns YAML 数据对象
58 | */
59 | export function formDataToYaml(formData: Partial): any {
60 | if (!formData) return {};
61 |
62 | const result: Record = {};
63 |
64 | // 映射特定字段
65 | const fieldsMap: Record = {
66 | 'id': 'id',
67 | 'idAlias': 'id-alias',
68 | 'platform': 'platform',
69 | 'name': 'name',
70 | 'nameEn': 'name_en',
71 | 'difficultyLevel': 'difficulty-level',
72 | 'description': 'description-markdown',
73 | 'descriptionEn': 'description-markdown_en',
74 | 'base64Url': 'base64-url',
75 | 'isExpired': 'is-expired',
76 | 'tags': 'tags',
77 | 'solutions': 'solutions',
78 | 'createTime': 'create-time',
79 | 'updateTime': 'update-time'
80 | };
81 |
82 | // 转换基础字段
83 | Object.entries(fieldsMap).forEach(([formKey, yamlKey]) => {
84 | if (formData[formKey as keyof ChallengeFormData] !== undefined) {
85 | result[yamlKey] = formData[formKey as keyof ChallengeFormData];
86 | }
87 | });
88 |
89 | return result;
90 | }
91 |
92 | /**
93 | * 将表单数据转换为YAML格式字符串
94 | * @param formData 表单数据
95 | * @returns YAML格式的字符串
96 | */
97 | export function generateYamlFromFormData(formData: ChallengeFormData): string {
98 | // 先将表单数据转换为YAML对象格式
99 | const yamlData = formDataToYaml(formData);
100 |
101 | // 生成YAML字符串,保持适当的缩进和格式
102 | return YAML.stringify(yamlData, {
103 | indent: 2,
104 | lineWidth: -1
105 | });
106 | }
107 |
108 | /**
109 | * 解析YAML字符串为表单数据
110 | * @param yamlString YAML格式的字符串
111 | * @returns 表单数据对象
112 | */
113 | export function parseYamlString(yamlString: string): Partial | null {
114 | if (!yamlString || typeof yamlString !== 'string') {
115 | return null;
116 | }
117 |
118 | try {
119 | // 解析YAML字符串为对象
120 | const yamlData = YAML.parse(yamlString);
121 |
122 | // 保存原始YAML字符串
123 | yamlData.originalYaml = yamlString;
124 |
125 | // 转换为表单数据
126 | return yamlToFormData(yamlData);
127 | } catch (error) {
128 | console.error('解析YAML字符串失败:', error);
129 | return null;
130 | }
131 | }
--------------------------------------------------------------------------------
/src/components/ChallengeDetailPage/ChallengeActions.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Space } from 'antd';
2 | import { HomeOutlined, RightOutlined } from '@ant-design/icons';
3 | import { useNavigate, Link } from 'react-router-dom';
4 | import { useTranslation } from 'react-i18next';
5 | import { Challenge } from '../../types/challenge';
6 |
7 | interface ChallengeActionsProps {
8 | challenge: Challenge;
9 | /**
10 | * 是否为移动端视图
11 | */
12 | isMobile?: boolean;
13 | }
14 |
15 | /**
16 | * 挑战详情页面底部操作按钮组件
17 | */
18 | const ChallengeActions: React.FC = ({ challenge, isMobile = false }) => {
19 | const navigate = useNavigate();
20 | const { t } = useTranslation();
21 |
22 | // 移动端布局
23 | if (isMobile) {
24 | return (
25 |
33 | {challenge.externalLink && (
34 |
46 | )}
47 |
48 | }
51 | onClick={() => navigate('/challenges')}
52 | size="middle"
53 | style={{
54 | width: '100%',
55 | maxWidth: '400px',
56 | alignSelf: 'center'
57 | }}
58 | >
59 | {t('challenge.actions.backToList')}
60 |
61 |
62 | );
63 | }
64 |
65 | // PC端布局
66 | return (
67 |
68 |
88 |
89 | {t('challenge.actions.backToList')}
90 |
91 |
92 | );
93 | };
94 |
95 | export default ChallengeActions;
--------------------------------------------------------------------------------
/src/components/ChallengeDetailPage/ChallengeExpiredAlert.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Alert } from 'antd';
3 | import { useTranslation } from 'react-i18next';
4 |
5 | interface ChallengeExpiredAlertProps {
6 | /**
7 | * 是否显示失效警告
8 | */
9 | isExpired?: boolean;
10 | }
11 |
12 | /**
13 | * 挑战题目链接失效警告组件
14 | * 当题目链接已失效时显示警告横幅
15 | */
16 | const ChallengeExpiredAlert: React.FC = ({ isExpired = false }) => {
17 | const { t } = useTranslation();
18 |
19 | if (!isExpired) {
20 | return null;
21 | }
22 |
23 | return (
24 |
32 | );
33 | };
34 |
35 | export default ChallengeExpiredAlert;
--------------------------------------------------------------------------------
/src/components/ChallengeDetailPage/ChallengePagination.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Button, Tooltip } from 'antd';
3 | import { LeftOutlined, RightOutlined } from '@ant-design/icons';
4 | import { useTranslation } from 'react-i18next';
5 | import { Challenge } from '../../types/challenge';
6 |
7 | interface ChallengePaginationProps {
8 | /**
9 | * 前一个挑战
10 | */
11 | prevChallenge: Challenge | null;
12 |
13 | /**
14 | * 下一个挑战
15 | */
16 | nextChallenge: Challenge | null;
17 |
18 | /**
19 | * 点击前一个挑战的回调
20 | */
21 | onPrevClick: () => void;
22 |
23 | /**
24 | * 点击下一个挑战的回调
25 | */
26 | onNextClick: () => void;
27 |
28 | /**
29 | * 是否为移动端视图
30 | */
31 | isMobile?: boolean;
32 | }
33 |
34 | /**
35 | * 挑战详情页翻页组件
36 | * 提供上一题/下一题导航,并显示键盘快捷键提示
37 | */
38 | const ChallengePagination: React.FC = ({
39 | prevChallenge,
40 | nextChallenge,
41 | onPrevClick,
42 | onNextClick,
43 | isMobile = false
44 | }) => {
45 | const { t } = useTranslation();
46 |
47 | return (
48 |
55 |
56 | }
59 | onClick={onPrevClick}
60 | disabled={!prevChallenge}
61 | size={isMobile ? "middle" : "default"}
62 | style={{
63 | minWidth: isMobile ? '100px' : '140px',
64 | flex: isMobile ? 1 : 'initial'
65 | }}
66 | >
67 | {isMobile ? '' : `${t('challenge.pagination.previous')} `}(←)
68 |
69 |
70 |
71 |
72 | }
75 | onClick={onNextClick}
76 | disabled={!nextChallenge}
77 | size={isMobile ? "middle" : "default"}
78 | style={{
79 | minWidth: isMobile ? '100px' : '140px',
80 | flex: isMobile ? 1 : 'initial'
81 | }}
82 | >
83 | {isMobile ? '' : `${t('challenge.pagination.next')} `}(→)
84 |
85 |
86 |
87 | );
88 | };
89 |
90 | export default ChallengePagination;
--------------------------------------------------------------------------------
/src/components/ChallengeDetailPage/ChallengeTags.tsx:
--------------------------------------------------------------------------------
1 | import { Tag, Typography, Space } from 'antd';
2 | import { TagOutlined } from '@ant-design/icons';
3 | import { Challenge } from '../../types/challenge';
4 | import { useTranslation } from 'react-i18next';
5 | import { useNavigate } from 'react-router-dom';
6 | import TopicTag from '../TopicTag';
7 |
8 | const { Title, Text } = Typography;
9 |
10 | interface ChallengeTagsProps {
11 | challenge: Challenge;
12 | /**
13 | * 是否为移动端视图
14 | */
15 | isMobile?: boolean;
16 | }
17 |
18 | /**
19 | * 挑战的标签组件
20 | */
21 | const ChallengeTags: React.FC = ({ challenge, isMobile = false }) => {
22 | const { t } = useTranslation();
23 | const navigate = useNavigate();
24 |
25 | // 如果没有标签,不显示
26 | if (!challenge.tags || challenge.tags.length === 0) {
27 | return null;
28 | }
29 |
30 | // 处理标签点击,跳转到列表页并应用过滤
31 | const handleTagClick = (tag: string) => {
32 | navigate(`/challenges?tags=${encodeURIComponent(tag)}`);
33 | };
34 |
35 | // 移动端布局
36 | if (isMobile) {
37 | return (
38 |
39 |
46 |
47 | {t('challenge.detail.tags')}
48 |
49 |
50 |
51 |
59 | {challenge.tags.map((tag, index) => (
60 |
68 | {tag}
69 |
70 | ))}
71 |
72 |
73 |
74 | );
75 | }
76 |
77 | // PC端布局
78 | return (
79 |
80 |
{t('challenge.detail.tags')}:
81 |
82 | {challenge.tags.map((tag, index) => (
83 | handleTagClick(tag)}
88 | style={{ marginRight: '8px' }}
89 | />
90 | ))}
91 |
92 |
93 | );
94 | };
95 |
96 | export default ChallengeTags;
--------------------------------------------------------------------------------
/src/components/ChallengeDetailPage/utils.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 根据难度等级返回对应的CSS类名
3 | */
4 | export const getDifficultyBadgeClass = (difficulty: number): string => {
5 | switch (difficulty) {
6 | case 1:
7 | return 'bg-green-100 text-green-800';
8 | case 2:
9 | case 3:
10 | return 'bg-yellow-100 text-yellow-800';
11 | case 4:
12 | case 5:
13 | return 'bg-red-100 text-red-800';
14 | default:
15 | return 'bg-gray-100 text-gray-800';
16 | }
17 | };
18 |
19 | /**
20 | * 根据难度等级返回中文描述
21 | */
22 | export const getDifficultyText = (difficulty: number): string => {
23 | switch (difficulty) {
24 | case 1:
25 | return '简单';
26 | case 2:
27 | case 3:
28 | return '中等';
29 | case 4:
30 | case 5:
31 | return '困难';
32 | default:
33 | return '未知';
34 | }
35 | };
36 |
37 | /**
38 | * 格式化日期时间为本地字符串
39 | */
40 | export const formatDateTime = (date: Date | string | number | undefined): string => {
41 | if (!date) return '未知';
42 |
43 | try {
44 | const dateObj = typeof date === 'object' ? date : new Date(date);
45 | return dateObj.toLocaleString();
46 | } catch (e) {
47 | return String(date);
48 | }
49 | };
--------------------------------------------------------------------------------
/src/components/ChallengeFilters.tsx:
--------------------------------------------------------------------------------
1 | interface ChallengeFiltersProps {
2 | /**
3 | * 选中的标签
4 | */
5 | selectedTags: string[];
6 |
7 | /**
8 | * 选中的难度
9 | */
10 | selectedDifficulty: string[];
11 |
12 | /**
13 | * 选中的平台
14 | */
15 | selectedPlatform: string;
16 |
17 | /**
18 | * 是否有过滤器被应用
19 | */
20 | hasFilters: boolean;
21 |
22 | /**
23 | * 删除单个标签的回调
24 | */
25 | onRemoveTag: (tag: string) => void;
26 |
27 | /**
28 | * 移除难度过滤的回调
29 | */
30 | onRemoveDifficulty: () => void;
31 |
32 | /**
33 | * 移除平台过滤的回调
34 | */
35 | onRemovePlatform: () => void;
36 |
37 | /**
38 | * 清除所有过滤器的回调
39 | */
40 | onClearAll: () => void;
41 |
42 | /**
43 | * 搜索提交回调
44 | */
45 | onSearch: (value: string) => void;
46 |
47 | /**
48 | * 当前的搜索值
49 | */
50 | searchValue?: string;
51 | }
--------------------------------------------------------------------------------
/src/components/ChallengeListPage/ChallengeData.ts:
--------------------------------------------------------------------------------
1 | // 导入虚拟文件系统生成的数据
2 | // @ts-ignore - 虚拟文件在构建时生成
3 | import rawChallenges from '/virtual-challenges.js';
4 | // 导入类型和解析函数
5 | import { Challenge, parseChallenges } from '../../types/challenge';
6 |
7 | // 使用虚拟文件系统数据
8 | export const challenges: Challenge[] = parseChallenges(Array.isArray(rawChallenges) ? rawChallenges : []);
9 |
10 | export type Solution = {
11 | title: string;
12 | url: string;
13 | source: string;
14 | };
--------------------------------------------------------------------------------
/src/components/ChallengeListPage/SimpleChallengeList.tsx:
--------------------------------------------------------------------------------
1 | import { FC, useEffect } from 'react';
2 | import { List, Pagination } from 'antd';
3 | import { Challenge } from '../../types/challenge';
4 | import ChallengeListItem from './ChallengeListItem';
5 |
6 | const PAGE_SIZE_KEY = 'challenge-list-page-size';
7 |
8 | interface SimpleChallengeListProps {
9 | challenges: Challenge[];
10 | selectedTags: string[];
11 | pagination: {
12 | current: number;
13 | pageSize: number;
14 | };
15 | total: number;
16 | onPaginationChange: (page: number, pageSize: number) => void;
17 | onTagClick: (tag: string) => void;
18 | onDifficultyClick: (difficulty: string) => void;
19 | onPlatformClick: (platform: string) => void;
20 | onChallengeClick: (id: string) => void;
21 | hidePagination?: boolean;
22 | }
23 |
24 | const SimpleChallengeList: FC = ({
25 | challenges,
26 | selectedTags,
27 | pagination,
28 | total,
29 | onPaginationChange,
30 | onTagClick,
31 | onDifficultyClick,
32 | onPlatformClick,
33 | onChallengeClick,
34 | hidePagination = false
35 | }) => {
36 | // 从本地存储加载上次使用的分页大小
37 | useEffect(() => {
38 | const savedPageSize = localStorage.getItem(PAGE_SIZE_KEY);
39 | if (savedPageSize && pagination && parseInt(savedPageSize) !== pagination.pageSize) {
40 | onPaginationChange(1, parseInt(savedPageSize));
41 | }
42 | }, [pagination, onPaginationChange]);
43 |
44 | // 处理分页变化
45 | const handlePaginationChange = (page: number, pageSize: number) => {
46 | // 保存分页大小到本地存储
47 | localStorage.setItem(PAGE_SIZE_KEY, pageSize.toString());
48 | onPaginationChange(page, pageSize);
49 | };
50 |
51 | // 确保pagination存在且有效
52 | if (!pagination) {
53 | return null;
54 | }
55 |
56 | return (
57 | <>
58 | {/* 挑战列表 */}
59 | (
63 |
64 | onChallengeClick(challenge.idAlias || challenge.id.toString())}
68 | onTagClick={onTagClick}
69 | onDifficultyClick={(difficulty) => onDifficultyClick(String(difficulty))}
70 | onPlatformClick={(platform) => onPlatformClick(platform)}
71 | />
72 |
73 | )}
74 | />
75 |
76 | {/* 分页 - 仅在hidePagination为false时显示 */}
77 | {!hidePagination && (
78 |
86 | )}
87 | >
88 | );
89 | };
90 |
91 | export default SimpleChallengeList;
--------------------------------------------------------------------------------
/src/components/ChallengeListPage/exports.ts:
--------------------------------------------------------------------------------
1 | // 导出挑战数据和SimpleChallenge列表
2 | export { challenges, type Solution } from './ChallengeData';
3 | export { default as SimpleChallengeList } from './SimpleChallengeList';
--------------------------------------------------------------------------------
/src/components/FileViewer.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { List, Button, Card } from 'antd';
3 | import { getFileContent, listDirectory } from '../utils/fileSystem';
4 |
5 | class FileViewer extends React.Component {
6 | state = {
7 | currentPath: '',
8 | files: [],
9 | fileContent: '',
10 | };
11 |
12 | componentDidMount() {
13 | this.loadDirectory('');
14 | }
15 |
16 | loadDirectory = (path) => {
17 | this.setState({
18 | currentPath: path,
19 | files: listDirectory(path),
20 | fileContent: '',
21 | });
22 | };
23 |
24 | handleFileClick = (name) => {
25 | const { currentPath } = this.state;
26 | const fullPath = currentPath ? `${currentPath}/${name}` : name;
27 | const content = getFileContent(fullPath);
28 |
29 | content ? this.setState({ fileContent: content }) : this.loadDirectory(fullPath);
30 | };
31 |
32 | handleBack = () => {
33 | const { currentPath } = this.state;
34 | const newPath = currentPath.split('/').slice(0, -1).join('/');
35 | this.loadDirectory(newPath);
36 | };
37 |
38 | render() {
39 | const { currentPath, files, fileContent } = this.state;
40 |
41 | return (
42 |
45 | {currentPath && (
46 |
53 | )}
54 | {currentPath || '根目录'}
55 |
56 | }
57 | >
58 | (
62 | this.handleFileClick(item)}
67 | key="open"
68 | >
69 | 打开
70 |
71 | ]}
72 | >
73 |
74 |
75 | )}
76 | />
77 | {fileContent && (
78 |
83 |
90 | {fileContent}
91 |
92 |
93 | )}
94 |
95 | );
96 | }
97 | }
98 |
99 | export default FileViewer;
--------------------------------------------------------------------------------
/src/components/GitHubRibbon.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { useMediaQuery } from 'react-responsive';
3 |
4 | interface GitHubRibbonProps {
5 | /**
6 | * GitHub仓库的URL地址
7 | */
8 | repositoryUrl: string;
9 |
10 | /**
11 | * 显示在徽标上的文本
12 | */
13 | text?: string;
14 |
15 | /**
16 | * 徽标的位置,默认为右上角
17 | */
18 | position?: 'right' | 'right-bottom' | 'left-top' | 'left-bottom';
19 | }
20 |
21 | /**
22 | * GitHub Ribbon组件
23 | * 显示"Fork me on GitHub"的角标
24 | * 在移动端下完全隐藏
25 | */
26 | const GitHubRibbon: React.FC = ({
27 | repositoryUrl,
28 | text = 'Fork me on GitHub',
29 | position = 'right'
30 | }) => {
31 | const isMobile = useMediaQuery({ maxWidth: 768 });
32 |
33 | // 如果是移动设备,不显示GitHub角标
34 | if (isMobile) {
35 | return null;
36 | }
37 |
38 | // 根据position确定位置样式
39 | const getPositionStyle = () => {
40 | switch(position) {
41 | case 'right':
42 | return { top: 0, right: 0 };
43 | case 'right-bottom':
44 | return { bottom: 0, right: 0 };
45 | case 'left-top':
46 | return { top: 0, left: 0 };
47 | case 'left-bottom':
48 | return { bottom: 0, left: 0 };
49 | default:
50 | return { top: 0, right: 0 };
51 | }
52 | };
53 |
54 | return (
55 |
92 | );
93 | };
94 |
95 | export default GitHubRibbon;
--------------------------------------------------------------------------------
/src/components/HomePage/FeatureSection.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Card, Col, Row, Typography } from 'antd';
3 | import {
4 | CodeOutlined,
5 | FilterOutlined,
6 | BugOutlined
7 | } from '@ant-design/icons';
8 | import { useTranslation } from 'react-i18next';
9 | import {
10 | featureSectionStyle,
11 | sectionTitleStyle,
12 | sectionTitleDividerStyle,
13 | featureCardStyle,
14 | featureCardContentStyle,
15 | featureSectionMobileStyle,
16 | sectionTitleMobileStyle
17 | } from './styles';
18 | import { useMediaQuery } from 'react-responsive';
19 |
20 | const { Title, Text } = Typography;
21 |
22 | /**
23 | * 功能介绍区域组件
24 | */
25 | const FeatureSection: React.FC = () => {
26 | const { t } = useTranslation();
27 | const isMobile = useMediaQuery({ maxWidth: 768 });
28 |
29 | // 从i18n获取功能特性数据
30 | const features = [
31 | {
32 | title: t('home.features.items.0.title'),
33 | content: t('home.features.items.0.content'),
34 | color: '#42b983',
35 | icon:
36 | },
37 | {
38 | title: t('home.features.items.1.title'),
39 | content: t('home.features.items.1.content'),
40 | color: '#1890ff',
41 | icon:
42 | },
43 | {
44 | title: t('home.features.items.2.title'),
45 | content: t('home.features.items.2.content'),
46 | color: '#722ed1',
47 | icon:
48 | }
49 | ];
50 |
51 | return (
52 |
53 |
58 | {t('home.features.title')}
59 |
60 |
61 |
62 |
63 | {features.map((feature, index) => (
64 |
65 |
69 |
70 |
parseInt(c, 16)).join(',')}, 0.1)`,
75 | width: isMobile ? '70px' : '80px',
76 | height: isMobile ? '70px' : '80px',
77 | display: 'flex',
78 | alignItems: 'center',
79 | justifyContent: 'center',
80 | borderRadius: isMobile ? '35px' : '40px'
81 | }}>
82 | {feature.icon}
83 |
84 |
85 | {feature.title}
86 |
87 |
88 | {feature.content}
89 |
90 |
91 |
92 |
93 | ))}
94 |
95 |
96 | );
97 | };
98 |
99 | export default FeatureSection;
--------------------------------------------------------------------------------
/src/components/HomePage/index.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { challenges } from '../ChallengeListPage/exports';
3 | import { useNavigate } from 'react-router-dom';
4 | import HeroSection from './HeroSection';
5 | import FeatureSection from './FeatureSection';
6 | import ChallengeSection from './ChallengeSection';
7 | import { pageContainerStyle, animationStyles } from './styles';
8 | import { useMediaQuery } from 'react-responsive';
9 |
10 | // 获取不同难度的挑战数量
11 | const getDifficultyCounts = (challenges: Challenge[]) => {
12 | let beginner = 0;
13 | let intermediate = 0;
14 | let advanced = 0;
15 |
16 | challenges.forEach(challenge => {
17 | if (challenge.difficulty === 1) {
18 | beginner++;
19 | } else if (challenge.difficulty >= 2 && challenge.difficulty <= 3) {
20 | intermediate++;
21 | } else if (challenge.difficulty >= 4) {
22 | advanced++;
23 | }
24 | });
25 |
26 | return { beginner, intermediate, advanced };
27 | };
28 |
29 | /**
30 | * 首页组件
31 | * 展示网站主要功能和推荐挑战列表
32 | */
33 | const HomePage = () => {
34 | const [animatedStats, setAnimatedStats] = useState(false);
35 | const navigate = useNavigate();
36 | const difficultyCounts = getDifficultyCounts(challenges);
37 | const isMobile = useMediaQuery({ maxWidth: 768 });
38 |
39 | // 获取最新的3个挑战
40 | const recentChallenges = [...challenges]
41 | .sort((a, b) => b.createTime.getTime() - a.createTime.getTime())
42 | .slice(0, 3);
43 |
44 | // 随机获取3个热门挑战
45 | const popularChallenges = [...challenges]
46 | .sort(() => 0.5 - Math.random())
47 | .slice(0, 3);
48 |
49 | // 首页列表分页设置
50 | const [homePagination] = useState({ current: 1, pageSize: 3 });
51 |
52 | // 组件挂载后触发数据统计动画
53 | useEffect(() => {
54 | const timer = setTimeout(() => {
55 | setAnimatedStats(true);
56 | }, 300);
57 |
58 | return () => clearTimeout(timer);
59 | }, []);
60 |
61 | // 处理分页变化 (这里实际不会使用,但需要传递以满足类型要求)
62 | const handlePaginationChange = () => {};
63 |
64 | // 处理标签点击
65 | const handleTagClick = (tag: string) => {
66 | navigate(`/challenges?tags=${tag}`);
67 | };
68 |
69 | // 处理难度点击
70 | const handleDifficultyClick = (difficulty: string) => {
71 | navigate(`/challenges?difficulty=${difficulty}`);
72 | };
73 |
74 | // 处理平台点击
75 | const handlePlatformClick = (platform: string) => {
76 | navigate(`/challenges?platform=${platform}`);
77 | };
78 |
79 | return (
80 |
81 | {/* 英雄区域 */}
82 |
87 |
88 | {/* 功能介绍 */}
89 |
90 |
91 | {/* 挑战列表区域 */}
92 |
101 |
102 | {/* 添加CSS动画 */}
103 |
104 |
105 | );
106 | };
107 |
108 | export default HomePage;
--------------------------------------------------------------------------------
/src/components/IdTag.tsx:
--------------------------------------------------------------------------------
1 | import { Tag, Tooltip } from 'antd';
2 | import * as React from 'react';
3 |
4 | interface IdTagProps {
5 | /**
6 | * 挑战ID
7 | */
8 | id: React.ReactNode;
9 |
10 | /**
11 | * 是否可点击
12 | */
13 | clickable?: boolean;
14 |
15 | /**
16 | * 点击事件处理函数
17 | */
18 | onClick?: () => void;
19 |
20 | /**
21 | * 提示文本
22 | */
23 | tooltip?: string;
24 |
25 | /**
26 | * 标签样式
27 | */
28 | style?: React.CSSProperties;
29 |
30 | /**
31 | * 是否阻止点击事件冒泡
32 | * 在列表项内部使用时通常需要设置为true
33 | */
34 | stopPropagation?: boolean;
35 | }
36 |
37 | /**
38 | * ID标签组件
39 | * 用于显示挑战ID
40 | */
41 | const IdTag: React.FC = ({
42 | id,
43 | clickable = false,
44 | onClick,
45 | tooltip = "点击返回挑战列表",
46 | style,
47 | stopPropagation = false
48 | }) => {
49 | // 确保id有值
50 | const displayId = id ?? '?';
51 | const cursor = clickable ? 'pointer' : undefined;
52 |
53 | const handleClick = (e: React.MouseEvent) => {
54 | // 根据需要阻止事件冒泡
55 | if (stopPropagation) {
56 | e.stopPropagation();
57 | }
58 |
59 | // 如果提供了onClick回调,则调用
60 | if (onClick) {
61 | onClick();
62 | }
63 | };
64 |
65 | const tag = (
66 |
71 | #{displayId}
72 |
73 | );
74 |
75 | return tooltip && clickable ? (
76 |
77 | {tag}
78 |
79 | ) : tag;
80 | };
81 |
82 | export default IdTag;
--------------------------------------------------------------------------------
/src/components/PageTitle.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import { useLocation } from 'react-router-dom';
3 | import { useTranslation } from 'react-i18next';
4 |
5 | /**
6 | * 页面标题组件
7 | * 根据当前路由自动设置文档标题
8 | */
9 | const PageTitle = () => {
10 | const location = useLocation();
11 | const { t } = useTranslation();
12 |
13 | useEffect(() => {
14 | let title = t('titles.default');
15 |
16 | // 根据路径设置不同的标题
17 | if (location.pathname === '/') {
18 | title = t('titles.home');
19 | } else if (location.pathname === '/challenges') {
20 | title = t('titles.challenges');
21 | } else if (location.pathname === '/about') {
22 | title = t('titles.about');
23 | } else if (location.pathname === '/challenge/contribute') {
24 | title = t('titles.contribute');
25 | } else if (location.pathname.startsWith('/challenge/')) {
26 | title = t('titles.challenge');
27 | }
28 |
29 | // 设置文档标题
30 | document.title = title;
31 | }, [location.pathname, t]);
32 |
33 | // 此组件不渲染任何内容
34 | return null;
35 | };
36 |
37 | export default PageTitle;
--------------------------------------------------------------------------------
/src/components/PlatformTag.tsx:
--------------------------------------------------------------------------------
1 | import { Tag, Tooltip } from 'antd';
2 | import * as React from 'react';
3 | import { useTranslation } from 'react-i18next';
4 |
5 | interface PlatformTagProps {
6 | /**
7 | * 平台名称
8 | */
9 | platform: string;
10 |
11 | /**
12 | * 是否可点击
13 | */
14 | clickable?: boolean;
15 |
16 | /**
17 | * 点击事件处理函数
18 | * @param platform 平台名称
19 | */
20 | onClick?: (platform: string) => void;
21 |
22 | /**
23 | * 提示文本
24 | */
25 | tooltip?: string;
26 |
27 | /**
28 | * 标签样式
29 | */
30 | style?: React.CSSProperties;
31 |
32 | /**
33 | * 是否阻止点击事件冒泡
34 | * 在列表项内部使用时通常需要设置为true
35 | */
36 | stopPropagation?: boolean;
37 | }
38 |
39 | /**
40 | * 平台标签组件
41 | * 用于显示挑战所属平台
42 | */
43 | const PlatformTag: React.FC = ({
44 | platform,
45 | clickable = false,
46 | onClick,
47 | tooltip,
48 | style,
49 | stopPropagation = false
50 | }) => {
51 | const { t } = useTranslation();
52 |
53 | // 使用传入的tooltip或默认的i18n文本
54 | const tooltipText = tooltip || t('tagTooltips.filterByPlatform');
55 |
56 | const getColor = () => {
57 | switch (platform) {
58 | case 'Web':
59 | return 'blue';
60 | case 'Android':
61 | return 'green';
62 | case 'iOS':
63 | return 'volcano';
64 | case 'WeChat-MiniProgram':
65 | return 'green';
66 | case 'Electron':
67 | return 'cyan';
68 | case 'Windows-Native':
69 | return 'blue';
70 | case 'Mac-Native':
71 | return 'purple';
72 | case 'Linux-Native':
73 | return 'orange';
74 | case 'LeetCode':
75 | return 'orange';
76 | default:
77 | return 'purple';
78 | }
79 | };
80 |
81 | const handleClick = (e: React.MouseEvent) => {
82 | // 根据需要阻止事件冒泡
83 | if (stopPropagation) {
84 | e.stopPropagation();
85 | }
86 |
87 | // 如果有平台名称且提供了onClick回调,则调用
88 | if (platform && onClick) {
89 | onClick(platform);
90 | }
91 | };
92 |
93 | const cursor = clickable ? 'pointer' : undefined;
94 | const displayText = platform || '未指定';
95 |
96 | const tag = (
97 |
102 | {displayText}
103 |
104 | );
105 |
106 | return tooltipText && clickable ? (
107 |
108 | {tag}
109 |
110 | ) : tag;
111 | };
112 |
113 | export default PlatformTag;
--------------------------------------------------------------------------------
/src/components/StarRating.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * 星级评分组件
3 | * 用于显示难度等级或评分,支持1-5星的展示和交互
4 | */
5 | import { Tag, Tooltip } from 'antd';
6 | import { StarFilled, StarOutlined } from '@ant-design/icons';
7 | import '../styles/star-rating.css'; // 正确的路径
8 | import { useTranslation } from 'react-i18next';
9 |
10 | /**
11 | * 星级评分组件的属性接口
12 | *
13 | * @interface StarRatingProps
14 | * @property {number} difficulty - 难度等级/评分值(1-5)
15 | * @property {Function} [onClick] - 可选的点击事件处理函数,点击时会传入当前难度值
16 | * @property {string} [tooltip] - 提示文本
17 | * @property {boolean} [stopPropagation] - 是否阻止点击事件冒泡
18 | * @property {React.CSSProperties} [style] - 组件样式
19 | */
20 | interface StarRatingProps {
21 | difficulty: number;
22 | onClick?: (difficulty: number) => void;
23 | tooltip?: string;
24 | stopPropagation?: boolean;
25 | style?: React.CSSProperties;
26 | }
27 |
28 | /**
29 | * 星级评分组件
30 | *
31 | * 根据传入的难度等级显示对应数量的星星,支持点击交互
32 | * 实心星星表示已选,空心星星表示未选
33 | *
34 | * @param {StarRatingProps} props - 组件属性
35 | * @returns {JSX.Element} 星级评分组件
36 | *
37 | * @example
38 | * // 基本用法 - 只显示3星难度
39 | *
40 | *
41 | * @example
42 | * // 带点击事件 - 用于评分选择
43 | * console.log('选择了难度:', newDifficulty)}
46 | * />
47 | */
48 | const StarRating = ({
49 | difficulty,
50 | onClick,
51 | tooltip,
52 | stopPropagation = false,
53 | style
54 | }: StarRatingProps) => {
55 | const { t } = useTranslation();
56 | const isClickable = !!onClick;
57 |
58 | // 使用传入的tooltip或默认的i18n文本
59 | const tooltipText = tooltip || t('tagTooltips.filterByDifficulty');
60 |
61 | const handleClick = (e: React.MouseEvent) => {
62 | // 根据需要阻止事件冒泡
63 | if (stopPropagation) {
64 | e.stopPropagation();
65 | }
66 |
67 | // 调用外部传入的onClick回调
68 | if (onClick) {
69 | onClick(difficulty);
70 | }
71 | };
72 |
73 | const starTag = (
74 |
91 | {/* 生成5个星星,根据difficulty值决定显示实心还是空心 */}
92 | {[...Array(5)].map((_, index) => {
93 | const StarComponent = index < difficulty ? StarFilled : StarOutlined; // 根据索引和难度选择星星类型
94 | return (
95 |
105 | );
106 | })}
107 |
108 | );
109 |
110 | return tooltipText && isClickable ? (
111 |
112 | {starTag}
113 |
114 | ) : starTag;
115 | };
116 |
117 | export default StarRating;
--------------------------------------------------------------------------------
/src/components/TopicTag.tsx:
--------------------------------------------------------------------------------
1 | import { Tag, Tooltip } from 'antd';
2 | import * as React from 'react';
3 | import { useTranslation } from 'react-i18next';
4 |
5 | interface TopicTagProps {
6 | /**
7 | * 标签文本
8 | */
9 | text: string;
10 |
11 | /**
12 | * 是否可点击
13 | */
14 | clickable?: boolean;
15 |
16 | /**
17 | * 点击事件处理函数
18 | * @param tag 标签文本
19 | */
20 | onClick?: (tag: string) => void;
21 |
22 | /**
23 | * 提示文本
24 | */
25 | tooltip?: string;
26 |
27 | /**
28 | * 是否被选中
29 | */
30 | selected?: boolean;
31 |
32 | /**
33 | * 标签样式
34 | */
35 | style?: React.CSSProperties;
36 |
37 | /**
38 | * 是否阻止点击事件冒泡
39 | * 在列表项内部使用时通常需要设置为true
40 | */
41 | stopPropagation?: boolean;
42 | }
43 |
44 | /**
45 | * 主题标签组件
46 | * 用于显示挑战的标签/主题
47 | */
48 | const TopicTag: React.FC = ({
49 | text,
50 | clickable = false,
51 | onClick,
52 | tooltip,
53 | selected = false,
54 | style,
55 | stopPropagation = false
56 | }) => {
57 | const { t } = useTranslation();
58 |
59 | // 使用传入的tooltip或默认的i18n文本
60 | const tooltipText = tooltip || t('tagTooltips.filterByTag');
61 |
62 | // 根据是否被选中设置颜色 - 固定颜色方案
63 | // 未选中时使用蓝色,选中时使用深蓝色
64 | const tagColor = selected ? 'geekblue' : 'blue';
65 | const cursor = clickable ? 'pointer' : undefined;
66 |
67 | const handleClick = (e: React.MouseEvent) => {
68 | // 根据需要阻止事件冒泡
69 | if (stopPropagation) {
70 | e.stopPropagation();
71 | }
72 |
73 | // 如果有标签文本且提供了onClick回调,则调用
74 | if (text && onClick) {
75 | onClick(text);
76 | }
77 | };
78 |
79 | const tag = (
80 |
85 | {text}
86 |
87 | );
88 |
89 | return tooltipText && clickable ? (
90 |
91 | {tag}
92 |
93 | ) : tag;
94 | };
95 |
96 | export default TopicTag;
--------------------------------------------------------------------------------
/src/gh-fork-ribbon.css:
--------------------------------------------------------------------------------
1 | /*!
2 | * "Fork me on GitHub" CSS ribbon v0.2.3
3 | * GitHub文档页面右上角的角标样式
4 | * MIT License
5 | */
6 |
7 | .github-fork-ribbon {
8 | width: 12.1em;
9 | height: 12.1em;
10 | position: absolute;
11 | overflow: hidden;
12 | top: 0;
13 | right: 0;
14 | z-index: 9999;
15 | pointer-events: none;
16 | font-size: 13px;
17 | text-decoration: none;
18 | text-indent: -999999px;
19 | }
20 |
21 | .github-fork-ribbon.fixed {
22 | position: fixed;
23 | }
24 |
25 | .github-fork-ribbon:hover, .github-fork-ribbon:active {
26 | background-color: rgba(0, 0, 0, 0.0);
27 | }
28 |
29 | .github-fork-ribbon:before, .github-fork-ribbon:after {
30 | position: absolute;
31 | display: block;
32 | width: 15.38em;
33 | height: 1.54em;
34 | top: 3.23em;
35 | right: -3.23em;
36 | -webkit-box-sizing: content-box;
37 | -moz-box-sizing: content-box;
38 | box-sizing: content-box;
39 | -webkit-transform: rotate(45deg);
40 | -moz-transform: rotate(45deg);
41 | -ms-transform: rotate(45deg);
42 | -o-transform: rotate(45deg);
43 | transform: rotate(45deg);
44 | }
45 |
46 | .github-fork-ribbon:before {
47 | content: "";
48 | padding: .38em 0;
49 | background-color: #333;
50 | background-image: -webkit-gradient(linear, left top, left bottom, from(rgba(0, 0, 0, 0)), to(rgba(0, 0, 0, 0.15)));
51 | background-image: -webkit-linear-gradient(top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.15));
52 | background-image: -moz-linear-gradient(top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.15));
53 | background-image: -ms-linear-gradient(top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.15));
54 | background-image: -o-linear-gradient(top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.15));
55 | background-image: linear-gradient(to bottom, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.15));
56 | -webkit-box-shadow: 0 .15em .23em 0 rgba(0, 0, 0, 0.5);
57 | -moz-box-shadow: 0 .15em .23em 0 rgba(0, 0, 0, 0.5);
58 | box-shadow: 0 .15em .23em 0 rgba(0, 0, 0, 0.5);
59 | pointer-events: auto;
60 | }
61 |
62 | .github-fork-ribbon:after {
63 | content: attr(data-ribbon);
64 | color: #fff;
65 | font: 700 1em "Helvetica Neue", Helvetica, Arial, sans-serif;
66 | line-height: 1.54em;
67 | text-decoration: none;
68 | text-shadow: 0 -.08em rgba(0, 0, 0, 0.5);
69 | text-align: center;
70 | text-indent: 0;
71 | padding: .15em 0;
72 | margin: .15em 0;
73 | border-width: .08em 0;
74 | border-style: dotted;
75 | border-color: #fff;
76 | border-color: rgba(255, 255, 255, 0.7);
77 | }
78 |
79 | .github-fork-ribbon.left-top, .github-fork-ribbon.left-bottom {
80 | right: auto;
81 | left: 0;
82 | }
83 |
84 | .github-fork-ribbon.left-bottom, .github-fork-ribbon.right-bottom {
85 | top: auto;
86 | bottom: 0;
87 | }
88 |
89 | .github-fork-ribbon.left-top:before, .github-fork-ribbon.left-top:after,
90 | .github-fork-ribbon.left-bottom:before, .github-fork-ribbon.left-bottom:after {
91 | right: auto;
92 | left: -3.23em;
93 | }
94 |
95 | .github-fork-ribbon.left-bottom:before, .github-fork-ribbon.left-bottom:after,
96 | .github-fork-ribbon.right-bottom:before, .github-fork-ribbon.right-bottom:after {
97 | top: auto;
98 | bottom: 3.23em;
99 | }
100 |
101 | .github-fork-ribbon.left-top:before, .github-fork-ribbon.left-top:after,
102 | .github-fork-ribbon.right-bottom:before, .github-fork-ribbon.right-bottom:after {
103 | -webkit-transform: rotate(-45deg);
104 | -moz-transform: rotate(-45deg);
105 | -ms-transform: rotate(-45deg);
106 | -o-transform: rotate(-45deg);
107 | transform: rotate(-45deg);
108 | }
--------------------------------------------------------------------------------
/src/i18n.ts:
--------------------------------------------------------------------------------
1 | import i18n from 'i18next';
2 | import { initReactI18next } from 'react-i18next';
3 | import LanguageDetector from 'i18next-browser-languagedetector';
4 | import enTranslations from './locales/en';
5 | import zhTranslations from './locales/zh';
6 |
7 | // 获取用户的语言偏好
8 | const getUserLanguagePreference = () => {
9 | // 首先检查本地存储中是否有用户选择的语言
10 | const savedLanguage = localStorage.getItem('language');
11 | if (savedLanguage) {
12 | console.log('从localStorage获取到语言偏好:', savedLanguage);
13 | return savedLanguage;
14 | }
15 |
16 | // 如果没有存储的语言偏好,则检测浏览器语言
17 | const browserLang = navigator.language || (navigator as any).userLanguage;
18 | console.log('从浏览器获取到语言偏好:', browserLang);
19 |
20 | // 只支持中文和英文,将浏览器语言匹配到支持的语言
21 | if (browserLang.startsWith('zh')) {
22 | console.log('使用中文作为默认语言');
23 | return 'zh';
24 | }
25 |
26 | // 默认返回英文
27 | console.log('使用英文作为默认语言');
28 | return 'en';
29 | };
30 |
31 | const initialLanguage = getUserLanguagePreference();
32 | console.log('初始化i18n,初始语言:', initialLanguage);
33 |
34 | i18n
35 | .use(LanguageDetector) // 添加语言检测器
36 | .use(initReactI18next)
37 | .init({
38 | resources: {
39 | en: {
40 | translation: enTranslations
41 | },
42 | zh: {
43 | translation: zhTranslations
44 | }
45 | },
46 | lng: initialLanguage,
47 | fallbackLng: 'en',
48 | detection: {
49 | order: ['localStorage', 'navigator'],
50 | lookupLocalStorage: 'language',
51 | caches: ['localStorage'],
52 | },
53 | interpolation: {
54 | escapeValue: false
55 | }
56 | });
57 |
58 | export const changeLanguage = (language: string) => {
59 | console.log('正在切换语言到:', language);
60 | localStorage.setItem('language', language);
61 | i18n.changeLanguage(language).then(() => {
62 | console.log('语言切换成功,当前语言:', i18n.language);
63 | });
64 | };
65 |
66 | export default i18n;
--------------------------------------------------------------------------------
/src/i18n/utils.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 创建类型安全的翻译键对象
3 | * @param keys 翻译键对象
4 | * @returns 相同结构的翻译键对象,但每个值都是它的键路径
5 | */
6 | export function createTranslationKeys>(
7 | keys: T,
8 | prefix: string = ''
9 | ): T {
10 | const result: any = {};
11 |
12 | for (const [key, value] of Object.entries(keys)) {
13 | const currentPath = prefix ? `${prefix}.${key}` : key;
14 |
15 | if (typeof value === 'object' && value !== null) {
16 | result[key] = createTranslationKeys(value, currentPath);
17 | } else {
18 | result[key] = currentPath;
19 | }
20 | }
21 |
22 | return result;
23 | }
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
3 | line-height: 1.5;
4 | font-weight: 400;
5 |
6 | color-scheme: light dark;
7 | color: rgba(255, 255, 255, 0.87);
8 | background-color: #242424;
9 |
10 | font-synthesis: none;
11 | text-rendering: optimizeLegibility;
12 | -webkit-font-smoothing: antialiased;
13 | -moz-osx-font-smoothing: grayscale;
14 | }
15 |
16 | a {
17 | font-weight: 500;
18 | color: #646cff;
19 | text-decoration: inherit;
20 | }
21 | a:hover {
22 | color: #535bf2;
23 | }
24 |
25 | body {
26 | margin: 0;
27 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
28 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
29 | sans-serif;
30 | -webkit-font-smoothing: antialiased;
31 | -moz-osx-font-smoothing: grayscale;
32 | background-color: #f8f9fa;
33 | }
34 |
35 | code {
36 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
37 | monospace;
38 | }
39 |
40 | a {
41 | color: #42b983;
42 | text-decoration: none;
43 | transition: color 0.3s;
44 | }
45 |
46 | a:hover {
47 | color: #3eaf7c;
48 | }
49 |
50 | #root {
51 | min-height: 100vh;
52 | display: flex;
53 | flex-direction: column;
54 | }
55 |
56 | .ant-layout {
57 | background: #fff;
58 | }
59 |
60 | .ant-menu {
61 | border-bottom: none;
62 | }
63 |
64 | .ant-card {
65 | border-radius: 8px;
66 | }
67 |
68 | .ant-btn-primary {
69 | background-color: #42b983;
70 | border-color: #42b983;
71 | }
72 |
73 | .ant-btn-primary:hover {
74 | background-color: #3eaf7c;
75 | border-color: #3eaf7c;
76 | }
77 |
78 | /* 调整Select组件的文本和下拉图标间距 */
79 | .ant-select-selection-item {
80 | padding-right: 5px !important;
81 | }
82 |
83 | .ant-select-arrow {
84 | inset-inline-end: 5px !important;
85 | }
86 |
87 | button:hover {
88 | border-color: #646cff;
89 | }
90 | button:focus,
91 | button:focus-visible {
92 | outline: 4px auto -webkit-focus-ring-color;
93 | }
94 |
95 | @media (prefers-color-scheme: light) {
96 | :root {
97 | color: #213547;
98 | background-color: #ffffff;
99 | }
100 | a:hover {
101 | color: #747bff;
102 | }
103 | button {
104 | background-color: #f9f9f9;
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | import './index.css'
4 | import App from './App.tsx'
5 | import './i18n'
6 |
7 | createRoot(document.getElementById('root')!).render(
8 |
9 |
10 | ,
11 | )
12 |
--------------------------------------------------------------------------------
/src/plugins/VirtualFileSystemPlugin/challengeProcessor/collector.ts:
--------------------------------------------------------------------------------
1 | import * as fs from 'fs';
2 | import * as path from 'path';
3 | import * as YAML from 'yaml';
4 | import { Challenge } from '../../../types/challenge';
5 | import { processChallengeData } from './processor';
6 |
7 | /**
8 | * 收集目录下所有YAML文件中的挑战数据
9 | * @param dirPath 目录路径
10 | * @param rootDir 根目录
11 | * @param isBuild 是否为构建模式
12 | * @returns 挑战数据数组
13 | */
14 | export function collectYAMLChallenges(dirPath: string, rootDir: string, isBuild = false): Challenge[] {
15 | const entries = fs.readdirSync(dirPath, { withFileTypes: true });
16 | let challenges: Challenge[] = [];
17 |
18 | for (const entry of entries) {
19 | const fullPath = path.join(dirPath, entry.name);
20 | if (entry.isDirectory()) {
21 | challenges = challenges.concat(collectYAMLChallenges(fullPath, rootDir, isBuild));
22 | } else if (entry.isFile() && path.extname(entry.name) === '.yml') {
23 | const yamlChallenges = processYamlFile(fullPath, rootDir, isBuild);
24 | if (yamlChallenges && yamlChallenges.length > 0) {
25 | challenges = challenges.concat(yamlChallenges);
26 | }
27 | }
28 | }
29 | return challenges;
30 | }
31 |
32 | /**
33 | * 处理单个YAML文件
34 | * @param filePath 文件路径
35 | * @param rootDir 根目录
36 | * @param isBuild 是否为构建模式
37 | * @returns 挑战数据数组
38 | */
39 | function processYamlFile(filePath: string, rootDir: string, isBuild = false): Challenge[] {
40 | try {
41 | const content = fs.readFileSync(filePath, 'utf8');
42 | const parsed = YAML.parse(content);
43 | const challenges: Challenge[] = [];
44 |
45 | // 计算YAML文件相对于根目录的路径
46 | const yamlRelativePath = path.relative(rootDir, filePath);
47 |
48 | // 处理挑战数据
49 | if (parsed.challenges && Array.isArray(parsed.challenges)) {
50 | // 处理多个挑战的数组格式
51 | console.log(`解析包含挑战数组的YAML文件: ${filePath}`);
52 | parsed.challenges.forEach((challenge: any) => {
53 | // 跳过标记为ignored的挑战
54 | if (challenge.ignored === true) {
55 | console.log(`跳过忽略的挑战: ID=${challenge.id || 'unknown'}, 文件: ${yamlRelativePath}`);
56 | return;
57 | }
58 | // 传递YAML文件路径
59 | challenges.push(processChallengeData(challenge, rootDir, isBuild, yamlRelativePath));
60 | });
61 | } else if (parsed.id) {
62 | // 处理单个挑战格式
63 | console.log(`解析单个挑战YAML文件: ${filePath}`);
64 | // 跳过标记为ignored的挑战
65 | if (parsed.ignored !== true) {
66 | challenges.push(processChallengeData(parsed, rootDir, isBuild, yamlRelativePath));
67 | } else {
68 | console.log(`跳过忽略的挑战文件: ${yamlRelativePath}`);
69 | }
70 | } else {
71 | console.warn(`无法识别的YAML格式,没有找到challenges数组或id字段: ${filePath}`);
72 | }
73 |
74 | return challenges;
75 | } catch (e: any) {
76 | console.error(`Error processing ${filePath}: ${e.message}`);
77 | return [];
78 | }
79 | }
--------------------------------------------------------------------------------
/src/plugins/VirtualFileSystemPlugin/challengeProcessor/index.ts:
--------------------------------------------------------------------------------
1 | export * from './types';
2 | export * from './collector';
3 | export * from './processor';
--------------------------------------------------------------------------------
/src/plugins/VirtualFileSystemPlugin/challengeProcessor/types.ts:
--------------------------------------------------------------------------------
1 | // src/plugins/VirtualFileSystemPlugin/challengeProcessor/types.ts
2 | export interface Solution {
3 | title: string;
4 | url: string;
5 | source: string;
6 | author: string;
7 | }
8 |
9 | /**
10 | * 挑战数据基本结构
11 | */
12 | export interface ChallengeBase {
13 | id: string | number;
14 | title: string;
15 | name: string;
16 | name_en?: string;
17 | titleEN?: string;
18 | difficulty: number;
19 | tags: string[];
20 | solutions: Solution[];
21 | sourceFile?: string;
22 | [key: string]: any; // 允许其他属性
23 | }
--------------------------------------------------------------------------------
/src/plugins/VirtualFileSystemPlugin/fileUtils.ts:
--------------------------------------------------------------------------------
1 | import * as fs from 'fs';
2 | import * as path from 'path';
3 |
4 | // 获取目录下所有的YAML文件
5 | export function getAllYamlFiles(dir: string): string[] {
6 | const results: string[] = [];
7 | const entries = fs.readdirSync(dir, { withFileTypes: true });
8 |
9 | for (const entry of entries) {
10 | const fullPath = path.join(dir, entry.name);
11 | if (entry.isDirectory()) {
12 | results.push(...getAllYamlFiles(fullPath));
13 | } else if (entry.isFile() &&
14 | (path.extname(entry.name) === '.yml' ||
15 | path.extname(entry.name) === '.yaml')) {
16 | results.push(fullPath);
17 | }
18 | }
19 |
20 | return results;
21 | }
22 |
23 | // 获取目录下所有的Markdown文件
24 | export function getAllMarkdownFiles(dir: string): string[] {
25 | const results: string[] = [];
26 | const entries = fs.readdirSync(dir, { withFileTypes: true });
27 |
28 | for (const entry of entries) {
29 | const fullPath = path.join(dir, entry.name);
30 | if (entry.isDirectory()) {
31 | results.push(...getAllMarkdownFiles(fullPath));
32 | } else if (entry.isFile() &&
33 | (path.extname(entry.name) === '.md' ||
34 | path.extname(entry.name) === '.markdown')) {
35 | results.push(fullPath);
36 | }
37 | }
38 |
39 | return results;
40 | }
--------------------------------------------------------------------------------
/src/plugins/VirtualFileSystemPlugin/imageProcessor/index.ts:
--------------------------------------------------------------------------------
1 | export * from './types';
2 | export * from './utils';
3 | export * from './markdownProcessor';
--------------------------------------------------------------------------------
/src/plugins/VirtualFileSystemPlugin/imageProcessor/types.ts:
--------------------------------------------------------------------------------
1 | export interface ImageProcessResult {
2 | success: boolean;
3 | data: string;
4 | mimeType: string;
5 | fullPath: string;
6 | }
--------------------------------------------------------------------------------
/src/plugins/VirtualFileSystemPlugin/types.ts:
--------------------------------------------------------------------------------
1 | import type { PluginOption } from 'vite';
2 |
3 | export interface VirtualFileSystemPluginOptions {
4 | directory: string;
5 | outputPath?: string;
6 | }
7 |
8 | export interface Solution {
9 | title: string;
10 | url: string;
11 | source: string;
12 | author: string;
13 | }
14 |
15 | export interface ImageProcessResult {
16 | success: boolean;
17 | data: string;
18 | mimeType: string;
19 | fullPath: string;
20 | }
--------------------------------------------------------------------------------
/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | declare namespace NodeJS {
4 | interface ProcessEnv {
5 | readonly NODE_ENV: 'development' | 'production' | 'test';
6 | readonly PUBLIC_URL: string;
7 | readonly REACT_APP_API_BASE: string;
8 | }
9 | }
--------------------------------------------------------------------------------
/src/services/SearchService.ts:
--------------------------------------------------------------------------------
1 | import Fuse from 'fuse.js';
2 | import { Challenge } from '../types/challenge';
3 |
4 | // Fuse.js配置选项
5 | const fuseOptions = {
6 | // 搜索键,指定在哪些字段中进行搜索
7 | keys: [
8 | { name: 'title', weight: 2 }, // 标题权重更高
9 | { name: 'description', weight: 1 },
10 | { name: 'tags', weight: 1.5 }, // 标签权重较高
11 | { name: 'platform', weight: 1 },
12 | { name: 'number', weight: 2 }, // 题号权重高
13 | 'id',
14 | 'idAlias',
15 | 'descriptionMarkdown'
16 | ],
17 | // 模糊搜索的阈值,越低匹配越严格,范围0-1
18 | threshold: 0.3,
19 | // 匹配字符的位置差异,越低匹配越严格
20 | distance: 100,
21 | // 是否包含匹配的分数
22 | includeScore: true,
23 | // 是否按分数排序
24 | shouldSort: true,
25 | // 最小字符匹配长度
26 | minMatchCharLength: 2,
27 | // 使用前缀匹配模式
28 | useExtendedSearch: true,
29 | // 是否区分大小写
30 | ignoreLocation: true,
31 | // 精确匹配时的加分
32 | findAllMatches: true
33 | };
34 |
35 | /**
36 | * 搜索服务类
37 | */
38 | class SearchService {
39 | private fuse: Fuse | null = null;
40 | private challenges: Challenge[] = [];
41 |
42 | /**
43 | * 初始化Fuse搜索实例
44 | * @param challenges 要搜索的挑战数据
45 | */
46 | initialize(challenges: Challenge[]) {
47 | this.challenges = challenges;
48 | this.fuse = new Fuse(challenges, fuseOptions);
49 | }
50 |
51 | /**
52 | * 执行搜索
53 | * @param query 搜索查询文本
54 | * @returns 匹配的挑战列表
55 | */
56 | search(query: string): Challenge[] {
57 | if (!query.trim() || !this.fuse) {
58 | return this.challenges;
59 | }
60 |
61 | // 执行搜索并返回结果
62 | const results = this.fuse.search(query);
63 | return results.map(result => result.item);
64 | }
65 |
66 | /**
67 | * 根据多个条件过滤挑战
68 | * @param challenges 要过滤的挑战列表
69 | * @param filters 过滤条件
70 | * @returns 过滤后的挑战列表
71 | */
72 | filterChallenges(
73 | challenges: Challenge[],
74 | filters: {
75 | tags?: string[];
76 | difficulty?: string;
77 | platform?: string;
78 | query?: string;
79 | }
80 | ): Challenge[] {
81 | let filteredList = challenges;
82 |
83 | // 确保已忽略的挑战不会包含在结果中
84 | filteredList = filteredList.filter(challenge => !challenge.ignored);
85 |
86 | // 如果有搜索查询,先进行搜索
87 | if (filters.query && filters.query.trim()) {
88 | this.initialize(filteredList);
89 | filteredList = this.search(filters.query);
90 | }
91 |
92 | // 应用其他过滤器
93 | return filteredList.filter(challenge => {
94 | // 标签过滤
95 | const matchesTags = !filters.tags?.length ||
96 | filters.tags.every(tag => challenge.tags.includes(tag));
97 |
98 | // 难度过滤
99 | let matchesDifficulty = true;
100 | if (filters.difficulty && filters.difficulty !== 'all') {
101 | // 处理逗号分隔的多难度筛选
102 | const difficultyArray = filters.difficulty.split(',').filter(Boolean);
103 | if (difficultyArray.length > 0) {
104 | matchesDifficulty = difficultyArray.some(diff =>
105 | challenge.difficulty === parseInt(diff)
106 | );
107 | }
108 | }
109 |
110 | // 平台过滤
111 | const matchesPlatform = !filters.platform || filters.platform === 'all' ||
112 | challenge.platform === filters.platform;
113 |
114 | return matchesTags && matchesDifficulty && matchesPlatform;
115 | });
116 | }
117 | }
118 |
119 | // 导出单例实例
120 | export const searchService = new SearchService();
--------------------------------------------------------------------------------
/src/styles.tsx:
--------------------------------------------------------------------------------
1 | // src/styles.ts
2 | import { Global } from '@emotion/react';
3 |
4 | export const GlobalStyles = () => (
5 |
34 | );
--------------------------------------------------------------------------------
/src/styles/dropdown.css:
--------------------------------------------------------------------------------
1 | /* 难度星级选项的样式 */
2 | .difficulty-option {
3 | cursor: pointer !important;
4 | }
5 |
6 | /* 让难度下拉框始终置于顶层 */
7 | .ant-select-dropdown {
8 | z-index: 1100 !important;
9 | }
10 |
11 | /* 确保下拉框内容完全可见 */
12 | .ant-select-item-option-content {
13 | white-space: nowrap;
14 | display: inline-block;
15 | width: 100%;
16 | }
--------------------------------------------------------------------------------
/src/styles/github-ribbon-fix.css:
--------------------------------------------------------------------------------
1 | /* 修复GitHub徽标点击问题 */
2 | .github-fork-ribbon {
3 | pointer-events: auto !important;
4 | }
5 |
6 | .github-fork-ribbon a {
7 | pointer-events: auto !important;
8 | display: block;
9 | width: 100%;
10 | height: 100%;
11 | position: relative;
12 | z-index: 10000;
13 | }
14 |
15 | .github-fork-ribbon:before {
16 | pointer-events: auto !important;
17 | }
--------------------------------------------------------------------------------
/src/styles/index.css:
--------------------------------------------------------------------------------
1 | /* src/styles/index.css */
2 | body {
3 | margin: 0;
4 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
5 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
6 | sans-serif;
7 | -webkit-font-smoothing: antialiased;
8 | -moz-osx-font-smoothing: grayscale;
9 | }
10 |
11 | code {
12 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
13 | monospace;
14 | }
--------------------------------------------------------------------------------
/src/styles/star-rating.css:
--------------------------------------------------------------------------------
1 | /* 可点击的星级容器悬停效果 */
2 | .star-rating-clickable:hover {
3 | transform: scale(1.05);
4 | box-shadow: 0 0 5px rgba(255, 197, 61, 0.3);
5 | }
6 |
7 | /* 可点击的星星图标悬停效果 */
8 | .star-icon-clickable:hover {
9 | transform: scale(1.1);
10 | }
11 |
12 | /* 下拉选项中的星级样式 */
13 | .difficulty-option .ant-tag {
14 | cursor: pointer !important;
15 | }
16 |
17 | /* 确保下拉项中的星级正确显示 */
18 | .ant-select-item-option-content .ant-tag {
19 | display: flex;
20 | align-items: center;
21 | width: 100%;
22 | justify-content: flex-start;
23 | }
--------------------------------------------------------------------------------
/src/types/challenge.ts :
--------------------------------------------------------------------------------
1 | export interface Challenge {
2 | id: number;
3 | title: string;
4 | difficulty: string;
5 | description: string;
6 | example: string;
7 | testCases: Array<{
8 | input: string;
9 | expectedOutput: string;
10 | }>;
11 | createdAt: string;
12 | updatedAt: string;
13 | tags: string[];
14 | solutions: Array<{
15 | title: string;
16 | url: string;
17 | source: string;
18 | }>;
19 | externalLink: string;
20 | }
--------------------------------------------------------------------------------
/src/types/vfs.ts:
--------------------------------------------------------------------------------
1 | // src/types/vfs.ts
2 | export interface Solution {
3 | title: string;
4 | url: string;
5 | source: string;
6 | author: string;
7 | }
8 |
9 | export interface Challenge {
10 | id: string;
11 | number: string;
12 | title: string;
13 | description: string;
14 | difficulty: string;
15 | tags: string[];
16 | solutions: Solution[];
17 | createTime: string;
18 | updateTime: string;
19 | externalLink: string;
20 | }
--------------------------------------------------------------------------------
/src/types/web-vitals.d.ts:
--------------------------------------------------------------------------------
1 | // src/types/web-vitals.d.ts
2 | declare module 'web-vitals' {
3 | export interface Metric {
4 | name: 'CLS' | 'FCP' | 'FID' | 'LCP' | 'TTFB';
5 | value: number;
6 | rating: 'good' | 'needs-improvement' | 'poor';
7 | delta: number;
8 | entries: PerformanceEntry[];
9 | id: string;
10 | }
11 |
12 | export function getCLS(cb: (metric: Metric) => void): void;
13 | export function getFID(cb: (metric: Metric) => void): void;
14 | export function getFCP(cb: (metric: Metric) => void): void;
15 | export function getLCP(cb: (metric: Metric) => void): void;
16 | export function getTTFB(cb: (metric: Metric) => void): void;
17 | }
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 | "target": "ES2020",
5 | "useDefineForClassFields": true,
6 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
7 | "module": "ESNext",
8 | "skipLibCheck": true,
9 |
10 | /* Bundler mode */
11 | "moduleResolution": "node",
12 | "allowImportingTsExtensions": true,
13 | "isolatedModules": true,
14 | "moduleDetection": "force",
15 | "noEmit": true,
16 | "jsx": "react-jsx",
17 |
18 | /* Linting */
19 | "strict": true,
20 | "noUnusedLocals": true,
21 | "noUnusedParameters": true,
22 | "noFallthroughCasesInSwitch": true,
23 | "noUncheckedSideEffectImports": true
24 | },
25 | "include": ["src"]
26 | }
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./tsconfig.app.json" },
5 | { "path": "./tsconfig.node.json" }
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2022",
5 | "lib": ["ES2023"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "isolatedModules": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 |
16 | /* Linting */
17 | "strict": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "noFallthroughCasesInSwitch": true,
21 | "noUncheckedSideEffectImports": true
22 | },
23 | "include": ["vite.config.ts"]
24 | }
25 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "rewrites": [
3 | {
4 | "source": "/(.*)",
5 | "destination": "/index.html"
6 | }
7 | ],
8 | "github": {
9 | "silent": true
10 | }
11 | }
--------------------------------------------------------------------------------