Перейти к содержанию

Projects pipeline — авто-подгрузка проектов с GitHub

Как раздел Projects на сайте обновляется сам, без ручного редактирования. Добавил публичную репу на GitHub → она появилась на сайте в течение часа.

Это первый кусок реальной инфраструктуры за сайтом, поэтому разберём подробно — не только что сделано, но и почему именно так.


Задача и в чём подвох

Хотелось: список проектов на сайте берётся прямо из GitHub, живой, без правки кода руками. Очевидное решение — дёрнуть GitHub API прямо из браузера. И тут два стопа:

  1. Сайт статический. Он на Cloudflare Pages — это просто HTML/CSS/JS файлы, там негде запустить серверный код. Статика не умеет «сходить в API» сама по себе.
  2. У 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"
  };
}
  1. Бейдж 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)
  1. GitHub требует заголовок User-Agent, иначе отказывает. Accept просит современный формат ответа (с топиками).
  2. Выкидываем форки, приватные и профильную репу.
  3. Ручные проекты (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)
}
  1. Есть свежий кэш → отдаём, в GitHub не ходим.
  2. expirationTtl: 3600 — KV сам удалит ключ через час, следующий запрос пересоберёт. Кэш протухает раз в час без всякого крона.
  3. Отдельно храним «последний удачный» без TTL — на случай если GitHub ляжет.
  4. Если GitHub недоступен и снимка нет — честно только реальные ручные проекты + флаг ошибки. Никаких выдуманных данных.

CORS — почему без него не работает

Браузер из соображений безопасности запрещает сайту обращаться на другой домен (сайт на technopriest.net, Worker на *.workers.dev — разные origins). Чтобы разрешить, Worker должен явно сказать «мне можно»:

function cors() {
  return { "Access-Control-Allow-Origin": "*", /* ... */ };
}

Без этого заголовка 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 — передеплой:

cd worker
npx wrangler deploy

Что освоено

Serverless и edge-функции · key-value кэширование и TTL · rate limits и зачем кэш · CORS · статика vs динамика · wrangler / npx / деплой Worker'а · честная деградация без фабриката данных.

Заметка

GitHub дёргается неавторизованно (лимит 60/час, но при кэше раз в час это 1 запрос — с огромным запасом). Если понадобится больше — GitHub-токен секретом Worker'а (wrangler secret put GH_TOKEN) поднимает лимит до 5000/час. Сейчас не нужно.