commit f68c77a49b165cd02583d324beb0d539bd9e254f Author: admin Date: Mon Sep 28 21:36:36 2026 +0800 AI 生成: 一个从 Gitea 服务器获取最新项目的工具,支持命令行使用和 Web 界面查看。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..5f7deb6 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# Gitea Latest Project Fetcher + +从 Gitea 服务器获取最新的项目(仓库)列表。 + +## 功能 + +- 通过 Gitea API 获取指定用户或组织的最新仓库 +- 支持命令行使用(Python) +- 提供 Web 界面查看结果(Flask) + +## 目录结构 + +``` +gitea-latest-project-fetcher/ +├── README.md +├── requirements.txt +├── cli.py # 命令行工具 +├── web_app.py # Flask Web 应用 +├── gitea_client.py # Gitea API 客户端 +├── templates/ +│ └── index.html # Web 页面 +└── static/ + └── style.css # 样式 +``` + +## 安装 + +```bash +pip install -r requirements.txt +``` + +## 命令行使用 + +```bash +# 获取用户的最新仓库 +python cli.py --base-url https://gitea.example.com --owner myuser --limit 5 + +# 使用 token(私有仓库需要) +python cli.py --base-url https://gitea.example.com --owner myuser --token YOUR_TOKEN +``` + +## Web 使用 + +```bash +python web_app.py +# 打开浏览器访问 http://127.0.0.1:5000 +``` + +## 配置 + +可通过环境变量设置默认值: + +- `GITEA_BASE_URL`:Gitea 服务器地址 +- `GITEA_TOKEN`:访问令牌 + +## 说明 + +本工具使用 Gitea 的 REST API:`GET /api/v1/users/{owner}/repos`, +按更新时间排序后返回最新的仓库。 diff --git a/cli.py b/cli.py new file mode 100644 index 0000000..ae25373 --- /dev/null +++ b/cli.py @@ -0,0 +1,50 @@ +"""命令行工具:从 Gitea 获取最新项目。""" + +import argparse +import os +import sys + +from gitea_client import GiteaClient + + +def main(): + parser = argparse.ArgumentParser(description='从 Gitea 获取最新的项目列表') + parser.add_argument('--base-url', default=os.environ.get('GITEA_BASE_URL'), + help='Gitea 服务器地址,如 https://gitea.example.com') + parser.add_argument('--owner', required=True, help='用户名或组织名') + parser.add_argument('--token', default=os.environ.get('GITEA_TOKEN'), + help='访问令牌(私有仓库需要)') + parser.add_argument('--limit', type=int, default=10, help='返回数量,默认 10') + parser.add_argument('--org', action='store_true', help='将 owner 视为组织') + args = parser.parse_args() + + if not args.base_url: + print('错误:请通过 --base-url 或环境变量 GITEA_BASE_URL 指定 Gitea 地址', file=sys.stderr) + sys.exit(1) + + client = GiteaClient(args.base_url, args.token) + + try: + if args.org: + repos = client.get_org_latest_repos(args.owner, args.limit) + else: + repos = client.get_latest_repos(args.owner, args.limit) + except Exception as e: + print(f'获取仓库失败:{e}', file=sys.stderr) + sys.exit(1) + + if not repos: + print('未找到仓库。') + return + + print(f'\n{args.owner} 的最新 {len(repos)} 个项目:\n') + for i, repo in enumerate(repos, 1): + print(f'{i}. {repo.get("full_name")}') + print(f' 描述: {repo.get("description") or "(无)"}') + print(f' 更新: {repo.get("updated_at")}') + print(f' 链接: {repo.get("html_url")}') + print() + + +if __name__ == '__main__': + main() diff --git a/gitea_client.py b/gitea_client.py new file mode 100644 index 0000000..6f7ae53 --- /dev/null +++ b/gitea_client.py @@ -0,0 +1,51 @@ +"""Gitea API 客户端:获取指定用户/组织的最新仓库。""" + +import os +import requests + + +class GiteaClient: + """简单的 Gitea API 客户端。""" + + def __init__(self, base_url: str, token: str = None): + # 去掉末尾的斜杠,避免拼接出双斜杠 + self.base_url = base_url.rstrip('/') + self.token = token or os.environ.get('GITEA_TOKEN') + + def _headers(self): + headers = {'Accept': 'application/json'} + if self.token: + headers['Authorization'] = f'token {self.token}' + return headers + + def get_latest_repos(self, owner: str, limit: int = 10): + """获取指定用户或组织的最新仓库。 + + 参数: + owner: 用户名或组织名 + limit: 返回的仓库数量上限 + + 返回: + 仓库信息列表(dict),按更新时间倒序排列。 + """ + url = f'{self.base_url}/api/v1/users/{owner}/repos' + params = {'sort': 'updated', 'limit': limit} + + resp = requests.get(url, headers=self._headers(), params=params, timeout=15) + resp.raise_for_status() + repos = resp.json() + + # 按 updated_at 倒序排序,确保拿到真正最新的 + repos.sort(key=lambda r: r.get('updated_at', ''), reverse=True) + return repos[:limit] + + def get_org_latest_repos(self, org: str, limit: int = 10): + """获取指定组织的最新仓库。""" + url = f'{self.base_url}/api/v1/orgs/{org}/repos' + params = {'sort': 'updated', 'limit': limit} + + resp = requests.get(url, headers=self._headers(), params=params, timeout=15) + resp.raise_for_status() + repos = resp.json() + repos.sort(key=lambda r: r.get('updated_at', ''), reverse=True) + return repos[:limit] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3c496be --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +requests>=2.28.0 +Flask>=3.0.0 diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..98db433 --- /dev/null +++ b/static/style.css @@ -0,0 +1,129 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + background: #f5f7fa; + color: #24292e; +} + +.container { + max-width: 800px; + margin: 0 auto; + padding: 32px 16px; +} + +h1 { + font-size: 28px; + margin-bottom: 24px; +} + +h2 { + font-size: 20px; + margin-top: 32px; +} + +.search-form { + background: #fff; + padding: 20px; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +.field { + display: flex; + flex-direction: column; +} + +.field label { + font-size: 13px; + color: #586069; + margin-bottom: 6px; +} + +.field input[type="text"], +.field input[type="password"], +.field input[type="number"] { + padding: 8px 10px; + border: 1px solid #d1d5da; + border-radius: 6px; + font-size: 14px; +} + +.field.checkbox { + justify-content: flex-end; +} + +.field.checkbox label { + display: flex; + align-items: center; + gap: 6px; + color: #24292e; + font-size: 14px; +} + +button { + grid-column: 1 / -1; + padding: 10px 16px; + background: #2ea44f; + color: #fff; + border: none; + border-radius: 6px; + font-size: 15px; + cursor: pointer; +} + +button:hover { + background: #2c974b; +} + +.error { + margin-top: 16px; + padding: 12px 16px; + background: #ffeef0; + color: #86181d; + border-radius: 6px; +} + +.repo-list { + list-style: none; + padding: 0; + margin: 16px 0 0; +} + +.repo-item { + background: #fff; + padding: 16px; + border-radius: 8px; + margin-bottom: 12px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); +} + +.repo-name { + font-size: 17px; + font-weight: 600; + color: #0366d6; + text-decoration: none; +} + +.repo-name:hover { + text-decoration: underline; +} + +.repo-desc { + margin: 8px 0; + color: #586069; + font-size: 14px; +} + +.repo-meta { + display: flex; + gap: 16px; + font-size: 12px; + color: #6a737d; +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..96b978d --- /dev/null +++ b/templates/index.html @@ -0,0 +1,61 @@ + + + + + + Gitea 最新项目 + + + +
+

Gitea 最新项目

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+ + {% if error %} +
{{ error }}
+ {% endif %} + + {% if repos %} +

结果({{ repos|length }} 个)

+ + {% endif %} +
+ + diff --git a/web_app.py b/web_app.py new file mode 100644 index 0000000..4d59e6c --- /dev/null +++ b/web_app.py @@ -0,0 +1,46 @@ +"""Flask Web 应用:在浏览器中查看 Gitea 最新项目。""" + +import os +from flask import Flask, render_template, request + +from gitea_client import GiteaClient + +app = Flask(__name__) + + +@app.route('/', methods=['GET', 'POST']) +def index(): + repos = [] + error = None + form = { + 'base_url': os.environ.get('GITEA_BASE_URL', ''), + 'owner': '', + 'token': '', + 'limit': 10, + 'is_org': False, + } + + if request.method == 'POST': + form['base_url'] = request.form.get('base_url', '').strip() + form['owner'] = request.form.get('owner', '').strip() + form['token'] = request.form.get('token', '').strip() + form['limit'] = int(request.form.get('limit', 10) or 10) + form['is_org'] = request.form.get('is_org') == 'on' + + if not form['base_url'] or not form['owner']: + error = '请填写 Gitea 地址和用户名/组织名。' + else: + try: + client = GiteaClient(form['base_url'], form['token'] or None) + if form['is_org']: + repos = client.get_org_latest_repos(form['owner'], form['limit']) + else: + repos = client.get_latest_repos(form['owner'], form['limit']) + except Exception as e: + error = f'获取失败:{e}' + + return render_template('index.html', repos=repos, error=error, form=form) + + +if __name__ == '__main__': + app.run(host='127.0.0.1', port=5000, debug=True)