jetbrain 插件本地化部署下载

背景

公司使用内外网隔离场景下,每次更新 IDEs 还要同时更新插件是很麻烦的。本人同时使用 Goland、PhpStorm、PyCharm(为啥不是装一个加其它语言插件?我不造啊),然后每个 IDEs 都要更新十几个插件,还不能批量更新,气死了!但人总是想偷懒的,不能偷懒那跟咸鱼有什么区别呢!动动手搞一个可以在线更新即可

原理

  • 自定义插件存储库 (ps 我说这个是当初始皇的插件发现的吗 zhili.io/updatePlugins.xml)

  • 根据插件 xmlid 查找到对应的下载地址,把相关的 jar、.zip、.blockmap.zip、.hash.json 以前下,然后组装 updatePlugins.xml

  • 在 IDE 中 填写 http://127.0.0.1/updatePlugins.xml 即可

代码

了解原理,就可以先这样,然后这样,最后这样就可以了,不对,还是直接上代码吧

package main

import (
	"bufio"
	"context"
	"encoding/xml"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/url"
	"os"
	"path"
	"path/filepath"
	"reflect"
	"strings"

	"github.com/google/go-querystring/query"
	"github.com/spf13/afero"
)

type (
	Loader interface {
		Load() ([]string, error)
	}
	Downloader interface {
		Download(ctx context.Context, id string) (*IdeaPlugin, error)
	}
	Exporter interface {
		Export(filename string, data any) error
	}
)

type txtLoader struct {
	fs       afero.Fs
	confPath string
}

func (t *txtLoader) Load() ([]string, error) {
	fd, err := os.Open(t.confPath)
	if err != nil {
		return nil, err
	}
	ids := make([]string, 0)
	reader := bufio.NewReader(fd)
	for {
		line, err := reader.ReadString('\n')
		if len(line) == 0 && err == io.EOF {
			break
		}
		if line == "" || !strings.HasPrefix(line, "http") {
			log.Println("read file ignore :", line)
			continue
		}
		parse, err := url.Parse(strings.Trim(line, "\r\n\t"))
		if err != nil {
			log.Printf("err for url %serror %s\n", line, err)
			continue
		}
		if parse.Path != "/plugin/index" {
			ids = append(ids, strings.ReplaceAll(parse.Path, "/plugin/", ""))
			continue
		}
		if v := parse.Query().Get("xmlId"); v != "" {
			ids = append(ids, v)
		}
	}
	return ids, nil
}

func NewTxtLoader(fs afero.Fs, configPath string) Loader {
	return &txtLoader{fs: fs, confPath: configPath}
}

type (
	PluginRepository struct {
		XMLName  xml.Name `xml:"plugin-repository"`
		FF       string   `xml:"ff"`
		Category Category `xml:"category"`
	}
	Category struct {
		Name       string       `xml:"name,attr"`
		IdeaPlugin []IdeaPlugin `xml:"idea-plugin"`
	}
	IdeaVersion struct {
		Min        string `xml:"min,attr"`
		Max        string `xml:"max,attr"`
		SinceBuild string `xml:"since-build,attr"`
		UntilBuild string `xml:"until-build,attr"`
	}
	ChangeNotes struct {
		ChangeNotes string `xml:",cdata"`
	}
	IdeaPlugin struct {
		Downloads   string      `xml:"downloads,attr"`
		Size        string      `xml:"size,attr"`
		Date        string      `xml:"date,attr"`
		UpdatedDate string      `xml:"updatedDate,attr"`
		URL         string      `xml:"url,attr"`
		Name        string      `xml:"name"`
		ID          string      `xml:"id"`
		Description string      `xml:"description"`
		Version     string      `xml:"version"`
		Vendor      string      `xml:"vendor"`
		Rating      string      `xml:"rating"`
		ChangeNotes ChangeNotes `xml:"change-notes"`
		IdeaVersion IdeaVersion `xml:"idea-version"`
	}
)

type jbDownload struct {
	fs    afero.Fs
	dist  string
	build string
}

func (d *jbDownload) Download(ctx context.Context, xid string) (*IdeaPlugin, error) {
	location, _, err := d.meta(ctx, xid, d.build)
	if err != nil {
		return nil, err
	}
	parse, err := url.Parse(location)
	if err != nil {
		return nil, err
	}
	updateId := parse.Query().Get("updateId")
	pluginId := parse.Query().Get("pluginId")

	detail, _, err := d.detail(ctx, pluginId, updateId)
	if err != nil {
		return nil, err
	}

	if err = d.downloadAll(ctx, parse.Path); err != nil {
		return nil, err
	}
	detail.URL = fmt.Sprintf("http://tools.sip.io/sis/jetbrains/plugins%s", parse.Path)
	return detail, nil
}

func (d *jbDownload) downloadAll(ctx context.Context, filename string) error {
	output := path.Join(d.dist, filename)

	err := d.fs.MkdirAll(filepath.Dir(output), os.ModeDir)
	if err != nil {
		return err
	}
	collection := []string{""}
	if strings.HasSuffix(filename, ".zip") {
		collection = append(collection, ".blockmap.zip", ".hash.json")
	}
	for _, suffix := range collection {
		fd, err := d.fs.Create(output + suffix)
		if err != nil {
			return err
		}
		resp, err := d.fetchFile(ctx, filename+suffix, fd)
		_ = fd.Close()
		if err != nil {
			if resp.StatusCode == http.StatusForbidden {
				log.Println("download 403 ", filename)
				_ = d.fs.RemoveAll(output + suffix)
				continue
			}
			return err
		}
	}
	return nil
}

func (d *jbDownload) addOptions(s string, opts interface{}) (string, error) {
	v := reflect.ValueOf(opts)
	if v.Kind() == reflect.Ptr && v.IsNil() {
		return s, nil
	}
	u, err := url.Parse(s)
	if err != nil {
		return s, err
	}
	qs, err := query.Values(opts)
	if err != nil {
		return s, err
	}
	u.RawQuery = qs.Encode()
	return u.String(), nil
}

func (d *jbDownload) buildRequest(ctx context.Context, method, path string, q any) (*http.Request, error) {
	u, err := d.addOptions("https://plugins.jetbrains.com"+path, q)
	if err != nil {
		return nil, err
	}
	return http.NewRequestWithContext(ctx, method, u, http.NoBody)
}

func (d *jbDownload) meta(ctx context.Context, id, build string) (string, *http.Response, error) {
	opts := struct {
		ID     string `url:"id"`
		Build  string `url:"build"`
		Action string `url:"action"`
	}{id, build, "download"}

	client := &http.Client{
		CheckRedirect: func(req *http.Request, via []*http.Request) error {
			return http.ErrUseLastResponse
		},
	}
	req, err := d.buildRequest(ctx, http.MethodHead, "/pluginManager", opts)
	if err != nil {
		return "", nil, err
	}
	resp, err := client.Do(req)
	if err != nil {
		return "", resp, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusMovedPermanently {
		return "", resp, fmt.Errorf("get id error by %s", resp.Request.URL.String())
	}
	return resp.Header.Get("Location"), resp, nil
}

func (d *jbDownload) detail(ctx context.Context, pluginId, updateId string) (*IdeaPlugin, *http.Response, error) {
	opts := struct {
		PluginID string `url:"pluginId"`
	}{pluginId}
	req, err := d.buildRequest(ctx, http.MethodGet, "/plugins/list", opts)
	if err != nil {
		return nil, nil, err
	}

	var p PluginRepository
	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		return nil, resp, err
	}
	defer resp.Body.Close()
	err = xml.NewDecoder(resp.Body).Decode(&p)
	if err != nil {
		return nil, resp, err
	}
	return &p.Category.IdeaPlugin[0], resp, nil
}

func (d *jbDownload) fetchFile(ctx context.Context, path string, w io.Writer) (*http.Response, error) {
	req, err := d.buildRequest(ctx, http.MethodGet, path, nil)
	if err != nil {
		return nil, err
	}
	client := &http.Client{}
	log.Printf("fetchFile %s\n", req.URL)

	resp, err := client.Do(req)
	if err != nil {
		return resp, err
	}
	defer resp.Body.Close()
	_, _ = io.Copy(w, resp.Body)
	return resp, nil
}

func NewJbDownload(fs afero.Fs, dist, build string) Downloader {
	_ = fs.RemoveAll(dist)
	return &jbDownload{fs: fs, dist: dist, build: build}
}

type repoExport struct {
	fs afero.Fs
}

func (e *repoExport) Export(filename string, data any) error {
	fd, err := e.fs.Create(filename)
	if err != nil {
		return err
	}
	encoder := xml.NewEncoder(fd)
	encoder.Indent("", "  ")
	return encoder.Encode(data)
}

func NewRepoExport(fs afero.Fs) Exporter {
	return &repoExport{fs: fs}
}

func main() {
	fs := afero.NewOsFs()
	xmlIds, err := NewTxtLoader(fs, "plugins_url.txt").Load()
	if err != nil {
		panic(err)
	}
	downloader := NewJbDownload(fs, "dist/plugins", "GO-241.14494.238")
	type plugins struct {
		Plugin []*IdeaPlugin `xml:"plugin"`
	}
	allPlugins := &plugins{}
	for _, id := range xmlIds {
		p, err := downloader.Download(context.Background(), id)
		if err != nil {
			log.Println("download eror", err)
			continue
		}
		allPlugins.Plugin = append(allPlugins.Plugin, p)
	}
	err = NewRepoExport(fs).Export("dist/plugins/updatePlugins.xml", allPlugins)
	if err != nil {
		panic(err)
	}
}

plugins_url.txt 文件格式

https://plugins.jetbrains.com/plugin/index?xmlId=com.chrisrm.idea.MaterialThemeUI
https://plugins.jetbrains.com/plugin/index?xmlId=izhangzhihao.rainbow.brackets
https://plugins.jetbrains.com/plugin/index?xmlId=com.intellij.tasks
https://plugins.jetbrains.com/plugin/index?xmlId=com.intellij.tasks.timeTracking
https://plugins.jetbrains.com/plugin/index?xmlId=org.toml.lang

最后

运行后在 dist/plugins 复制到内网即可 嘿嘿

9 个赞