Go로 APKPure 클라이언트 만들기

Go로 APKPure 클라이언트 만들기

Android 앱 자동화 도구를 만들다가, APK 파일을 자동으로 다운로드할 필요가 생겼다. Play Store는 인증이 복잡해서, APKPure를 사용하기로 했다.

Python으로 된 apkeep이 있지만, Go 프로젝트에 임베딩하고 싶어서 Go로 포팅했다.


APKPure 구조 분석

APKPure는 웹사이트와 API 두 가지 경로가 있다.

웹사이트 구조

https://apkpure.com/kr/instagram/com.instagram.android
                    ↑       ↑              ↑
                 locale   app name    package ID

버전 목록 페이지:

https://apkpure.com/kr/instagram/com.instagram.android/versions

다운로드 API

POST https://api.apkpure.com/v3/download_link
Content-Type: application/json

{
  "packageName": "com.instagram.android",
  "versionCode": 123456789
}

응답:

{
  "download_link": "https://download.apkpure.com/...",
  "file_size": 50000000
}

Go 클라이언트 구현

기본 구조

type Client struct {
    httpClient *http.Client
    options    DownloadOptions
}

type DownloadOptions struct {
    Arch      string // arm64-v8a, armeabi-v7a, x86_64
    Language  string // ko-KR, en-US
    OSVersion string // Android API level
}

func NewClient(opts DownloadOptions) *Client {
    return &Client{
        httpClient: &http.Client{Timeout: 30 * time.Second},
        options:    opts,
    }
}

버전 목록 가져오기

func (c *Client) ListVersions(packageID string) ([]Version, error) {
    url := fmt.Sprintf(
        "https://apkpure.com/%s/%s/versions",
        c.options.Language, packageID,
    )

    resp, err := c.httpClient.Get(url)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    doc, err := goquery.NewDocumentFromReader(resp.Body)
    if err != nil {
        return nil, err
    }

    var versions []Version
    doc.Find(".ver-item").Each(func(i int, s *goquery.Selection) {
        versions = append(versions, Version{
            Code: s.AttrOr("data-versioncode", ""),
            Name: s.Find(".ver-item-n").Text(),
            Size: s.Find(".ver-item-s").Text(),
        })
    })

    return versions, nil
}

goquery로 HTML 파싱. APKPure 페이지 구조가 바뀌면 깨질 수 있다.

다운로드 링크 획득

func (c *Client) GetDownloadLink(packageID string, versionCode string) (string, error) {
    payload := map[string]string{
        "packageName": packageID,
        "versionCode": versionCode,
    }

    body, _ := json.Marshal(payload)
    resp, err := c.httpClient.Post(
        "https://api.apkpure.com/v3/download_link",
        "application/json",
        bytes.NewReader(body),
    )
    if err != nil {
        return "", err
    }
    defer resp.Body.Close()

    var result struct {
        DownloadLink string `json:"download_link"`
    }
    json.NewDecoder(resp.Body).Decode(&result)

    return result.DownloadLink, nil
}

파일 다운로드 (프로그레스 지원)

func (c *Client) Download(url string, dest string, progress func(int64, int64)) error {
    resp, err := c.httpClient.Get(url)
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    file, err := os.Create(dest)
    if err != nil {
        return err
    }
    defer file.Close()

    totalSize := resp.ContentLength
    var downloaded int64

    buf := make([]byte, 32*1024)
    for {
        n, err := resp.Body.Read(buf)
        if n > 0 {
            file.Write(buf[:n])
            downloaded += int64(n)
            if progress != nil {
                progress(downloaded, totalSize)
            }
        }
        if err == io.EOF {
            break
        }
        if err != nil {
            return err
        }
    }

    return nil
}

XAPK/Split APK 처리

최신 앱은 Split APK 형태로 배포된다. APKPure는 이걸 XAPK라는 형식으로 묶어서 제공.

XAPK 구조

app.xapk (ZIP 파일)
├── manifest.json
├── base.apk
├── config.arm64_v8a.apk
├── config.ko.apk
└── config.xxhdpi.apk

XAPK 처리

func ExtractXAPK(xapkPath string, destDir string) ([]string, error) {
    reader, err := zip.OpenReader(xapkPath)
    if err != nil {
        return nil, err
    }
    defer reader.Close()

    var apks []string
    for _, file := range reader.File {
        if strings.HasSuffix(file.Name, ".apk") {
            destPath := filepath.Join(destDir, file.Name)
            extractFile(file, destPath)
            apks = append(apks, destPath)
        }
    }

    return apks, nil
}

CLI 도구

func main() {
    app := &cli.App{
        Name:  "apkpure",
        Usage: "Download APKs from APKPure",
        Commands: []*cli.Command{
            {
                Name:  "download",
                Usage: "Download an APK",
                Flags: []cli.Flag{
                    &cli.StringFlag{Name: "app", Aliases: []string{"a"}, Required: true},
                    &cli.StringFlag{Name: "arch", Value: "arm64-v8a"},
                },
                Action: func(c *cli.Context) error {
                    client := apkpure.NewClient(apkpure.DownloadOptions{
                        Arch: c.String("arch"),
                    })

                    return client.DownloadLatest(
                        c.String("app"),
                        c.Args().First(),
                    )
                },
            },
            {
                Name:  "list",
                Usage: "List available versions",
                Flags: []cli.Flag{
                    &cli.StringFlag{Name: "app", Aliases: []string{"a"}, Required: true},
                },
                Action: func(c *cli.Context) error {
                    client := apkpure.NewClient(apkpure.DownloadOptions{})
                    versions, err := client.ListVersions(c.String("app"))
                    if err != nil {
                        return err
                    }
                    for _, v := range versions {
                        fmt.Printf("%s\t%s\t%s\n", v.Code, v.Name, v.Size)
                    }
                    return nil
                },
            },
        },
    }

    app.Run(os.Args)
}

사용 예시

# 최신 버전 다운로드
apkpure -a com.instagram.android ./downloads

# 특정 버전 다운로드
apkpure -a [email protected] ./downloads

# 버전 목록
apkpure list -a com.instagram.android

라이브러리로 사용

import "github.com/kyungw00k/apkpure-go/pkg/apkpure"

func main() {
    client := apkpure.NewClient(apkpure.DownloadOptions{
        Arch:     "arm64-v8a",
        Language: "ko-KR",
    })

    // 최신 버전 다운로드
    err := client.DownloadLatest("com.instagram.android", "./instagram.apk")

    // 프로그레스 콜백
    err = client.DownloadWithProgress(
        "com.instagram.android",
        "./instagram.apk",
        func(downloaded, total int64) {
            fmt.Printf("\r%.1f%%", float64(downloaded)/float64(total)*100)
        },
    )
}

삽질 포인트

문제 해결
HTML 구조 변경 goquery 셀렉터를 유연하게
XAPK 형식 ZIP으로 처리
Rate limiting 요청 간 딜레이
아키텍처별 APK options에 arch 지정

결론

APKPure API는 공식 문서가 없어서 리버스 엔지니어링이 필요했다.

Go로 만들어서:

공식 API가 없으면 직접 만들면 된다.

Back