├── .github └── workflows │ ├── codeql-analysis.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── cmd └── fetch │ ├── .gitignore │ ├── README.md │ ├── build.go │ ├── build.sh │ ├── collector.go │ ├── fetch.go │ ├── go.mod │ ├── go.sum │ └── main.go ├── cnregion.go ├── cnregion_test.go ├── data ├── embed.go ├── embed_test.go └── regions.db ├── db.go ├── db_test.go ├── districts.go ├── districts_test.go ├── go.mod ├── go.sum ├── id ├── id.go └── id_test.go ├── region.go ├── region_test.go ├── search.go ├── search_test.go └── version ├── version.go └── version_test.go /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | name: "CodeQL" 7 | 8 | on: 9 | push: 10 | branches: [master] 11 | pull_request: 12 | # The branches below must be a subset of the branches above 13 | branches: [master] 14 | schedule: 15 | - cron: '0 16 * * 5' 16 | 17 | jobs: 18 | analyze: 19 | name: Analyze 20 | runs-on: ubuntu-latest 21 | 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | # Override automatic language detection by changing the below list 26 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 27 | language: ['go'] 28 | # Learn more... 29 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 30 | 31 | steps: 32 | - name: Checkout repository 33 | uses: actions/checkout@v4 34 | with: 35 | # We must fetch at least the immediate parents so that if this is 36 | # a pull request then we can checkout the head. 37 | fetch-depth: 2 38 | 39 | # Initializes the CodeQL tools for scanning. 40 | - name: Initialize CodeQL 41 | uses: github/codeql-action/init@v3 42 | with: 43 | languages: ${{ matrix.language }} 44 | # If you wish to specify custom queries, you can do so here or in a config file. 45 | # By default, queries listed here will override any specified in a config file. 46 | # Prefix the list here with "+" to use these queries and those in the config file. 47 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 48 | 49 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 50 | # If this step fails, then you should remove it and run the build manually (see below) 51 | - name: Autobuild 52 | uses: github/codeql-action/autobuild@v3 53 | 54 | # ℹ️ Command-line programs to run using the OS shell. 55 | # 📚 https://git.io/JvXDl 56 | 57 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 58 | # and modify them (or add more) to build your code if your project 59 | # uses a compiled language 60 | 61 | #- run: | 62 | # make bootstrap 63 | # make release 64 | 65 | - name: Perform CodeQL Analysis 66 | uses: github/codeql-action/analyze@v3 67 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | 6 | test: 7 | name: Test 8 | runs-on: ${{ matrix.os }} 9 | 10 | strategy: 11 | matrix: 12 | os: [ubuntu-latest, macOS-latest, windows-latest] 13 | go: ['1.21.x', '1.23.x'] 14 | 15 | steps: 16 | 17 | - name: Set git to use LF 18 | run: | 19 | git config --global core.autocrlf false 20 | git config --global core.eol lf 21 | 22 | - name: Check out code into the Go module directory 23 | uses: actions/checkout@v4 24 | 25 | - name: Set up Go ${{ matrix.go }} 26 | uses: actions/setup-go@v5 27 | with: 28 | go-version: ${{ matrix.go }} 29 | id: go 30 | 31 | - name: Vet 32 | run: go vet -v ./... 33 | 34 | - name: Test 35 | run: go test -v -coverprofile='coverage.txt' -covermode=atomic ./... 36 | 37 | - name: Upload Coverage report 38 | uses: codecov/codecov-action@v4 39 | with: 40 | token: ${{secrets.CODECOV_TOKEN}} 41 | file: ./coverage.txt 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | .vscode/ 18 | .idea/ 19 | *.swp 20 | .DS_Store 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 caixw 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cnregion 2 | 3 | [![Test](https://github.com/issue9/cnregion/workflows/Test/badge.svg)](https://github.com/issue9/cnregion/actions?query=workflow%3ATest) 4 | [![Go version](https://img.shields.io/github/go-mod/go-version/issue9/cnregion)](https://golang.org) 5 | [![PkgGoDev](https://pkg.go.dev/badge/github.com/issue9/cnregion)](https://pkg.go.dev/github.com/issue9/cnregion/v2) 6 | [![codecov](https://codecov.io/gh/issue9/cnregion/branch/master/graph/badge.svg)](https://codecov.io/gh/issue9/cnregion) 7 | ![License](https://img.shields.io/github/license/issue9/cnregion) 8 | 9 | 历年统计用区域和城乡划分代码,数据来源于 。 10 | 符合国家标准 GB/T 2260 与 GB/T 10114。 11 | 12 | 关于版本号,主版本号代码不兼容性更改,次版本号代码最后一次生成的数据年份,BUG 修正和兼容性的功能增加则增加修订版本号。 13 | 14 | ```go 15 | v, err := cnregion.LoadFile("./data/regions.db", "-", 2020) 16 | 17 | p := v.Provinces() // 返回所有省列表 18 | cities := p[0].Items() // 返回该省下的所有市 19 | counties := cities[0].Items() // 返回该市下的所有县 20 | towns := counties[0].Items() // 返回所有镇 21 | villages := towns[0].Items() // 所有村和街道信息 22 | 23 | d := v.Districts() // 按以前的行政大区进行划分 24 | provinces := d[0].Items() // 该大区下的所有省份 25 | 26 | list := v.Search(&SearchOptions{Text: "温州"}) // 按索地名中带温州的区域列表 27 | ``` 28 | 29 | 对采集的数据进行了一定的加工,以减少文件的体积,文件保存在 `./data/regions.db` 中。 30 | 31 | ## 安装 32 | 33 | ```shell 34 | go get github.com/issue9/cnregion/v2 35 | ``` 36 | 37 | ## 版权 38 | 39 | 本项目采用 [MIT](https://opensource.org/licenses/MIT) 开源授权许可证,完整的授权说明可在 [LICENSE](LICENSE) 文件中找到。 40 | -------------------------------------------------------------------------------- /cmd/fetch/.gitignore: -------------------------------------------------------------------------------- 1 | fetch 2 | 3 | data/ 4 | caches/ -------------------------------------------------------------------------------- /cmd/fetch/README.md: -------------------------------------------------------------------------------- 1 | # fetch 2 | 3 | 时间比较漫长,一年份的数据估计在 0.5 天左右。如果某个省的数据出错,会自动忽略该省的所有数据,下次再运行即可。 4 | 5 | 调整 colly limit 或是多协程运行 fetchTown 可以一定程序上提交效率。 6 | 7 | 如果出错,可以在执行完一轮之后重新再执行一次,会自动拉取有错误的数据。 8 | 9 | 拉取数据: 10 | ` 11 | fetch fetch -years=2003,2004 12 | ` 13 | 14 | 生成数据: 15 | ` 16 | fetch build -output=../data -data=./data 17 | ` 18 | -------------------------------------------------------------------------------- /cmd/fetch/build.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2021-2024 caixw 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | package main 6 | 7 | import ( 8 | "bufio" 9 | "bytes" 10 | "fmt" 11 | "os" 12 | "path/filepath" 13 | "strconv" 14 | "strings" 15 | 16 | "github.com/issue9/cnregion/v2" 17 | "github.com/issue9/cnregion/v2/version" 18 | ) 19 | 20 | func build(dataDir, output string, years ...int) error { 21 | d := cnregion.NewDB() 22 | 23 | if len(years) == 0 { 24 | years = version.All() 25 | } 26 | for _, year := range years { 27 | if err := buildYear(d, dataDir, year); err != nil { 28 | return err 29 | } 30 | } 31 | 32 | return d.Dump(output, true) 33 | } 34 | 35 | func buildYear(d *cnregion.DB, dataDir string, year int) error { 36 | fmt.Printf("\n添加 %d 的数据\n", year) 37 | if !d.AddVersion(year) { 38 | fmt.Printf("已经存在该年份 %d 的数据\n\n", year) 39 | return nil 40 | } 41 | 42 | y := strconv.Itoa(year) 43 | dataDir = filepath.Join(dataDir, y) 44 | 45 | return filepath.Walk(dataDir, func(path string, info os.FileInfo, err error) error { 46 | if err != nil { 47 | return err 48 | } 49 | 50 | if info.IsDir() { 51 | return nil 52 | } 53 | 54 | if info.Name()[0] == '.' { // 忽略隐藏文件 55 | return nil 56 | } 57 | 58 | data, err := os.ReadFile(path) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | s := bufio.NewScanner(bytes.NewBuffer(data)) 64 | s.Split(bufio.ScanLines) 65 | for s.Scan() { 66 | txt := s.Text() 67 | values := strings.Split(txt, "\t") 68 | if len(values) != 2 { 69 | return fmt.Errorf("无效的格式,位于 %s:%s", path, txt) 70 | } 71 | id, name := values[0], values[1] 72 | 73 | if err := d.AddItem(id, name, year); err != nil { 74 | return err 75 | } 76 | } 77 | 78 | return nil 79 | }) 80 | } 81 | -------------------------------------------------------------------------------- /cmd/fetch/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | go build -v ./ 4 | unlink ../../data/regions.db 5 | ./fetch build -output=../../data/regions.db -data=./data -------------------------------------------------------------------------------- /cmd/fetch/collector.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2021-2024 caixw 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | package main 6 | 7 | import ( 8 | "fmt" 9 | "os" 10 | "regexp" 11 | "sort" 12 | "strings" 13 | "sync" 14 | "time" 15 | 16 | "github.com/gocolly/colly/v2" 17 | "github.com/issue9/errwrap" 18 | "github.com/issue9/sliceutil" 19 | "github.com/issue9/term/v3/colors" 20 | ) 21 | 22 | const userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.99 Safari/537.36" 23 | 24 | // 以省为单位的文件内容管理 25 | type provinceFile struct { 26 | lock *sync.Mutex 27 | items []*item 28 | path string 29 | } 30 | 31 | type item struct { 32 | id string // 区域 ID 33 | href string // href 的属性值,仅存在于中间过程 34 | text string 35 | ignore bool // 忽略此条数据 36 | } 37 | 38 | func newProvinceFile(path string) *provinceFile { 39 | return &provinceFile{ 40 | path: path, 41 | lock: &sync.Mutex{}, 42 | items: make([]*item, 0, 50000), 43 | } 44 | } 45 | 46 | func (fs *provinceFile) append(text, id string) { 47 | if id == text { 48 | return 49 | } 50 | 51 | if id == "" || text == "" { 52 | panic(fmt.Sprintf("数据不能为空%s:%s", id, text)) 53 | } 54 | 55 | fs.lock.Lock() 56 | defer fs.lock.Unlock() 57 | fs.items = append(fs.items, &item{text: text, id: id}) 58 | } 59 | 60 | func (fs *provinceFile) dump() error { 61 | fmt.Printf("去重(%d)...\n", len(fs.items)) 62 | fs.items = sliceutil.Unique(fs.items, func(i, j *item) bool { return i.id == j.id }) 63 | 64 | fmt.Printf("排序(%d)...\n", len(fs.items)) 65 | sort.SliceStable(fs.items, func(i, j int) bool { return fs.items[i].id < fs.items[j].id }) 66 | 67 | fmt.Println(colorsSprintf(colors.Green, "准备将 %d 条数据写入 %s\n", len(fs.items), fs.path)) 68 | 69 | buf := errwrap.Buffer{} 70 | for _, item := range fs.items { 71 | buf.Printf("%s\t%s\n", item.id, item.text) 72 | } 73 | if buf.Err != nil { 74 | return buf.Err 75 | } 76 | 77 | if err := os.WriteFile(fs.path, buf.Bytes(), os.ModePerm); err != nil { 78 | return err 79 | } 80 | 81 | colors.Printf(colors.Normal, colors.Green, colors.Default, "写入 %s 完成\n\n", fs.path) 82 | return nil 83 | } 84 | 85 | func buildCollector(base string) (*colly.Collector, error) { 86 | expr := base + "[0-9/]*.html" 87 | c := colly.NewCollector( 88 | colly.URLFilters( 89 | regexp.MustCompile(base), 90 | regexp.MustCompile(expr), 91 | ), 92 | colly.UserAgent(userAgent), 93 | colly.DetectCharset(), 94 | colly.AllowURLRevisit(), 95 | colly.CacheDir("./caches"), 96 | ) 97 | 98 | rule := &colly.LimitRule{Parallelism: 100, DomainGlob: "*", Delay: time.Second} 99 | if err := c.Limit(rule); err != nil { 100 | return nil, err 101 | } 102 | 103 | c.OnRequest(func(r *colly.Request) { 104 | fmt.Printf("抓取 %s\n", r.URL) 105 | }) 106 | 107 | c.OnError(func(resp *colly.Response, err error) { 108 | colors.Printf(colors.Normal, colors.Red, colors.Default, "ERROR: %s 并返回状态码 %d\n", err, resp.StatusCode) 109 | 110 | // 重试 111 | if err := c.Visit(resp.Request.URL.String()); err != nil { 112 | colors.Printf(colors.Normal, colors.Red, colors.Default, "ERROR: %s at visit %s\n", err, resp.Request.URL.String()) 113 | } 114 | }) 115 | 116 | c.OnResponse(func(r *colly.Response) { 117 | if len(r.Body) == 0 { 118 | colors.Printf(colors.Normal, colors.Red, colors.Default, "页面 %s 没有数据\n", r.Request.URL.String()) 119 | } 120 | }) 121 | 122 | return c, nil 123 | } 124 | 125 | func firstID(href string) string { 126 | href = strings.TrimSuffix(href, ".html") 127 | index := strings.IndexByte(href, '/') 128 | if index <= 0 { 129 | return strings.TrimSuffix(href, ".html") 130 | } 131 | return href[:index] 132 | } 133 | -------------------------------------------------------------------------------- /cmd/fetch/fetch.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2021-2024 caixw 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | package main 6 | 7 | import ( 8 | "errors" 9 | "fmt" 10 | "io" 11 | "os" 12 | "path/filepath" 13 | "regexp" 14 | "strconv" 15 | "strings" 16 | "time" 17 | 18 | "github.com/gocolly/colly/v2" 19 | "github.com/issue9/cnregion/v2/version" 20 | "github.com/issue9/term/v3/colors" 21 | ) 22 | 23 | const baseURL = "https://www.stats.gov.cn/sj/tjbz/tjyqhdmhcxhfdm/" 24 | 25 | var digit = regexp.MustCompile("[0-9]+") 26 | 27 | var errNoData = errors.New("no data") 28 | 29 | // 拉取指定年份的数据 30 | // 31 | // years 为指定的一个或多个年份,如果为空,则表示所有的年份。 32 | // 年份时间为 http://www.stats.gov.cn/tjsj/tjbz/tjyqhdmhcxhfdm/ 33 | // 上存在的时间,从 2009 开始,到当前年份的上一年。 34 | func fetch(dir string, interval time.Duration, years ...int) error { 35 | if len(years) == 0 { 36 | years = version.All() 37 | } 38 | 39 | if err := os.MkdirAll(dir, os.ModePerm); err != nil { 40 | return err 41 | } 42 | 43 | fmt.Printf("拉取以下年份:%v\n", colorsSprint(colors.Green, years)) 44 | for _, year := range years { 45 | if err := fetchYear(dir, interval, year); err != nil { 46 | return err 47 | } 48 | } 49 | return nil 50 | } 51 | 52 | func fetchYear(dir string, interval time.Duration, year int) error { 53 | if !version.IsValid(year) { 54 | return version.ErrInvalidYear 55 | } 56 | 57 | fmt.Printf("准备拉取 %s 的数据\n", colorsSprint(colors.Green, year)) 58 | 59 | y := strconv.Itoa(year) 60 | dir = filepath.Join(dir, y) // 带年份的目录 61 | if err := os.MkdirAll(dir, os.ModePerm); err != nil { 62 | return err 63 | } 64 | 65 | base := baseURL + y + "/" // 带年份地址的 URL 66 | c, err := buildCollector(base) 67 | if err != nil { 68 | return err 69 | } 70 | 71 | provinces := make([]*item, 0, 50) 72 | c.OnHTML(".provincetable .provincetr td a", func(e *colly.HTMLElement) { 73 | href := strings.TrimSuffix(e.Attr("href"), ".html") 74 | ignore := exists(filepath.Join(dir, href+".txt")) 75 | provinces = append(provinces, &item{ 76 | href: href + ".html", 77 | text: e.Text, 78 | ignore: ignore, 79 | id: href + strings.Repeat("0", 10), 80 | }) 81 | 82 | state := colorsSprint(colors.Red, "\t未完成") 83 | if ignore { 84 | state = colorsSprint(colors.Green, "\t已完成") 85 | } 86 | fmt.Println(href, e.Text, state) 87 | }) 88 | 89 | if err := c.Visit(base); err != nil { 90 | return err 91 | } 92 | c.Wait() 93 | if len(provinces) == 0 { 94 | return fmt.Errorf("未获取到 %s 年的省级数据", y) 95 | } 96 | fmt.Println(colorsSprintf(colors.Green, "拉取 %d 年份的省级数据完成,总共 %d 条\n", year, len(provinces))) 97 | 98 | f, err := os.Create(dir + "/../" + y + "-error.log") 99 | if err != nil { 100 | return err 101 | } 102 | f.WriteString("此文件记录错误信息\n") 103 | 104 | for _, province := range provinces { 105 | if province.ignore { 106 | fmt.Println(colorsSprint(colors.Green, province.text, "\t已完成")) 107 | continue 108 | } 109 | 110 | if err := fetchProvince(f, dir, base, province); err != nil { 111 | // 出错就忽略这个省份的输出,继续下一个省的。 112 | fmt.Println(colorsSprint(colors.Red, err)) 113 | f.WriteString(y) 114 | f.WriteString("\t") 115 | f.WriteString(err.Error()) 116 | f.WriteString("\n\n") 117 | } 118 | time.Sleep(interval) 119 | } 120 | 121 | return f.Close() 122 | } 123 | 124 | // base 格式: https://example.com/2022/ 到年份为止的数据 125 | func fetchProvince(f io.Writer, dir, base string, p *item) error { 126 | fs := newProvinceFile(filepath.Join(dir, strings.TrimSuffix(p.href, ".html")+".txt")) 127 | fs.append(p.text, p.id) // 加入省级标记 128 | 129 | c, err := buildCollector(base) 130 | if err != nil { 131 | return err 132 | } 133 | 134 | cities := make([]*item, 0, 500) 135 | c.OnHTML(".citytable .citytr", func(e *colly.HTMLElement) { 136 | cities = append(cities, getItem(e)) 137 | }) 138 | 139 | if err := c.Visit(base + p.href); err != nil { 140 | return err 141 | } 142 | c.Wait() 143 | 144 | if len(cities) == 0 { 145 | return fmt.Errorf("未获取到 %s:%s 的市级数据", p.id, p.text) 146 | } 147 | fmt.Println(colorsSprintf(colors.Green, "拉取 %s 的市级数据完成,总共 %d 条\n", p.text, len(cities))) 148 | 149 | for _, city := range cities { 150 | if digit.MatchString(city.text) { 151 | continue 152 | } 153 | 154 | fs.append(city.text, city.id) 155 | if city.href == "" { 156 | continue 157 | } 158 | err = fetchCity(f, fs, base, city) 159 | switch { 160 | case errors.Is(err, errNoData): 161 | if err1 := fetchCounty(f, fs, base, city); err1 != nil { // 广东省 东莞 162 | if errors.Is(err1, errNoData) { 163 | err1 = fmt.Errorf("未获取到 %s:%s 的县/乡镇数据", city.id, city.text) 164 | } 165 | return err1 166 | } 167 | case err != nil: 168 | return err 169 | } 170 | } 171 | 172 | return fs.dump() 173 | } 174 | 175 | func fetchCity(f io.Writer, fs *provinceFile, base string, p *item) error { 176 | c, err := buildCollector(base) 177 | if err != nil { 178 | return err 179 | } 180 | 181 | counties := make([]*item, 0, 500) 182 | c.OnHTML(".countytable .countytr", func(e *colly.HTMLElement) { 183 | counties = append(counties, getItem(e)) 184 | }) 185 | 186 | if err := c.Visit(base + p.href); err != nil { 187 | return err 188 | } 189 | c.Wait() 190 | 191 | if len(counties) == 0 { 192 | return errNoData 193 | } 194 | fmt.Println(colorsSprintf(colors.Green, "拉取 %s 的县级数据完成,总共 %d 条\n", p.text, len(counties))) 195 | 196 | for _, county := range counties { 197 | if digit.MatchString(county.text) { 198 | continue 199 | } 200 | 201 | fs.append(county.text, county.id) 202 | if county.href == "" { 203 | continue 204 | } 205 | if err := fetchCounty(f, fs, base+firstID(p.href)+"/", county); err != nil { 206 | return err 207 | } 208 | } 209 | return nil 210 | } 211 | 212 | func fetchCounty(f io.Writer, fs *provinceFile, base string, p *item) error { 213 | c, err := buildCollector(base) 214 | if err != nil { 215 | return err 216 | } 217 | 218 | towns := make([]*item, 0, 500) 219 | c.OnHTML(".towntable .towntr", func(e *colly.HTMLElement) { 220 | towns = append(towns, getItem(e)) 221 | }) 222 | 223 | c.OnHTML(".countytable .towntr", func(e *colly.HTMLElement) { // 2021 之后的东莞等 224 | towns = append(towns, getItem(e)) 225 | }) 226 | 227 | // http://www.stats.gov.cn/tjsj/tjbz/tjyqhdmhcxhfdm/2014/46/4602.html 228 | c.OnHTML(".countytable .countytr", func(e *colly.HTMLElement) { 229 | towns = append(towns, getItem(e)) 230 | }) 231 | 232 | if err := c.Visit(base + p.href); err != nil { 233 | return err 234 | } 235 | c.Wait() 236 | 237 | if len(towns) == 0 { 238 | // 2014 460201 239 | io.WriteString(f, fmt.Sprintf("%s 返回乡镇数据为空,请确认该内容是否正常\n\n", base+p.href)) 240 | return nil 241 | } 242 | fmt.Println(colorsSprintf(colors.Green, "拉取 %s 的乡镇数据完成,总共 %d 条\n", p.text, len(towns))) 243 | 244 | for _, town := range towns { 245 | if digit.MatchString(town.text) { 246 | continue 247 | } 248 | 249 | fs.append(town.text, town.id) 250 | if town.href == "" { 251 | continue 252 | } 253 | if err := fetchTown(f, fs, base+firstID(p.href)+"/", town); err != nil { 254 | return err 255 | } 256 | } 257 | return nil 258 | } 259 | 260 | func fetchTown(f io.Writer, fs *provinceFile, base string, p *item) error { 261 | c, err := buildCollector(base) 262 | if err != nil { 263 | return err 264 | } 265 | 266 | var count int 267 | c.OnHTML(".villagetable .villagetr", func(e *colly.HTMLElement) { 268 | var id, text string 269 | e.ForEach("td", func(i int, elem *colly.HTMLElement) { 270 | if i == 0 { 271 | id = elem.Text 272 | } else if i == 2 { 273 | text = elem.Text 274 | } 275 | }) 276 | count++ 277 | fs.append(text, id) 278 | }) 279 | 280 | if err := c.Visit(base + p.href); err != nil { 281 | return err 282 | } 283 | c.Wait() 284 | 285 | if count == 0 { 286 | // 街道可以为空,比如: 287 | // http://www.stats.gov.cn/tjsj/tjbz/tjyqhdmhcxhfdm/2015/34/01/11/340111009.html 288 | io.WriteString(f, fmt.Sprintf("%s 返回空数据,请确认该内容是否正常\n\n", base+p.href)) 289 | return nil 290 | } 291 | fmt.Print(colorsSprintf(colors.Green, "拉取 %s 的街道数据完成,总共 %d 条\n", p.text, count)) 292 | return nil 293 | } 294 | 295 | func getItem(e *colly.HTMLElement) *item { 296 | p := &item{} 297 | e.ForEach("td", func(i int, elem *colly.HTMLElement) { 298 | if i == 0 { 299 | elem.ForEach("a", func(j int, child *colly.HTMLElement) { 300 | if j > 1 { 301 | return 302 | } 303 | 304 | p.href = child.Attr("href") 305 | p.id = child.Text 306 | }) 307 | 308 | if p.id == "" { 309 | p.href = "" 310 | p.id = elem.Text 311 | } 312 | } else if i == 1 { 313 | elem.ForEach("a", func(j int, child *colly.HTMLElement) { 314 | if j > 1 { 315 | return 316 | } 317 | 318 | p.text = child.Text 319 | }) 320 | if p.text == "" { 321 | p.text = elem.Text 322 | } 323 | } 324 | }) 325 | 326 | return p 327 | } 328 | -------------------------------------------------------------------------------- /cmd/fetch/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/issue9/cnregion/cmd/fetch 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/gocolly/colly/v2 v2.1.0 7 | github.com/issue9/cmdopt v0.13.1 8 | github.com/issue9/cnregion/v2 v2.2023.0 9 | github.com/issue9/errwrap v0.3.2 10 | github.com/issue9/sliceutil v0.17.0 11 | github.com/issue9/term/v3 v3.3.2 12 | ) 13 | 14 | require ( 15 | github.com/PuerkitoBio/goquery v1.8.0 // indirect 16 | github.com/andybalholm/cascadia v1.3.1 // indirect 17 | github.com/antchfx/htmlquery v1.3.0 // indirect 18 | github.com/antchfx/xmlquery v1.3.15 // indirect 19 | github.com/antchfx/xpath v1.2.3 // indirect 20 | github.com/gobwas/glob v0.2.3 // indirect 21 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 22 | github.com/golang/protobuf v1.5.2 // indirect 23 | github.com/kennygrant/sanitize v1.2.4 // indirect 24 | github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect 25 | github.com/temoto/robotstxt v1.1.2 // indirect 26 | golang.org/x/net v0.5.0 // indirect 27 | golang.org/x/sys v0.26.0 // indirect 28 | golang.org/x/text v0.6.0 // indirect 29 | google.golang.org/appengine v1.6.7 // indirect 30 | google.golang.org/protobuf v1.28.1 // indirect 31 | ) 32 | 33 | replace github.com/issue9/cnregion/v2 => ../../ 34 | -------------------------------------------------------------------------------- /cmd/fetch/go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 3 | github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= 4 | github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U= 5 | github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= 6 | github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= 7 | github.com/andybalholm/cascadia v1.2.0/go.mod h1:YCyR8vOZT9aZ1CHEd8ap0gMVm2aFgxBp0T0eFw1RUQY= 8 | github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c= 9 | github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= 10 | github.com/antchfx/htmlquery v1.2.3/go.mod h1:B0ABL+F5irhhMWg54ymEZinzMSi0Kt3I2if0BLYa3V0= 11 | github.com/antchfx/htmlquery v1.3.0 h1:5I5yNFOVI+egyia5F2s/5Do2nFWxJz41Tr3DyfKD25E= 12 | github.com/antchfx/htmlquery v1.3.0/go.mod h1:zKPDVTMhfOmcwxheXUsx4rKJy8KEY/PU6eXr/2SebQ8= 13 | github.com/antchfx/xmlquery v1.2.4/go.mod h1:KQQuESaxSlqugE2ZBcM/qn+ebIpt+d+4Xx7YcSGAIrM= 14 | github.com/antchfx/xmlquery v1.3.15 h1:aJConNMi1sMha5G8YJoAIF5P+H+qG1L73bSItWHo8Tw= 15 | github.com/antchfx/xmlquery v1.3.15/go.mod h1:zMDv5tIGjOxY/JCNNinnle7V/EwthZ5IT8eeCGJKRWA= 16 | github.com/antchfx/xpath v1.1.6/go.mod h1:Yee4kTMuNiPYJ7nSNorELQMr1J33uOpXDMByNYhvtNk= 17 | github.com/antchfx/xpath v1.1.8/go.mod h1:Yee4kTMuNiPYJ7nSNorELQMr1J33uOpXDMByNYhvtNk= 18 | github.com/antchfx/xpath v1.2.3 h1:CCZWOzv5bAqjVv0offZ2LVgVYFbeldKQVuLNbViZdes= 19 | github.com/antchfx/xpath v1.2.3/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= 20 | github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= 21 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 22 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 23 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 24 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 25 | github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 26 | github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= 27 | github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= 28 | github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= 29 | github.com/gocolly/colly v1.2.0/go.mod h1:Hof5T3ZswNVsOHYmba1u03W65HDWgpV5HifSuueE0EA= 30 | github.com/gocolly/colly/v2 v2.1.0 h1:k0DuZkDoCsx51bKpRJNEmcxcp+W5N8ziuwGaSDuFoGs= 31 | github.com/gocolly/colly/v2 v2.1.0/go.mod h1:I2MuhsLjQ+Ex+IzK3afNS8/1qP3AedHOusRPcRdC5o0= 32 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 33 | github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 34 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= 35 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 36 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 37 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 38 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 39 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 40 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 41 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 42 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 43 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 44 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 45 | github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= 46 | github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 47 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 48 | github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= 49 | github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 50 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 51 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 52 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 53 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 54 | github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= 55 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 56 | github.com/issue9/assert/v4 v4.3.1 h1:dHYODk1yV7j/1baIB6K6UggI4r1Hfuljqic7PaDbwLg= 57 | github.com/issue9/assert/v4 v4.3.1/go.mod h1:v7qDRXi7AsaZZNh8eAK2rkLJg5/clztqQGA1DRv9Lv4= 58 | github.com/issue9/cmdopt v0.13.1 h1:VA/Hgd92NBbZyHjZx1xcRCMhoc+XjI1LWhiuZkOZ0VU= 59 | github.com/issue9/cmdopt v0.13.1/go.mod h1:7lnF45Ush0boi4/nDjeSDYV5lZfoOzp3jm+biJr0f4g= 60 | github.com/issue9/errwrap v0.3.2 h1:7KEme9Pfe75M+sIMcPCn/DV90wjnOcRbO4DXVAHj3Fw= 61 | github.com/issue9/errwrap v0.3.2/go.mod h1:KcCLuUGiffjooLCUjL89r1cyO8/HT/VRcQrneO53N3A= 62 | github.com/issue9/sliceutil v0.17.0 h1:EtmVlldkAyGxS0O2TnxcAu3pgLJw74I5eh9DHT7TOZs= 63 | github.com/issue9/sliceutil v0.17.0/go.mod h1:CVpH4f228pICY7JImlztUPe/zf2w0EicEleU9YfFe00= 64 | github.com/issue9/term/v3 v3.3.2 h1:heyRhfA6EMztqh5UHUZyKr/LatFZXbJ7YhE5RI5fPTs= 65 | github.com/issue9/term/v3 v3.3.2/go.mod h1:n5XiDESqvPeaLgHeG7H0JdIXU8/H5IZPEaH++0aHE5Q= 66 | github.com/jawher/mow.cli v1.1.0/go.mod h1:aNaQlc7ozF3vw6IJ2dHjp2ZFiA4ozMIYY6PyuRJwlUg= 67 | github.com/kennygrant/sanitize v1.2.4 h1:gN25/otpP5vAsO2djbMhF/LQX6R7+O1TB4yv8NzpJ3o= 68 | github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak= 69 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 70 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 71 | github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 72 | github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU= 73 | github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA= 74 | github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU= 75 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 76 | github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= 77 | github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= 78 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 79 | github.com/temoto/robotstxt v1.1.1/go.mod h1:+1AmkuG3IYkh1kv0d2qEB9Le88ehNO0zwOr3ujewlOo= 80 | github.com/temoto/robotstxt v1.1.2 h1:W2pOjSJ6SWvldyEuiFXNxz3xZ8aiWX5LbfDiOFd7Fxg= 81 | github.com/temoto/robotstxt v1.1.2/go.mod h1:+1AmkuG3IYkh1kv0d2qEB9Le88ehNO0zwOr3ujewlOo= 82 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 83 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 84 | golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 85 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 86 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 87 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 88 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 89 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 90 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 91 | golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 92 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 93 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 94 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 95 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 96 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 97 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 98 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 99 | golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 100 | golang.org/x/net v0.0.0-20200421231249-e086a090c8fd/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 101 | golang.org/x/net v0.0.0-20200602114024-627f9648deb9/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 102 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 103 | golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 104 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 105 | golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw= 106 | golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= 107 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 108 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 109 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 110 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 111 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 112 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 113 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 114 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 115 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 116 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 117 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 118 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 119 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 120 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 121 | golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 122 | golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= 123 | golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 124 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 125 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 126 | golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= 127 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 128 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 129 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 130 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 131 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 132 | golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k= 133 | golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 134 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 135 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 136 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 137 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 138 | golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 139 | golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 140 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 141 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 142 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 143 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 144 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 145 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 146 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 147 | google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 148 | google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= 149 | google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 150 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 151 | google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 152 | google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= 153 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 154 | google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= 155 | google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 156 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 157 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 158 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 159 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 160 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 161 | google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 162 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 163 | google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 164 | google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= 165 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 166 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 167 | google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= 168 | google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 169 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 170 | honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 171 | -------------------------------------------------------------------------------- /cmd/fetch/main.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2021-2024 caixw 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | package main 6 | 7 | import ( 8 | "errors" 9 | "flag" 10 | "fmt" 11 | "io" 12 | "os" 13 | "strconv" 14 | "strings" 15 | "time" 16 | 17 | "github.com/issue9/cmdopt" 18 | "github.com/issue9/term/v3/colors" 19 | ) 20 | 21 | func main() { 22 | const usage = `fetch 23 | commands: 24 | {{commands}} 25 | 26 | flag: 27 | {{flags}} 28 | ` 29 | opt := cmdopt.New(os.Stdout, flag.ContinueOnError, usage, nil, func(s string) string { return fmt.Sprintf("not found %s", s) }) 30 | cmdopt.Help(opt, "help", "显示当前命令\n", "显示当前命令\n") 31 | 32 | opt.New("fetch", "拉取数据\n", "拉取数据\n", doFetch) 33 | 34 | opt.New("build", "生成数据\n", "生成数据\n", doBuild) 35 | 36 | if err := opt.Exec(os.Args[1:]); err != nil { 37 | fmt.Fprintln(os.Stdout, err) 38 | os.Exit(2) 39 | } 40 | } 41 | 42 | func doFetch(fs *flag.FlagSet) cmdopt.DoFunc { 43 | var ( 44 | fetchDataDir string 45 | fetchYears string 46 | fetchInterval string 47 | ) 48 | fs.StringVar(&fetchDataDir, "data", "./data", "指定数据的保存目录") 49 | fs.StringVar(&fetchYears, "years", "", "指定年份,空值表示所有年份。格式 y1,y2。") 50 | fs.StringVar(&fetchInterval, "internal", "1m", "每拉取一个省份数据后的间隔时间。") 51 | 52 | return func(w io.Writer) error { 53 | years, err := getYears(fetchYears) 54 | if err != nil { 55 | return err 56 | } 57 | 58 | interval, err := time.ParseDuration(fetchInterval) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | return fetch(fetchDataDir, interval, years...) 64 | } 65 | } 66 | 67 | func doBuild(fs *flag.FlagSet) cmdopt.DoFunc { 68 | var ( 69 | buildDataDir string 70 | buildOutput string 71 | buildYears string 72 | ) 73 | fs.StringVar(&buildDataDir, "data", "", "指定数据目录") 74 | fs.StringVar(&buildOutput, "output", "", "指定输出文件路径") 75 | fs.StringVar(&buildYears, "years", "", "指定年份,空值表示所有年份。格式 y1,y2。") 76 | 77 | return func(io.Writer) error { 78 | years, err := getYears(buildYears) 79 | if err != nil { 80 | return err 81 | } 82 | 83 | return build(buildDataDir, buildOutput, years...) 84 | } 85 | } 86 | 87 | func getYears(years string) ([]int, error) { 88 | if years == "" { 89 | return nil, nil 90 | } 91 | 92 | yearList := strings.Split(years, ",") 93 | ys := make([]int, 0, len(yearList)) 94 | for _, y := range yearList { 95 | year, err := strconv.Atoi(strings.TrimSpace(y)) 96 | if err != nil { 97 | return nil, err 98 | } 99 | ys = append(ys, year) 100 | } 101 | 102 | return ys, nil 103 | } 104 | 105 | func colorsSprintf(fore colors.Color, format string, v ...any) string { 106 | return colors.Sprintf(colors.Normal, fore, colors.Default, format, v...) 107 | } 108 | 109 | func colorsSprint(fore colors.Color, v ...any) string { 110 | return colors.Sprint(colors.Normal, fore, colors.Default, v...) 111 | } 112 | 113 | func exists(path string) bool { 114 | _, err := os.Stat(path) 115 | return err == nil || !errors.Is(err, os.ErrNotExist) 116 | } 117 | -------------------------------------------------------------------------------- /cnregion.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2021-2024 caixw 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | // Package cnregion 中国区域划分代码 6 | // 7 | // 中国行政区域五级划分代码,包含了省、市、县、乡和村五个级别。 8 | // [数据规则]以及[数据来源]。 9 | // 10 | // [数据规则]: http://www.stats.gov.cn/tjsj/tjbz/200911/t20091125_8667.html 11 | // [数据来源]: http://www.stats.gov.cn/tjsj/tjbz/tjyqhdmhcxhfdm/ 12 | package cnregion 13 | 14 | import ( 15 | "bytes" 16 | "compress/gzip" 17 | "io" 18 | "io/fs" 19 | "os" 20 | ) 21 | 22 | // LoadFS 从数据文件加载数据 23 | func LoadFS(f fs.FS, file, separator string, compress bool, version ...int) (*DB, error) { 24 | data, err := fs.ReadFile(f, file) 25 | if err != nil { 26 | return nil, err 27 | } 28 | return Load(data, separator, compress, version...) 29 | } 30 | 31 | // Load 将数据内容加载至 DB 对象 32 | // 33 | // version 仅加载指定年份的数据,如果为空,则加载所有数据; 34 | func Load(data []byte, separator string, compress bool, version ...int) (*DB, error) { 35 | if compress { 36 | rd, err := gzip.NewReader(bytes.NewReader(data)) 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | data, err = io.ReadAll(rd) 42 | if err != nil { 43 | return nil, err 44 | } 45 | } 46 | 47 | db := &DB{ 48 | fullNameSeparator: separator, 49 | filters: version, 50 | } 51 | if err := db.unmarshal(data); err != nil { 52 | return nil, err 53 | } 54 | 55 | db.initDistricts() 56 | 57 | return db, nil 58 | } 59 | 60 | // LoadFile 从数据文件加载数据 61 | func LoadFile(file, separator string, compress bool, version ...int) (*DB, error) { 62 | data, err := os.ReadFile(file) 63 | if err != nil { 64 | return nil, err 65 | } 66 | return Load(data, separator, compress, version...) 67 | } 68 | 69 | // Dump 输出到文件 70 | func (db *DB) Dump(file string, compress bool) error { 71 | data, err := db.marshal() 72 | if err != nil { 73 | return err 74 | } 75 | 76 | if compress { 77 | buf := new(bytes.Buffer) 78 | w := gzip.NewWriter(buf) 79 | if _, err = w.Write(data); err != nil { 80 | return err 81 | } 82 | if err = w.Close(); err != nil { 83 | return err 84 | } 85 | 86 | data = buf.Bytes() 87 | } 88 | 89 | return os.WriteFile(file, data, os.ModePerm) 90 | } 91 | -------------------------------------------------------------------------------- /cnregion_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2021-2024 caixw 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | package cnregion 6 | 7 | import ( 8 | "os" 9 | "path/filepath" 10 | "testing" 11 | 12 | "github.com/issue9/assert/v4" 13 | 14 | "github.com/issue9/cnregion/v2/id" 15 | "github.com/issue9/cnregion/v2/version" 16 | ) 17 | 18 | func TestLoad(t *testing.T) { 19 | a := assert.New(t, false) 20 | 21 | o1, err := Load(data, "-", false) 22 | a.NotError(err). 23 | Equal(o1.fullNameSeparator, obj.fullNameSeparator). 24 | Equal(o1.versions, obj.versions). 25 | Equal(len(o1.root.items), len(obj.root.items)). 26 | Equal(o1.root.items[0].id, obj.root.items[0].id). 27 | Equal(o1.root.items[0].fullID, obj.root.items[0].fullID). 28 | Equal(o1.root.items[0].items[0].id, obj.root.items[0].items[0].id). 29 | Equal(o1.root.items[1].items[0].id, obj.root.items[1].items[0].id). 30 | Equal(o1.root.items[1].items[0].fullID, obj.root.items[1].items[0].fullID). 31 | Equal(o1.root.items[1].items[1].fullID, obj.root.items[1].items[1].fullID). 32 | NotEqual(o1.root.items[1].items[1].fullID, obj.root.items[1].items[0].fullID) 33 | 34 | d1, err := obj.marshal() 35 | a.NotError(err).NotNil(d1) 36 | a.Equal(string(d1), string(data)) 37 | 38 | _, err = Load([]byte("100:[2020]:::1:0{}"), "-", false) 39 | a.Equal(err, ErrIncompatible) 40 | 41 | o1, err = Load(data, "-", false, 2019) 42 | a.NotError(err). 43 | Equal(0, len(o1.root.items)) 44 | } 45 | 46 | func TestDB_LoadDump(t *testing.T) { 47 | a := assert.New(t, false) 48 | 49 | path := filepath.Join(os.TempDir(), "cnregion_db.dict") 50 | a.NotError(obj.Dump(path, false)) 51 | d, err := LoadFile(path, "-", false) 52 | a.NotError(err).NotNil(d) 53 | 54 | path = filepath.Join(os.TempDir(), "cnregion_db_compress.dict") 55 | a.NotError(obj.Dump(path, true)) 56 | d, err = LoadFile(path, "-", true) 57 | a.NotError(err).NotNil(d) 58 | } 59 | 60 | func TestLoadFS(t *testing.T) { 61 | a := assert.New(t, false) 62 | 63 | obj, err := LoadFS(os.DirFS("./data"), "regions.db", "-", true) 64 | a.NotError(err).NotNil(obj) 65 | a.Equal(obj.versions, version.All()). 66 | Equal(obj.fullNameSeparator, "-"). 67 | True(len(obj.root.items) > 0). 68 | Equal(obj.root.items[0].level, id.Province). 69 | Equal(obj.root.items[0].items[0].level, id.City). 70 | Equal(obj.root.items[0].items[0].items[0].level, id.County). 71 | Equal(obj.root.items[1].level, id.Province). 72 | Equal(obj.root.items[2].items[0].level, id.City) 73 | } 74 | -------------------------------------------------------------------------------- /data/embed.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2021-2024 caixw 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | package data 6 | 7 | import ( 8 | "embed" 9 | 10 | "github.com/issue9/cnregion/v2" 11 | ) 12 | 13 | //go:embed regions.db 14 | var data embed.FS 15 | 16 | // Embed 将 regions.db 的内容嵌入到程序中 17 | // 18 | // 这样可以让程序不依赖外部文件,但同时也会增加编译后程序的大小。 19 | func Embed(separator string, version ...int) (*cnregion.DB, error) { 20 | return cnregion.LoadFS(data, "regions.db", separator, true, version...) 21 | } 22 | -------------------------------------------------------------------------------- /data/embed_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2021-2024 caixw 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | package data 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/issue9/assert/v4" 11 | ) 12 | 13 | func TestEmbed(t *testing.T) { 14 | a := assert.New(t, false) 15 | 16 | v, err := Embed(">", 2021) 17 | a.NotError(err).NotNil(v) 18 | r := v.Find("330305000000") 19 | a.NotNil(r). 20 | Equal(r.ID(), "05"). 21 | Equal(r.FullID(), "330305000000"). 22 | Equal(r.Name(), "洞头区"). 23 | Equal(r.FullName(), "浙江省>温州市>洞头区") 24 | } 25 | -------------------------------------------------------------------------------- /data/regions.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/issue9/cnregion/692b4fb3ec650555706a2a6b34164b105b79687e/data/regions.db -------------------------------------------------------------------------------- /db.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2021-2024 caixw 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | package cnregion 6 | 7 | import ( 8 | "bytes" 9 | "errors" 10 | "fmt" 11 | "slices" 12 | "strconv" 13 | "strings" 14 | 15 | "github.com/issue9/errwrap" 16 | 17 | "github.com/issue9/cnregion/v2/id" 18 | ) 19 | 20 | // Version 数据文件的版本号 21 | const Version = 1 22 | 23 | // ErrIncompatible 数据文件版本不兼容 24 | // 25 | // 当数据文件中指定的版本号与当前的 Version 不相等时,返回此错误。 26 | var ErrIncompatible = errors.New("数据文件版本不兼容") 27 | 28 | // DB 区域数据库信息 29 | // 30 | // 数据格式: 31 | // 32 | // 1:[versions]:{id:name:yearIndex:size{}} 33 | // 34 | // - 1 表示数据格式的版本,采用当前包的 Version 常量; 35 | // - versions 表示当前数据文件中的数据支持的年份列表,以逗号分隔; 36 | // - id 当前区域的 ID; 37 | // - name 当前区域的名称; 38 | // - yearIndex 此条数据支持的年份列表,每一个位表示一个年份在 versions 中的索引值; 39 | // - size 表示子元素的数量; 40 | type DB struct { 41 | root *Region 42 | versions []int // 支持的版本 43 | 44 | // 以下数据不会写入数据文件中 45 | 46 | fullNameSeparator string 47 | districts []*Region 48 | 49 | // Load 指定的过滤版本,仅在 unmarshal 过程中使用, 50 | // 在完成 unmarshal 之的清空。 51 | filters []int 52 | } 53 | 54 | // NewDB 返回空的 [DB] 对象 55 | func NewDB() *DB { 56 | db := &DB{versions: []int{}} 57 | db.root = &Region{db: db} 58 | return db 59 | } 60 | 61 | // Version 当前这份数据支持的年份列表 62 | func (db *DB) Versions() []int { return db.versions } 63 | 64 | // AddVersion 添加新的版本号 65 | func (db *DB) AddVersion(ver int) (ok bool) { 66 | if slices.Index(db.versions, ver) > -1 { // 检测 ver 是否已经存在 67 | return false 68 | } 69 | 70 | db.versions = append(db.versions, ver) 71 | return true 72 | } 73 | 74 | // Find 查找指定 ID 对应的信息 75 | func (db *DB) Find(regionID string) *Region { return db.root.findItem(id.SplitFilter(regionID)...) } 76 | 77 | var levelIndex = []id.Level{id.Province, id.City, id.County, id.Town, id.Village} 78 | 79 | // AddItem 添加一条子项 80 | func (db *DB) AddItem(regionID, name string, ver int) error { 81 | list := id.SplitFilter(regionID) 82 | item := db.root.findItem(list...) 83 | 84 | if item == nil { 85 | items := list[:len(list)-1] // 上一级 86 | item = db.root.findItem(items...) 87 | level := levelIndex[len(items)] 88 | return item.addItem(list[len(list)-1], name, level, ver) 89 | } 90 | 91 | return item.setSupported(ver) 92 | } 93 | 94 | func (db *DB) marshal() ([]byte, error) { 95 | versions := make([]string, 0, len(db.versions)) 96 | for _, v := range db.versions { 97 | versions = append(versions, strconv.Itoa(v)) 98 | } 99 | 100 | buf := errwrap.Buffer{Buffer: bytes.Buffer{}} 101 | buf.WString(strconv.Itoa(Version)).WByte(':') 102 | 103 | buf.WByte('[') 104 | buf.WString(strings.Join(versions, ",")) 105 | buf.WByte(']').WByte(':') 106 | 107 | err := db.root.marshal(&buf) 108 | if err != nil { 109 | return nil, err 110 | } 111 | 112 | if buf.Err != nil { 113 | return nil, err 114 | } 115 | return buf.Bytes(), nil 116 | } 117 | 118 | func (db *DB) unmarshal(data []byte) error { 119 | data, val := indexBytes(data, ':') 120 | ver, err := strconv.Atoi(val) 121 | if err != nil { 122 | return err 123 | } 124 | if ver != Version { 125 | return ErrIncompatible 126 | } 127 | 128 | data, val = indexBytes(data, ':') 129 | versions := strings.Split(strings.Trim(val, "[]"), ",") 130 | db.versions = make([]int, 0, len(versions)) 131 | for _, version := range versions { 132 | v, err := strconv.Atoi(version) 133 | if err != nil { 134 | return err 135 | } 136 | db.versions = append(db.versions, v) 137 | } 138 | 139 | if len(db.filters) == 0 { 140 | db.filters = db.versions 141 | } else { 142 | LOOP: 143 | for _, v := range db.filters { 144 | for _, v2 := range db.versions { 145 | if v2 == v { 146 | continue LOOP 147 | } 148 | } 149 | return fmt.Errorf("当前数据文件没有 %d 年份的数据", v) 150 | } 151 | } 152 | 153 | defer func() { 154 | db.versions = db.filters 155 | db.filters = db.filters[:0] 156 | }() 157 | 158 | db.root = &Region{db: db} 159 | return db.root.unmarshal(data, "", "", 0) 160 | } 161 | 162 | func (db *DB) filterVersions(versions []int) []int { 163 | vers := make([]int, 0, len(versions)) 164 | LOOP: 165 | for _, v := range versions { 166 | for _, v2 := range db.filters { 167 | if v2 == v { 168 | vers = append(vers, v) 169 | continue LOOP 170 | } 171 | } 172 | } 173 | 174 | return vers 175 | } 176 | -------------------------------------------------------------------------------- /db_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2021-2024 caixw 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | package cnregion 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/issue9/assert/v4" 11 | 12 | "github.com/issue9/cnregion/v2/id" 13 | "github.com/issue9/cnregion/v2/version" 14 | ) 15 | 16 | var data = []byte(`1:[2020,2019]:::1:2{33:浙江:1:1{01:温州:3:0{}}34:安徽:1:3{01:合肥:3:0{}02:芜湖:1:0{}03:芜湖-2:1:0{}}}`) 17 | 18 | var obj = &DB{ 19 | versions: []int{2020, 2019}, 20 | fullNameSeparator: "-", 21 | root: &Region{ 22 | name: "", 23 | versions: []int{2020}, 24 | items: []*Region{ 25 | { 26 | id: "33", 27 | name: "浙江", 28 | versions: []int{2020}, 29 | fullName: "浙江", 30 | fullID: "330000000000", 31 | level: id.Province, 32 | items: []*Region{ 33 | { 34 | id: "01", 35 | name: "温州", 36 | versions: []int{2020, 2019}, 37 | fullName: "浙江-温州", 38 | fullID: "330100000000", 39 | level: id.City, 40 | }, 41 | }, 42 | }, 43 | { 44 | id: "34", 45 | name: "安徽", 46 | fullName: "安徽", 47 | fullID: "340000000000", 48 | versions: []int{2020}, 49 | level: id.Province, 50 | items: []*Region{ 51 | { 52 | id: "01", 53 | name: "合肥", 54 | versions: []int{2020, 2019}, 55 | fullName: "安徽-合肥", 56 | fullID: "340100000000", 57 | level: id.City, 58 | }, 59 | { 60 | id: "02", 61 | name: "芜湖", 62 | versions: []int{2020}, 63 | fullName: "安徽-芜湖", 64 | fullID: "340200000000", 65 | level: id.City, 66 | }, 67 | { 68 | id: "03", 69 | name: "芜湖-2", 70 | versions: []int{2020}, 71 | fullName: "安徽-芜湖-2", 72 | fullID: "340300000000", 73 | level: id.City, 74 | }, 75 | }, 76 | }, 77 | }, 78 | }, 79 | } 80 | 81 | func init() { 82 | setRegionDB(obj.root, obj) 83 | } 84 | 85 | func setRegionDB(r *Region, db *DB) { 86 | r.db = db 87 | for _, i := range r.items { 88 | setRegionDB(i, db) 89 | } 90 | } 91 | 92 | func TestDB_Find(t *testing.T) { 93 | a := assert.New(t, false) 94 | 95 | // 2020 96 | db, err := LoadFile("./data/regions.db", ">", true, 2020) 97 | a.NotError(err).NotNil(db) 98 | r := db.Find("330305000000") 99 | a.NotNil(r). 100 | Equal(r.ID(), "05"). 101 | Equal(r.FullID(), "330305000000"). 102 | Equal(r.Name(), "洞头区"). 103 | Equal(r.FullName(), "浙江省>温州市>洞头区"). 104 | Equal(r.Versions(), []int{2020}) 105 | r = db.Find("330322000000") // 洞头县,已改为洞头区 106 | a.Nil(r) 107 | 108 | // 2009 109 | db, err = LoadFile("./data/regions.db", ">", true, 2009) 110 | a.NotError(err).NotNil(db) 111 | r = db.Find("330322000000") 112 | a.NotNil(r). 113 | Equal(r.ID(), "22"). 114 | Equal(r.FullID(), "330322000000"). 115 | Equal(r.Name(), "洞头县"). 116 | Equal(r.FullName(), "浙江省>温州市>洞头县"). 117 | Equal(r.Versions(), []int{2009}) 118 | r = db.Find("330305000000") 119 | a.Nil(r) 120 | 121 | // 所有年份的数据 122 | db, err = LoadFile("./data/regions.db", ">", true, version.Range(2009, 2020)...) 123 | a.NotError(err).NotNil(db) 124 | r = db.Find("330322000000") 125 | a.NotNil(r). 126 | Equal(r.ID(), "22"). 127 | Equal(r.Versions(), []int{2014, 2013, 2012, 2011, 2010, 2009}) 128 | r = db.Find("330305000000") 129 | a.NotNil(r). 130 | Equal(r.ID(), "05"). 131 | Contains(r.Versions(), []int{2018, 2017, 2016, 2015}) 132 | } 133 | -------------------------------------------------------------------------------- /districts.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2021-2024 caixw 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | package cnregion 6 | 7 | import "github.com/issue9/cnregion/v2/id" 8 | 9 | // Districts 按行政大区划分 10 | // 11 | // NOTE: 大区划分并不统一,按照各个省份的第一个数字进行划分。 12 | func (db *DB) Districts() []*Region { return db.districts } 13 | 14 | func (db *DB) initDistricts() { 15 | db.districts = make([]*Region, 0, len(districtsMap)) 16 | 17 | for index, name := range districtsMap { 18 | items := make([]*Region, 0, 10) 19 | for _, p := range db.Provinces() { 20 | if p.ID()[0] == index { 21 | items = append(items, p) 22 | } 23 | } 24 | 25 | db.districts = append(db.districts, &Region{ 26 | id: string(index), 27 | fullID: id.Fill(string(index), id.Village), 28 | name: name, 29 | fullName: name, 30 | items: items, 31 | }) 32 | } 33 | 34 | } 35 | 36 | var districtsMap = map[byte]string{ 37 | '1': "华北地区", 38 | '2': "东北地区", 39 | '3': "华东地区", 40 | '4': "中南地区", 41 | '5': "西南地区", 42 | '6': "西北地区", 43 | } 44 | -------------------------------------------------------------------------------- /districts_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2021-2024 caixw 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | package cnregion 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/issue9/assert/v4" 11 | ) 12 | 13 | func TestDB_Districts(t *testing.T) { 14 | a := assert.New(t, false) 15 | 16 | db, err := LoadFile("./data/regions.db", ">", true, 2020) 17 | a.NotError(err).NotNil(db) 18 | a.Length(db.Districts(), len(districtsMap)) 19 | 20 | for _, d := range db.Districts() { 21 | if d.ID() == "1" { 22 | a.Equal(d.Name(), "华北地区").Equal(5, len(d.Items())) 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/issue9/cnregion/v2 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/issue9/assert/v4 v4.3.1 7 | github.com/issue9/errwrap v0.3.2 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/issue9/assert/v4 v4.1.1/go.mod h1:v7qDRXi7AsaZZNh8eAK2rkLJg5/clztqQGA1DRv9Lv4= 2 | github.com/issue9/assert/v4 v4.3.1 h1:dHYODk1yV7j/1baIB6K6UggI4r1Hfuljqic7PaDbwLg= 3 | github.com/issue9/assert/v4 v4.3.1/go.mod h1:v7qDRXi7AsaZZNh8eAK2rkLJg5/clztqQGA1DRv9Lv4= 4 | github.com/issue9/errwrap v0.3.2 h1:7KEme9Pfe75M+sIMcPCn/DV90wjnOcRbO4DXVAHj3Fw= 5 | github.com/issue9/errwrap v0.3.2/go.mod h1:KcCLuUGiffjooLCUjL89r1cyO8/HT/VRcQrneO53N3A= 6 | -------------------------------------------------------------------------------- /id/id.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2021-2024 caixw 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | // Package id 针对 ID 的一些操作函数 6 | package id 7 | 8 | import ( 9 | "fmt" 10 | "strings" 11 | ) 12 | 13 | // Level 表示区域的级别 14 | type Level uint8 15 | 16 | // 对区域级别的定义 17 | const ( 18 | Village Level = 1 << iota 19 | Town 20 | County 21 | City 22 | Province 23 | 24 | AllLevel = Village + Town + County + City + Province 25 | ) 26 | 27 | var lengths = map[Level]int{ 28 | Village: 12, 29 | Town: 9, 30 | County: 6, 31 | City: 4, 32 | Province: 2, 33 | } 34 | 35 | // Length 获取各个类型 ID 的有效果长度 36 | func Length(level Level) int { 37 | if _, found := lengths[level]; !found { 38 | panic("无效的 level 参数") 39 | } 40 | 41 | return lengths[level] 42 | } 43 | 44 | // Split 将一个区域 ID 按区域进行划分 45 | func Split(id string) (province, city, county, town, village string) { 46 | if len(id) != Length(Village) { 47 | panic(fmt.Sprintf("id 的长度只能为 %d,当前为 %s", Length(Village), id)) 48 | } 49 | 50 | return id[:Length(Province)], 51 | id[Length(Province):Length(City)], 52 | id[Length(City):Length(County)], 53 | id[Length(County):Length(Town)], 54 | id[Length(Town):Length(Village)] 55 | } 56 | 57 | // SplitFilter 将 id 按区域进行划分且过滤掉零值的区域 58 | // 59 | // 330312123000 => 33 03 12 123 60 | // 61 | // 如果传递的是零值,则返回空数组。 62 | func SplitFilter(id string) []string { 63 | province, city, county, town, village := Split(id) 64 | return filterZero(province, city, county, town, village) 65 | } 66 | 67 | func filterZero(id ...string) []string { 68 | for index, i := range id { // 过滤掉数组中的零值 69 | if isZero(i) { 70 | id = id[:index] 71 | break 72 | } 73 | } 74 | return id 75 | } 76 | 77 | // Parent 获取 id 的上一级行政区域的 ID 78 | // 79 | // 330312123456 => 330312123 80 | func Parent(id string) string { 81 | list := SplitFilter(id) 82 | return strings.Join(list[:len(list)-1], "") 83 | } 84 | 85 | // Prefix 获取 ID 的非零前缀 86 | // 87 | // 330312123456 => 330312123456 88 | // 330312123000 => 330312123 89 | func Prefix(id string) string { 90 | return strings.Join(SplitFilter(id), "") 91 | } 92 | 93 | // Fill 为 id 填充后缀的 0 94 | // 95 | // id 为原始值; 96 | // level 为需要达到的行政级别,最终的长度为 Length(level)。 97 | func Fill(id string, level Level) string { 98 | rem := Length(level) - len(id) 99 | switch { 100 | case rem == 0: 101 | return id 102 | case rem > Length(level) || rem < 2: 103 | panic(fmt.Sprintf("无效的 id %s,无法为其填充 0", id)) 104 | default: 105 | return id + strings.Repeat("0", rem) 106 | } 107 | } 108 | 109 | // isZero 判断一组字符串是否都由 0 组成 110 | func isZero(id string) bool { 111 | for _, r := range id { 112 | if r != '0' { 113 | return false 114 | } 115 | } 116 | return true 117 | } 118 | -------------------------------------------------------------------------------- /id/id_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2021-2024 caixw 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | package id 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/issue9/assert/v4" 11 | ) 12 | 13 | func TestSplit(t *testing.T) { 14 | a := assert.New(t, false) 15 | 16 | province, city, county, town, village := Split("330203103233") 17 | a.Equal(province, "33"). 18 | Equal(city, "02"). 19 | Equal(county, "03"). 20 | Equal(town, "103"). 21 | Equal(village, "233") 22 | 23 | a.Panic(func() { 24 | Split("3303") 25 | }) 26 | } 27 | 28 | func TestSplitFilter(t *testing.T) { 29 | a := assert.New(t, false) 30 | 31 | list := SplitFilter("330203103000") 32 | a.Equal(4, len(list)). 33 | Equal(list[0], "33"). 34 | Equal(list[1], "02"). 35 | Equal(list[2], "03"). 36 | Equal(list[3], "103") 37 | 38 | // 碰到第一个零值,即结果后续的判断 39 | list = SplitFilter("330003103000") 40 | a.Equal(1, len(list)).Equal(list[0], "33") 41 | 42 | list = SplitFilter("000000000000") 43 | a.Empty(list) 44 | } 45 | 46 | func TestParent(t *testing.T) { 47 | a := assert.New(t, false) 48 | 49 | a.Equal(Parent("330300000000"), "33") 50 | a.Equal(Parent("330302111000"), "330302") 51 | } 52 | 53 | func TestPrefix(t *testing.T) { 54 | a := assert.New(t, false) 55 | 56 | a.Equal(Prefix("330301001001"), "330301001001") 57 | a.Equal(Prefix("330300000000"), "3303") 58 | a.Equal(Prefix("330302000000"), "330302") 59 | } 60 | 61 | func TestFill(t *testing.T) { 62 | a := assert.New(t, false) 63 | 64 | a.Equal(Fill("34", Village), "340000000000") 65 | a.Equal(Fill("3", Village), "300000000000") 66 | a.Equal(Fill("34", Province), "34") 67 | a.Equal(Fill("34", City), "3400") 68 | a.Equal(Fill("341234666777", Village), "341234666777") 69 | a.Panic(func() { 70 | Fill("34112233444332", Village) 71 | }) 72 | } 73 | 74 | func TestIsZero(t *testing.T) { 75 | a := assert.New(t, false) 76 | 77 | a.True(isZero("000")) 78 | a.False(isZero("00x")) 79 | a.True(isZero("")) 80 | } 81 | -------------------------------------------------------------------------------- /region.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2021-2024 caixw 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | package cnregion 6 | 7 | import ( 8 | "bytes" 9 | "errors" 10 | "fmt" 11 | "slices" 12 | "strconv" 13 | 14 | "github.com/issue9/errwrap" 15 | 16 | "github.com/issue9/cnregion/v2/id" 17 | ) 18 | 19 | // Region 表示单个区域 20 | type Region struct { 21 | id string 22 | name string 23 | items []*Region 24 | versions []int // 支持的版本号列表 25 | 26 | // 以下数据不会写入数据文件中 27 | 28 | fullName string // 全名 29 | fullID string 30 | db *DB 31 | level id.Level 32 | } 33 | 34 | // Provinces 省份列表 35 | func (db *DB) Provinces() []*Region { return db.root.items } 36 | 37 | func (r *Region) ID() string { return r.id } // 区域的 ID,不包括后缀 0 和上一级的 ID 38 | func (r *Region) Name() string { return r.name } // 区域的名称 39 | func (r *Region) FullName() string { return r.fullName } // 区域的全称,包括上一级的名称 40 | func (r *Region) FullID() string { return r.fullID } // 区域的 ID,包括后缀的 0 以及上一级的 ID,长度为 12 41 | func (r *Region) Versions() []int { return r.versions } // 支持的年份版本 42 | func (r *Region) Items() []*Region { return r.items } // 子项 43 | 44 | // IsSupported 当前数据是否支持该年份 45 | func (r *Region) IsSupported(ver int) bool { return slices.Index(r.versions, ver) > -1 } 46 | 47 | func (reg *Region) addItem(id, name string, level id.Level, ver int) error { 48 | if slices.Index(reg.db.versions, ver) == -1 { 49 | return fmt.Errorf("不支持该年份 %d 的数据", ver) 50 | } 51 | 52 | for _, item := range reg.items { 53 | if item.id == id { 54 | return fmt.Errorf("已经存在相同 ID 的数据项:%s", id) 55 | } 56 | } 57 | 58 | reg.items = append(reg.items, &Region{ 59 | id: id, 60 | name: name, 61 | db: reg.db, 62 | level: level, 63 | versions: []int{ver}, 64 | }) 65 | return nil 66 | } 67 | 68 | func (reg *Region) setSupported(ver int) error { 69 | if slices.Index(reg.db.versions, ver) == -1 { 70 | return fmt.Errorf("不存在该年份 %d 的数据", ver) 71 | } 72 | 73 | if !reg.IsSupported(ver) { 74 | reg.versions = append(reg.versions, ver) 75 | } 76 | return nil 77 | } 78 | 79 | func (reg *Region) findItem(regionID ...string) *Region { 80 | if len(regionID) == 0 { 81 | return reg 82 | } 83 | 84 | for _, item := range reg.items { 85 | if item.id == regionID[0] { 86 | return item.findItem(regionID[1:]...) 87 | } 88 | } 89 | 90 | return nil 91 | } 92 | 93 | func (reg *Region) marshal(buf *errwrap.Buffer) error { 94 | supported := 0 95 | for _, ver := range reg.versions { 96 | index := slices.Index(reg.db.versions, ver) 97 | if index == -1 { 98 | return fmt.Errorf("无效的年份 %d 位于 %s", ver, reg.fullName) 99 | } 100 | supported += 1 << index 101 | } 102 | buf.Printf("%s:%s:%d:%d{", reg.id, reg.name, supported, len(reg.items)) 103 | for _, item := range reg.items { 104 | err := item.marshal(buf) 105 | if err != nil { 106 | return err 107 | } 108 | } 109 | buf.WByte('}') 110 | 111 | return nil 112 | } 113 | 114 | func (reg *Region) unmarshal(data []byte, parentName, parentID string, level id.Level) error { 115 | reg.level = level 116 | 117 | data, reg.id = indexBytes(data, ':') 118 | 119 | data, reg.name = indexBytes(data, ':') 120 | reg.fullName = reg.name 121 | if parentName != "" { 122 | reg.fullName = parentName + reg.db.fullNameSeparator + reg.name 123 | } 124 | parentID += reg.id 125 | reg.fullID = id.Fill(parentID, id.Village) 126 | 127 | // Versions 128 | data, val := indexBytes(data, ':') 129 | supported, err := strconv.Atoi(val) 130 | if err != nil { 131 | return err 132 | } 133 | versions := make([]int, 0, len(reg.db.versions)) 134 | for i, v := range reg.db.versions { 135 | if flag := 1 << i; flag&supported == flag { 136 | versions = append(versions, v) 137 | } 138 | } 139 | reg.versions = reg.db.filterVersions(versions) 140 | 141 | data, val = indexBytes(data, '{') 142 | size, err := strconv.Atoi(val) 143 | if err != nil { 144 | return err 145 | } 146 | 147 | if size > 0 { 148 | for i := 0; i < size; i++ { 149 | index := findEnd(data) 150 | if index < 0 { 151 | return errors.New("未找到结束符号 }") 152 | } 153 | 154 | // 下一级的 Level 155 | var next id.Level 156 | if level == 0 { 157 | next = id.Province 158 | } else { 159 | next = level >> 1 160 | } 161 | 162 | item := &Region{db: reg.db} 163 | if err := item.unmarshal(data[:index], reg.fullName, parentID, next); err != nil { 164 | return err 165 | } 166 | if len(item.versions) > 0 { // 表示该条数据不支持所有的年份 167 | reg.items = append(reg.items, item) 168 | } 169 | data = data[index+1:] 170 | } 171 | } 172 | 173 | return nil 174 | } 175 | 176 | func indexBytes(data []byte, b byte) ([]byte, string) { 177 | index := bytes.IndexByte(data, b) 178 | if index == -1 { 179 | panic(fmt.Sprintf("在%s未找到:%s", string(data), string(b))) 180 | } 181 | 182 | return data[index+1:], string(data[:index]) 183 | } 184 | 185 | func findEnd(data []byte) int { 186 | deep := 0 187 | for i, b := range data { 188 | switch b { 189 | case '{': 190 | deep++ 191 | case '}': 192 | deep-- 193 | if deep == 0 { 194 | return i 195 | } 196 | } 197 | } 198 | 199 | return 0 200 | } 201 | -------------------------------------------------------------------------------- /region_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2021-2024 caixw 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | package cnregion 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/issue9/assert/v4" 11 | 12 | "github.com/issue9/cnregion/v2/id" 13 | ) 14 | 15 | func TestRegion_IsSupported(t *testing.T) { 16 | a := assert.New(t, false) 17 | 18 | obj := &DB{versions: []int{2020, 2019, 2018}} 19 | obj.root = &Region{items: []*Region{ 20 | {versions: []int{2020, 2019}, name: "test", db: obj}, 21 | }, db: obj} 22 | 23 | a.True(obj.root.items[0].IsSupported(2020)) 24 | a.True(obj.root.items[0].IsSupported(2019)) 25 | a.False(obj.root.items[0].IsSupported(2018)) // 不支持 26 | a.False(obj.root.items[0].IsSupported(2009)) // 不存在于 db 27 | } 28 | 29 | func TestRegion_addItem(t *testing.T) { 30 | a := assert.New(t, false) 31 | 32 | obj := &DB{versions: []int{2020, 2019, 2018}} 33 | obj.root = &Region{items: []*Region{}, db: obj} 34 | 35 | a.ErrorString(obj.root.addItem("33", "浙江", id.Province, 2001), "不支持该年份") 36 | 37 | a.NotError(obj.root.addItem("44", "广东", id.Province, 2020)) 38 | a.Equal(obj.root.items[0].id, "44"). 39 | NotNil(obj.root.items[0].db). 40 | True(obj.root.items[0].IsSupported(2020)). 41 | False(obj.root.items[0].IsSupported(2019)) 42 | 43 | a.ErrorString(obj.root.addItem("44", "广东", id.Province, 2020), "存在相同") 44 | } 45 | 46 | func TestRegion_SetSupported(t *testing.T) { 47 | a := assert.New(t, false) 48 | 49 | obj := &DB{versions: []int{2020, 2019, 2018}} 50 | obj.root = &Region{items: []*Region{{db: obj}}, db: obj} 51 | 52 | a.NotError(obj.root.addItem("33", "浙江", id.Province, 2020)) 53 | a.NotError(obj.root.items[0].setSupported(2020)). 54 | Equal(obj.root.items[0].versions, []int{2020}) 55 | a.NotError(obj.root.items[0].setSupported(2019)). 56 | Equal(obj.root.items[0].versions, []int{2020, 2019}) 57 | a.ErrorString(obj.root.items[0].setSupported(2001), "不存在该年份") 58 | } 59 | 60 | func TestFindEnd(t *testing.T) { 61 | a := assert.New(t, false) 62 | 63 | data := []byte("0123{56}") 64 | a.Equal(findEnd(data), 7) 65 | } 66 | 67 | func TestDB_Provinces(t *testing.T) { 68 | a := assert.New(t, false) 69 | 70 | v, err := LoadFile("./data/regions.db", ">", true, 2020) 71 | a.NotError(err).NotNil(v) 72 | 73 | for _, p := range v.Provinces() { 74 | if p.ID() == "33" { 75 | a.Equal(p.Name(), "浙江省") 76 | } 77 | } 78 | } 79 | 80 | func TestRegion_Items(t *testing.T) { 81 | a := assert.New(t, false) 82 | 83 | // 2020 84 | var x05, x22 bool 85 | v, err := LoadFile("./data/regions.db", ">", true, 2020) 86 | a.NotError(err).NotNil(v) 87 | r := v.Find("330300000000") 88 | for _, item := range r.Items() { 89 | if item.ID() == "05" { 90 | x05 = true 91 | } 92 | if item.ID() == "22" { 93 | x22 = true 94 | } 95 | } 96 | a.True(x05).False(x22) 97 | 98 | // 2009 99 | x05 = false 100 | x22 = false 101 | v, err = LoadFile("./data/regions.db", ">", true, 2009) 102 | a.NotError(err).NotNil(v) 103 | r = v.Find("330300000000") 104 | for _, item := range r.Items() { 105 | if item.ID() == "05" { 106 | x05 = true 107 | } 108 | if item.ID() == "22" { 109 | x22 = true 110 | } 111 | } 112 | a.False(x05).True(x22) 113 | 114 | //2020 + 2009 115 | x05 = false 116 | x22 = false 117 | v, err = LoadFile("./data/regions.db", ">", true, 2009, 2020) 118 | a.NotError(err).NotNil(v) 119 | r = v.Find("330300000000") 120 | for _, item := range r.Items() { 121 | if item.ID() == "05" { 122 | x05 = true 123 | } 124 | if item.ID() == "22" { 125 | x22 = true 126 | } 127 | } 128 | a.True(x05).True(x22) 129 | } 130 | 131 | func TestRegion_findItem(t *testing.T) { 132 | a := assert.New(t, false) 133 | 134 | r := obj.root.findItem("34", "01") 135 | a.NotNil(r).Equal(r.name, "合肥").Equal(r.fullName, "安徽-合肥") 136 | 137 | r = obj.root.findItem("34", "01", "00") 138 | a.Nil(r) 139 | 140 | r = obj.root.findItem("34") 141 | a.NotNil(r).Equal(r.name, "安徽").Equal(r.fullName, "安徽") 142 | 143 | r = obj.root.findItem() 144 | a.NotNil(r).Equal(r.name, "").Equal(r.fullName, "").Equal(2, len(r.items)) 145 | 146 | // 不存在于 obj 147 | a.Nil(obj.root.findItem("99")) 148 | a.Nil(obj.root.findItem("")) 149 | } 150 | -------------------------------------------------------------------------------- /search.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2021-2024 caixw 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | package cnregion 6 | 7 | import ( 8 | "strings" 9 | 10 | "github.com/issue9/cnregion/v2/id" 11 | ) 12 | 13 | // Options 搜索选项 14 | type Options struct { 15 | // 表示你需要搜索的地名需要包含的内容 16 | // 17 | // 不能是多个名称的组合,比如"浙江温州",直接写"温州"就可以。 18 | // 也不要提供类似于"居委会"这种无实际意义的地名; 19 | Text string 20 | 21 | // 上一级的区域 ID 22 | // 23 | // 为空表示不限制。 24 | Parent string 25 | 26 | // 搜索的城市类型 27 | // 28 | // 该值取值于 github.com/issue9/cnregion/v2/id.Level 类型。 多个值可以通过或运算叠加。 29 | // 0 表示所有类型。 30 | Level id.Level 31 | 32 | // 最大的搜索数量。0 表示不限制数量。 33 | Max int 34 | unlimited bool 35 | } 36 | 37 | func (o *Options) isEmpty() bool { 38 | return o.Text == "" && 39 | (o.Parent == "" || o.Parent == "000000000000") && 40 | o.Level == 0 && 41 | o.Max == 0 42 | } 43 | 44 | // Search 简单的搜索功能 45 | func (db *DB) Search(opt *Options) []*Region { 46 | if opt == nil || opt.isEmpty() { 47 | panic("参数 opt 不能为空值") 48 | } 49 | 50 | r := db.root 51 | if opt.Parent != "" { 52 | r = db.Find(opt.Parent) 53 | } 54 | if r == nil { // 不存在 opt.Parent 指定的数据 55 | return nil 56 | } 57 | 58 | if opt.Level == 0 { 59 | opt.Level = id.AllLevel 60 | } 61 | 62 | opt.unlimited = opt.Max == 0 63 | size := 100 64 | if !opt.unlimited { 65 | size = opt.Max 66 | } 67 | list := make([]*Region, 0, size) 68 | 69 | return r.search(opt, list) 70 | } 71 | 72 | func (reg *Region) search(opt *Options, list []*Region) []*Region { 73 | if strings.Contains(reg.name, opt.Text) && 74 | (reg.level&opt.Level == reg.level) && reg.level != 0 { // level == 0 只有根元素才有 75 | list = append(list, reg) 76 | opt.Max-- 77 | } 78 | 79 | if !opt.unlimited && opt.Max <= 0 { 80 | return list 81 | } 82 | 83 | for _, item := range reg.items { 84 | list = item.search(opt, list) 85 | } 86 | 87 | return list 88 | } 89 | -------------------------------------------------------------------------------- /search_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2021-2024 caixw 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | package cnregion 6 | 7 | import ( 8 | "os" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/issue9/assert/v4" 13 | 14 | "github.com/issue9/cnregion/v2/id" 15 | ) 16 | 17 | func TestDB_Search(t *testing.T) { 18 | a := assert.New(t, false) 19 | 20 | rs := obj.Search(&Options{Text: "合肥"}) 21 | a.Equal(1, len(rs)). 22 | Equal(rs[0].name, "合肥") 23 | 24 | rs = obj.Search(&Options{Parent: "340000000000", Text: "合肥"}) 25 | a.Equal(1, len(rs)). 26 | Equal(rs[0].name, "合肥") 27 | 28 | rs = obj.Search(&Options{Parent: "000000000000", Text: "合肥"}) 29 | a.Equal(1, len(rs)). 30 | Equal(rs[0].name, "合肥") 31 | 32 | // 限定 level 只能是省以及 parent 为 34 开头 33 | rs = obj.Search(&Options{Parent: "340000000000", Level: id.Province, Text: "合肥"}) 34 | a.Equal(0, len(rs)) 35 | 36 | // 未限定 parent 且 level 正确 37 | rs = obj.Search(&Options{Level: id.City, Text: "合肥"}) 38 | a.Equal(1, len(rs)) 39 | 40 | rs = obj.Search(&Options{Level: id.City, Text: "湖"}) 41 | a.Equal(2, len(rs)) 42 | 43 | rs = obj.Search(&Options{Level: id.City, Parent: "340000000000", Text: "湖"}) 44 | a.Equal(2, len(rs)) 45 | 46 | // parent = 浙江 47 | rs = obj.Search(&Options{Parent: "330000000000", Text: "合肥"}) 48 | a.Equal(0, len(rs)) 49 | 50 | // parent 不存在 51 | rs = obj.Search(&Options{Parent: "110000000000", Text: "合肥"}) 52 | a.Equal(0, len(rs)) 53 | 54 | // 只有 Level 55 | rs = obj.Search(&Options{Level: id.City}) 56 | a.Equal(4, len(rs)) 57 | for _, r := range rs { 58 | a.True(strings.HasSuffix(r.fullID, "00000000")) 59 | } 60 | 61 | // 只有 Level 62 | rs = obj.Search(&Options{Level: id.City + id.Town}) 63 | a.Equal(4, len(rs)) 64 | 65 | // 只有 Level 66 | rs = obj.Search(&Options{Level: id.City + id.Province}) 67 | a.Equal(6, len(rs)) 68 | } 69 | 70 | func TestDB_SearchWithData(t *testing.T) { 71 | a := assert.New(t, false) 72 | 73 | obj, err := LoadFS(os.DirFS("./data"), "regions.db", "-", true) 74 | a.NotError(err).NotNil(obj) 75 | got := obj.Search(&Options{Text: "温州"}) 76 | a.NotEmpty(got) 77 | 78 | // Level 不匹配 79 | got = obj.Search(&Options{Text: "温州", Level: id.Province}) 80 | a.Empty(got) 81 | } 82 | -------------------------------------------------------------------------------- /version/version.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2021-2024 caixw 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | // Package version 提供版本的相关信息 6 | // 7 | // 依据 https://www.stats.gov.cn/sj/tjbz/tjyqhdmhcxhfdm/ 提供的数据, 8 | // 以年作为单位进行更新,同时也以四位的年份作为版本号。 9 | package version 10 | 11 | import "fmt" 12 | 13 | // ErrInvalidYear 无效的年份版本 14 | // 15 | // 年份只能介于 [2009, 当前年份-1) 的区间之间。 16 | var ErrInvalidYear = fmt.Errorf("无效的版本号,必须是介于 [%d,%d] 之间的整数", start, latest) 17 | 18 | // 起始版本号,即提供的数据的起始年份。 19 | const start = 2009 20 | 21 | // 最新的有效年份,每次更新数据之后,需要手动更新此值。 22 | var latest = 2023 23 | 24 | // All 返回支持的版本号列表 25 | func All() []int { return Range(start, latest) } 26 | 27 | // IsValid 验证年份是否为一个有效的版本号 28 | func IsValid(year int) bool { return year >= start && year <= latest } 29 | 30 | // BeginWith 从 begin 开始直到最新年份 31 | func BeginWith(begin int) []int { return Range(begin, latest) } 32 | 33 | // Range 获取指定范围内的版本号 34 | func Range(begin, end int) []int { 35 | if !IsValid(begin) { 36 | panic(ErrInvalidYear) 37 | } 38 | 39 | if !IsValid(end) { 40 | panic(ErrInvalidYear) 41 | } 42 | 43 | if begin > end { 44 | panic(ErrInvalidYear) 45 | } 46 | 47 | years := make([]int, 0, end-begin+1) 48 | for year := end; year >= begin; year-- { 49 | years = append(years, year) 50 | } 51 | return years 52 | } 53 | -------------------------------------------------------------------------------- /version/version_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2021-2024 caixw 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | package version 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/issue9/assert/v4" 11 | ) 12 | 13 | func TestAll(t *testing.T) { 14 | a := assert.New(t, false) 15 | 16 | all := All() 17 | // 保证从大到小 18 | a.Equal(all[0], latest). 19 | Equal(all[len(all)-1], start) 20 | } 21 | 22 | func TestBeginWith(t *testing.T) { 23 | a := assert.New(t, false) 24 | 25 | list := BeginWith(latest) 26 | a.Equal(1, len(list)).Equal(list[0], latest) 27 | 28 | a.Panic(func() { 29 | BeginWith(start - 1) 30 | }) 31 | } 32 | --------------------------------------------------------------------------------