Projects pipeline — авто-подгрузка проектов с GitHub¶
Как раздел Projects на сайте обновляется сам, без ручного редактирования. Добавил публичную репу на GitHub → она появилась на сайте в течение часа.
Это первый кусок реальной инфраструктуры за сайтом, поэтому разберём подробно — не только что сделано, но и почему именно так.
Задача и в чём подвох¶
Хотелось: список проектов на сайте берётся прямо из GitHub, живой, без правки кода руками. Очевидное решение — дёрнуть GitHub API прямо из браузера. И тут два стопа:
- Сайт статический. Он на Cloudflare Pages — это просто HTML/CSS/JS файлы, там негде запустить серверный код. Статика не умеет «сходить в API» сама по себе.
- У GitHub API лимит — 60 запросов в час на IP (для неавторизованных), и тратит его каждый посетитель со своего IP. Десять обновлений страницы — 10 из 60. За корпоративным/универским NAT, где сотни людей под одним IP, лимит общий — выжгут за секунды, и у части людей секция не загрузится.
Вывод: ходить в GitHub из браузера нельзя. Нужен посредник, который сходит в GitHub один раз, запомнит результат и раздаст всем. Этот посредник — Cloudflare Worker.
Архитектура¶
flowchart LR
GH[GitHub API] -->|раз в час| W[Cloudflare Worker]
W -->|пишет/читает| KV[(KV cache)]
W -->|чистый JSON| S[Сайт<br/>js/main.js]
S -->|рисует карточки| U[Посетитель]
Поток данных: GitHub → Worker → KV → сайт → посетитель. Браузер посетителя общается только с Worker'ом, GitHub он не видит. Поэтому лимит GitHub посетителей не касается — в GitHub ходит только Worker, один раз в час.
Из чего собрано¶
Cloudflare Worker¶
Маленькая программа, которая крутится на серверах Cloudflare по всему миру, а не у меня дома. «Serverless»: я не держу и не обслуживаю сервер — Cloudflare сам запускает код, когда приходит запрос. Бесплатно (100k запросов в день), всегда онлайн, железо не нужно. Этот Worker делает одно: ходит в GitHub API, причёсывает ответ, отдаёт чистый JSON.
KV (Key-Value)¶
Простейшая база Cloudflare формата «ключ → значение», как словарь. Worker кладёт готовый
список под ключом projects:v1 и держит час (TTL — time to live). Пока кэш свежий —
отдаёт из KV, не дёргая GitHub. Это и есть кэширование: посчитали один раз, раздаём многим.
Сайт (js/main.js)¶
Фронт просто делает fetch к URL Worker'а и рисует карточки. Логики с GitHub на сайте
нет — она вся спрятана в Worker.
Worker по частям¶
Полный код — в репозитории technopriest-site, файл worker/index.js.
Конфиг¶
const GH_USER = "Moody-code365";
const CACHE_KEY = "projects:v1";
const TTL = 3600; // 1 час в секундах
const FEATURED = ["ScrapperAD", "technopriest-site"]; // эти — вперёд
const HIDE = new Set([GH_USER.toLowerCase()]); // профильную репу прячем
const MANUAL = [ { name: "oknovdom.kz", /* ... */ } ];// реальные проекты без репы
const OVERRIDES = { }; // правки описания/бейджа поверх
Вся курация живёт здесь, в Worker'е, а не на сайте. Сайт «тупой» — рисует что дали.
Закрепить проект → имя в FEATURED. Скрыть → в HIDE. Добавить проект не из GitHub →
в MANUAL. Так сайт никогда не содержит захардкоженных реп, но картинка управляемая.
shapeRepo — причёсывание¶
GitHub на каждую репу возвращает огромный объект с десятками полей; нужно пять.
shapeRepo делает из сырой репы маленький чистый объект:
function shapeRepo(repo) {
return {
name: repo.name,
description: repo.description || "",
url: repo.html_url,
language: repo.language || "",
topics: (repo.topics || []).slice(0, 3),
stars: repo.stargazers_count || 0,
badge: repo.archived ? "archived" : "oss", // (1)
badge_label: repo.archived ? "archived" : "open source",
link_label: "github \u2197"
};
}
- Бейдж
archivedставится автоматически, если репа реально заархивирована на GitHub (Settings → Archive). То есть заархивировал на GitHub — Worker подхватит сам.
buildPayload — сборка списка¶
const res = await fetch(
`https://api.github.com/users/${GH_USER}/repos?per_page=100&sort=pushed`,
{ headers: { "User-Agent": "technopriest-projects-worker",
"Accept": "application/vnd.github+json" } } // (1)
);
const repos = await res.json();
const cleaned = repos
.filter(r => !r.fork && !r.private && !HIDE.has(r.name.toLowerCase())) // (2)
.map(shapeRepo)
.sort(/* FEATURED вперёд, потом по дате последнего push */);
return { generated_at: new Date().toISOString(),
projects: [...MANUAL, ...cleaned] }; // (3)
- GitHub требует заголовок
User-Agent, иначе отказывает.Acceptпросит современный формат ответа (с топиками). - Выкидываем форки, приватные и профильную репу.
- Ручные проекты (
MANUAL, там oknovdom) ставятся первыми, потом репы.
Обработчик запроса — где живёт кэш¶
const cached = await env.PROJECTS_KV.get(CACHE_KEY);
if (cached) return json(JSON.parse(cached)); // (1)
try {
const payload = await buildPayload();
const body = JSON.stringify(payload);
await env.PROJECTS_KV.put(CACHE_KEY, body, { expirationTtl: TTL }); // (2)
await env.PROJECTS_KV.put("projects:lastgood", body); // (3)
return json(payload);
} catch (err) {
const lastGood = await env.PROJECTS_KV.get("projects:lastgood");
if (lastGood) return json(JSON.parse(lastGood));
return json({ projects: MANUAL, error: "github_unavailable" }); // (4)
}
- Есть свежий кэш → отдаём, в GitHub не ходим.
expirationTtl: 3600— KV сам удалит ключ через час, следующий запрос пересоберёт. Кэш протухает раз в час без всякого крона.- Отдельно храним «последний удачный» без TTL — на случай если GitHub ляжет.
- Если GitHub недоступен и снимка нет — честно только реальные ручные проекты + флаг ошибки. Никаких выдуманных данных.
CORS — почему без него не работает¶
Браузер из соображений безопасности запрещает сайту обращаться на другой домен (сайт
на technopriest.net, Worker на *.workers.dev — разные origins). Чтобы разрешить,
Worker должен явно сказать «мне можно»:
Без этого заголовка fetch с сайта упал бы с CORS-ошибкой. Это не «защита», а
разрешение, которое сервер выдаёт браузеру.
Сайт по частям¶
В js/main.js логика короткая: сходить на Worker, нарисовать карточки, при неудаче —
честное состояние.
fetch(PROJECTS_ENDPOINT, { headers: { 'Accept': 'application/json' } })
.then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(data => render(data.projects || data))
.catch(() => setState('offline', 'projects offline.'));
Состояния: loading пока грузит, ok рисуем карточки, offline при неудаче (честная плашка, не выдуманные репы), empty если проектов нет.
Всё прогоняется через экранирование перед вставкой в HTML — описания приходят из внешнего источника, без экранирования это была бы дыра XSS.
Как с этим жить¶
| Хочу | Что сделать |
|---|---|
| Добавить проект | Создать публичную репу — появится сам в течение часа |
| Закрепить вверху | Имя в FEATURED в worker/index.js |
| Скрыть репу | Имя в HIDE |
| Проект не из GitHub | Объект в MANUAL |
| Улучшить описание/бейдж | Запись в OVERRIDES |
Бейдж archived |
Заархивировать репу на GitHub |
| Теги на карточках | Проставить Topics на репе (About → ⚙️) |
После правки worker/index.js — передеплой:
Что освоено¶
Serverless и edge-функции · key-value кэширование и TTL · rate limits и зачем кэш · CORS ·
статика vs динамика · wrangler / npx / деплой Worker'а · честная деградация без
фабриката данных.
Заметка
GitHub дёргается неавторизованно (лимит 60/час, но при кэше раз в час это 1 запрос —
с огромным запасом). Если понадобится больше — GitHub-токен секретом Worker'а
(wrangler secret put GH_TOKEN) поднимает лимит до 5000/час. Сейчас не нужно.