Допустим, вам захотелось, на всякий случай, сохранить копию всех пакетов nuget.org. Как обнаружить и скачать все пакеты не привлекая внимания админов сервиса?

Протоколы NuGet

Как NuGet-клиент обнаруживает пакеты? У клиента есть список пакетов, которые пользователь хочет скачать, клиент должен выяснить откуда их скачать, рекурсивно разрешить зависимости и собственно произвести загрузку. Для получения всей необходимой информации он обращается к API сервиса.

Особо любознательные знают, что существует две версии протокола NuGet: v2 и v3 с соответствующими "source URLs":

V2 основан на OData (XML, странный синтаксис запросов - вот это вот все) и фактически является интерфейсом к БД. Официальной документации на него, насколько я понимаю, не существует, но есть неофициальная.

С помощью v2 можно, в принципе, перечислить все пакеты, но администрация сервиса не любит когда по v2 много стучатся и ограничивает частоту запросов v2 и урезает возможности. Не используйте v2.

Протокол v3 был разработан для улучшения масштабируемости. Практически весь v3 работает из статических файлов, которые раздаются через CDN, что масштабировать гораздо проще, чем веб-сервис с БД. Только поиск требует каких-то вычислительных мощностей для работы.

Подробнее про v3

Протокол более-менее адекватно документирован. Запрос к v3 source URL вернет нам JSON со списком "сервисов", предоставляемых реализацией NuGet-сервера.

Из реального ответа nuget.org, нас будут интересовать следующие сервисы:

{
    "@id": "https://api.nuget.org/v3/catalog0/index.json",
    "@type": "Catalog/3.0.0",
    "comment": "Index of the NuGet package catalog."
},

{
    "@id": "https://api.nuget.org/v3-flatcontainer/",
    "@type": "PackageBaseAddress/3.0.0",
    "comment": "Base URL of where NuGet packages are stored, in the format https://api.nuget.org/v3-flatcontainer/{id-lower}/{version-lower}/{id-lower}.{version-lower}.nupkg"
},    

Все (или почти все) JSON объекты, возвращаемые v3, на самом деле являются JSON-LD если вдруг вы знаете что это такое и как этим пользоваться. Из-за этого они содержат занимательные свойства с @ в начале имени, которые можно спокойно игнорировть. Возможно из-за этого же есть... "особенности" парсинга некоторых объектов, об этом ниже.

Catalog

С помощью Catalog/3.0.0 мы будем перечислять все пакеты. Каталог представляет собой журнал всего, что происходило с NuGet-пакетами с "начала времен". Прочитав каталог от начала до конца мы воспроизведем все изменения на nuget.org, что даст нам список всех пакетов. Из каталога ничего не удаляется, только добавляются записи в конец.

Протокол v3 не существовал с самого начала жизни nuget.org, поэтому при его создании был произведен импорт всех пакетов, существовавщих на момент запуска, что привело к созданию большого количества записей в начале каталога с близкими временными метками (1 февраля 2015 года). После этого, все новые пакеты имеют отметку времени близкую ко времени публикации.

https://api.nuget.org/v3/catalog0/index.json возвращает JSON примерно такой структуры:

{
  "@id": "https://api.nuget.org/v3/catalog0/index.json",
  "@type": [
    "CatalogRoot",
    "AppendOnlyCatalog",
    "Permalink"
  ],
  "commitId": "a304b4af-3a2c-4653-8ba8-2cdfd667951d",
  "commitTimeStamp": "2024-10-10T02:42:09.3106213Z",
  "count": 20671,
  "nuget:lastCreated": "2024-10-10T02:41:49.91Z",
  "nuget:lastDeleted": "2024-10-09T16:35:48.4746061Z",
  "nuget:lastEdited": "2024-10-10T02:41:49.91Z",
  "items": [
    {
      "@id": "https://api.nuget.org/v3/catalog0/page7713.json",
      "@type": "CatalogPage",
      "commitId": "9f4532df-09d2-473e-a5b5-acfe3fa3935a",
      "commitTimeStamp": "2018-12-29T16:00:42.7935125Z",
      "count": 533
    }
    ...
  ]
}

Это "оглавление каталога" (catalog index) - информация о всех его страницах. Чтобы не иметь один огромный файл, каталог разбит на страницы.

Свойства, начинающиеся на nuget: в этом ответе - недокументированные служебные поля, имеющие отношение к генерации каталога.

commitId - GUID последней записи (точнее группы записей, подробности ниже). Если он изменился с последнего чтения, значит были добавлены новые записи.

commitTimeStamp - время последней записи. Все временные метки используют UTC.

count - число страниц каталога.

items - массив объектов с информацией о каждой странице: GUID последней записи, время последней записи, число записей и ссылка на страницу.

Массив записей не отсортирован, если вам нужны страницы в порядке их создания, придется отсортировать его по полю commitTimeStamp.

Страницы каталога

Если послать запрос с адресом страницы, мы получим, например:

{
  "@id": "https://api.nuget.org/v3/catalog0/page0.json",
  "@type": "CatalogPage",
  "commitId": "19a4aedc-5139-4df5-81a3-b40aeabb3f3c",
  "commitTimeStamp": "2015-02-01T06:30:11.7477681Z",
  "count": 540,
  "items": [
    {
      "@id": "https://api.nuget.org/v3/catalog0/data/2015.02.01.06.22.45/adam.jsgenerator.1.1.0.json",
      "@type": "nuget:PackageDetails",
      "commitTimeStamp": "2015-02-01T06:22:45.8488496Z",
      "nuget:id": "Adam.JSGenerator",
      "nuget:version": "1.1.0",
      "commitId": "b3f4fc8a-7522-42a3-8fee-a91d5488c0b1"
    },
    {
      "@id": "https://api.nuget.org/v3/catalog0/data/2015.02.01.06.22.45/agatha-rrsl.1.2.0.json",
      "@type": "nuget:PackageDetails",
      "commitTimeStamp": "2015-02-01T06:22:45.8488496Z",
      "nuget:id": "Agatha-rrsl",
      "nuget:version": "1.2.0",
      "commitId": "b3f4fc8a-7522-42a3-8fee-a91d5488c0b1"
    },
    ...,
    {
      "@id": "https://api.nuget.org/v3/catalog0/data/2015.02.01.06.30.11/superfarter.1.0.0.json",
      "@type": "nuget:PackageDetails",
      "commitTimeStamp": "2015-02-01T06:30:11.7477681Z",
      "nuget:id": "SuperFarter",
      "nuget:version": "1.0.0",
      "commitId": "19a4aedc-5139-4df5-81a3-b40aeabb3f3c"
    }
  ],
  "parent": "https://api.nuget.org/v3/catalog0/index.json",
  "@context": {
    "@vocab": "http://schema.nuget.org/catalog#",
    "nuget": "http://schema.nuget.org/schema#",
    "items": {
      "@id": "item",
      "@container": "@set"
    },
    "parent": {
      "@type": "@id"
    },
    "commitTimeStamp": {
      "@type": "http://www.w3.org/2001/XMLSchema#dateTime"
    },
    "nuget:lastCreated": {
      "@type": "http://www.w3.org/2001/XMLSchema#dateTime"
    },
    "nuget:lastEdited": {
      "@type": "http://www.w3.org/2001/XMLSchema#dateTime"
    }
  }
}

Тут мы видим уже знакомые commitId, commitTimeStamp, count и items в корневом объекте с тем же смыслом, что и в предыдущем запросе, но только в контексте текущей страницы. items, соответственно, содержит данные о записях каталога, вместо страниц.

parent содержит ссылку на оглавление каталога.

@context можно игнорировать.

Элементы массива items содержат ссылку на собственно запись каталога, тип этой записи, время и GUID группы записей, идентификатор и версию пакета. Массив опять не отсортирован.

Группы записей

Как можно заметить в примере выше, первые два элемента items имеют одинаковые значения свойств commitTimeStamp и commitId. Это особенности работы процесса, который формирует каталог.

Процесс-генератор просыпается каждые несколько минут и проверяет свежие изменения в базе данных. Все найденные изменения записываются в одну страницу с одним commitId и commitTimeStamp. Если добавление прочитанных изменений в текущую страницу превысит некий порог, процесс создает новую страницу и помещает записи туда. Из-за этого, также, размер страницы непостоянен.

Вероятно есть ограничения на размер одного коммита, иначе в начале каталога была бы одна (или несколько) огромная страница, но это явно не так.

Еще одно наблюдение: в начале каталога страницы были размером примерно 550 записей, а в конце - больше 2700. Размер был изменен в 2022 году, чтобы замедлить рост количества страниц.

Типы записей

Документация описывает два типа записей каталога:

  • nuget:PackageDetails - создается для всех новых пакетов, а также если произошло изменение метаданных пакета.

  • nuget:PackageDelete - создается если пакет был удален. Удаление происходит в некоторых исключительных случаях, так что такие записи редки.

Несколько слов об изменениях метаданных пакета. До 2018 года загруженные пакеты можно было редактировать на сайте. Ввиду того, что метаданные находятся в пакете в .nuspec-файле, сайт переупаковывал пакет с новыми метаданными. В 2018 году была добавлена возможность авторам и репозиториям подписывать пакеты (подпись автора и репозитория могут присутствовать одновременно), что поставило крест на возможности редактировать пакеты после публикации и возможность была выпилена.

Соответственно, до 2018 года можно встретить несколько записей каталога для одной пары (идентификатор; версия) с разными метаданными. В таком случае записи, следующие за первой содержат обновленные метаданные пакета и актуальная версия метаданных находится в последней записи.

Документация описывает в каких случаях на сегодняшний день могут быть добавлены новые записи для тех же пакетов.

Запись PackageDetails

{
  "@id": "https://api.nuget.org/v3/catalog0/data/2015.02.01.08.38.05/bclcontrib-abstract.spring.0.1.6.json",
  "@type": "nuget:PackageDetails",
  "commitId": "0502702c-6a9e-4eb6-93a6-e0798a3a0dc7",
  "commitTimeStamp": "2015-02-01T08:38:05.5456876Z",
  "nuget:id": "BclContrib-Abstract.Spring",
  "nuget:version": "0.1.6"
},

Содержит идентификатор и версию пакета, а так же ссылку на "лист" (catalog leaves, аналогично листовым узлам деревьев), который содержит больше информации: лист типа PackageDetails содержит данные из .nuspec-файла пакета: данные секции metadata из .nuspec-файла, информацию о зависимостях, список файлов, дату и время создания и публикации, размер и хеш пакета, информацию о коммите: GUID и время создания, такие же как на странице каталога и признак видимости пакета.

Запись PackageDelete

Выглядит так:

    {
      "@id": "https://api.nuget.org/v3/catalog0/data/2015.10.28.10.44.16/imagesbuttoncontrol.1.0.0.json",
      "@type": "nuget:PackageDelete",
      "commitId": "15d2ae77-d9e4-413e-a5da-f3ea3d5abeb1",
      "commitTimeStamp": "2015-10-28T10:44:16.9226556Z",
      "nuget:id": "ImagesButtonControl",
      "nuget:version": "1.0.0"
    },

Скачивать лист, на который эта запись ссылается бессмысленно, т.к. там записано то же самое.

"Особенности" парсинга

Уж не знаю, исходит ли это от самого формата JSON-LD, библиотеки, которая была использована для генерации этого JSON-LD или это просто баги, но в JSON-файлах размещенных на api.nuget.org некоторые свойства могут быть представлены либо строковым/числовым литералом, либо массивом.

Например, иногда в секции с зависимостями можно обнаружить такое:

{
  "@id": "https://api.nuget.org/v3/catalog0/data/2016.02.21.11.06.01/dingu.generic.repo.ef7.1.0.0-beta2.json#dependencygroup/.netplatform5.4/nuget.commandline",
  "@type": "PackageDependency",
  "id": "NuGet.CommandLine",
  "range": "[3.3.0, )"
},
{
  "@id": "https://api.nuget.org/v3/catalog0/data/2016.02.21.11.06.01/dingu.generic.repo.ef7.1.0.0-beta2.json#dependencygroup/.netplatform5.4/system.runtime",
  "@type": "PackageDependency",
  "id": "System.Runtime",
  "range": [
    "[4.0.10, )",
    "[4.0.21-beta-23516, )"
  ]
},

Свойство range может быть строкой, а может быть и массивом. Не велика проблема, просто имейте в виду, что такое может встретиться.

Качаем пакеты

Если нас интересуют только сами пакеты, листы каталога можно не качать. Для скачивания пакета нам достаточно знать идентификатор и версию пакета: в комментарии к ресурсу PackageBaseAddress/3.0.0 написано все, что нам надо знать, чтобы сконструировать ссылку для скачивания. Любители документации обнаружат, что там записано то же самое: приводим идентификатор и версию пакета в нижний регистр и добавляем к адресу ресурса согласно схеме:

https://api.nuget.org/v3-flatcontainer/{id-lower}/{version-lower}/{id-lower}.{version-lower}.nupkg

Так что все, что надо сделать: пробежать по записям каталога, сгенерировать ссылки на пакеты и скачать их. Надо иметь в ввиду, что для пакета может быть несколько записей и качать пакет стоит только один раз; пакет может быть удален, так что запрос к сконструированной ссылке вернет 404.

Т.к. записи всегда добавляются только в конец каталога, если надо скачивать пакеты по мере их загрузки, то достаточно запоминать commitId и проверять оглавление каталога раз в несколько минут на предмет изменения идентификатора последнего коммита, и когда он поменялся искать новые записи в последних страницах каталога. Ориентация по временным меткам позволит все быстро найти.

Если же целью стоит склонировать nuget.org с возможностью направить NuGet-клиент на ваш клон и восстанавливать с него пакеты, то нужно будет воссоздать ресурс RegistrationsBaseUrl/3.6.0. Для вычисления полного дерева пакетов клиент пользуется только этим им, так что этого должно быть достаточно. Статья и так уже длинная, так что это я оставлю как упражнение для читателя.

Сколько потребуется места - я не знаю. Последний раз я это пробовал несколько лет назад, и тогда 4-х терабайтного диска мне не хватило. Количество пакетов с тех пор увеличилось в несколько раз. Наверняка, в наши дни общий объем перевалил далеко за 10 ТБ. Дерзайте.

Комментарии (9)


  1. rezdm
    17.10.2024 07:54

    Что, если:

    wget --mirror --convert-links --no-parent --no-clobber --execute robots=off --no-check-certificate --accept=nuget --reject=html --recursive --level=inf --domains=nuget.org --span-hosts --follow-tags=a https://www.nuget.org

    ну или lftp ну или ещё с полдюжины похожего

    Конечно, это все файлы с расширением nuget, как в заголовке заметки. Если есть желание только определённые версии -- это другой вопрос.


    1. andreishe Автор
      17.10.2024 07:54

      Существуют страницы для каждого пакета, но не на каждую такую страницу есть ссылка.

      Плюс, цель была - не привлекать внимание санитаров. Если сайт долбить запросами, сайт вам скоро отвечать станет неохотно.


      1. rezdm
        17.10.2024 07:54

        https://www.nuget.org/packages
        все тут.

        Не привлекать внимания -- не поможет, хотя меня терзают смутные сомнения, что MS режет траффик там.

        Самое правильное -- это настроить какой jfrog (ну или что там сейчас стильно-модно-молодёжно) проксей.


        1. andreishe Автор
          17.10.2024 07:54

          https://www.nuget.org/packages?page=1501&sortBy=relevance - попробуйте нажать next. Посчитайте, сколько вы перечислите таким образом.

          Кроме того, пакеты можно скрывать. На скрытые пакеты ссылок нет в принципе и в коде соответствующих страниц указаны инструкции поисковикам игнорировать их.


        1. andreishe Автор
          17.10.2024 07:54

          А в рейт-лимиты можно просто в браузере вляпаться, если слишком активно бегать по сайту:


  1. Mingun
    17.10.2024 07:54

    Меня вот что удивляет: разве клиент nuget — не опенсорсное решение? А если да, то почему в его коде не посмотреть, как он пакеты выкачивает или хотя бы ищет?


    1. andreishe Автор
      17.10.2024 07:54

      У клиента нет задачи перечислить все пакеты, кода, который это делает там просто не существует. Он пользуется RegistrationsBaseUrl/3.6.0 для получения метаданных пакетов, указанных как зависимости по идентификатору и версии пакета.

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


      1. Mingun
        17.10.2024 07:54

        Неужели в нём нет параметра типа --list — перечислить все пакеты? Я почему-то думал, что он входит в джентельменский набор менеджера пакетов.


        1. andreishe Автор
          17.10.2024 07:54

          Пакетов сотни тысяч (не считая индивидуальных версий, с ними - миллионы). Какой use case у этой команды был бы? У какого пакетного менеджера она есть?