Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,14 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v7.0.0
with:
submodules: true
# Sitemap lastmod comes from the latest content commit.
fetch-depth: 0

- name: Checkout tago
uses: actions/checkout@v6.0.2
uses: actions/checkout@v7.0.0
with:
repository: tamnd/tago
path: .tago-src
Expand Down Expand Up @@ -107,7 +107,7 @@ jobs:
group: cloudflare-pages-netease163-cli
cancel-in-progress: true
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v7.0.0
with:
fetch-depth: 1
sparse-checkout: scripts/
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ jobs:

# Tools GoReleaser shells out to for signing and SBOMs.
- uses: sigstore/cosign-installer@v3
- uses: anchore/sbom-action/download-syft@v0
- uses: anchore/sbom-action/download-syft@v0.24.0

- uses: goreleaser/goreleaser-action@v6
with:
Expand Down
27 changes: 27 additions & 0 deletions cli/cmd_article.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package cli

import (
"github.com/spf13/cobra"
)

// articleCmd returns the article command.
func (a *App) articleCmd() *cobra.Command {
return &cobra.Command{
Use: "article <docid|url>",
Short: "Fetch full article content from NetEase 163",
Long: `Fetch the full content of a NetEase 163 article by its docid or URL.

Examples:
netease163 article KVACHDNB0514D3UH
netease163 article https://www.163.com/dy/article/KVACHDNB0514D3UH.html`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
a.progressf("fetching article %q...", args[0])
detail, err := a.client.Article(cmd.Context(), args[0])
if err != nil {
return mapFetchErr(err)
}
return a.render(detail)
},
}
}
28 changes: 28 additions & 0 deletions cli/cmd_comments.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package cli

import (
"github.com/spf13/cobra"
)

// commentsCmd returns the comments command.
func (a *App) commentsCmd() *cobra.Command {
return &cobra.Command{
Use: "comments <docid|url>",
Short: "Show top comments for a NetEase 163 article",
Long: `Fetch the most-liked comments for a NetEase 163 article.

Examples:
netease163 comments KVACHDNB0514D3UH
netease163 comments https://www.163.com/dy/article/KVACHDNB0514D3UH.html`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
n := a.effectiveLimit(0)
a.progressf("fetching comments for %q...", args[0])
comments, err := a.client.Comments(cmd.Context(), args[0], n)
if err != nil {
return mapFetchErr(err)
}
return a.renderOrEmpty(comments, len(comments))
},
}
}
23 changes: 23 additions & 0 deletions cli/cmd_hot.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package cli

import (
"github.com/spf13/cobra"
)

// hotCmd returns the hot command.
func (a *App) hotCmd() *cobra.Command {
return &cobra.Command{
Use: "hot",
Short: "Show today's hot/trending NetEase 163 articles",
Long: `Fetch and display today's trending articles from the NetEase 163 hot feed.`,
RunE: func(cmd *cobra.Command, _ []string) error {
n := a.effectiveLimit(20)
a.progressf("fetching hot articles...")
articles, err := a.client.Hot(cmd.Context(), n)
if err != nil {
return mapFetchErr(err)
}
return a.renderOrEmpty(articles, len(articles))
},
}
}
28 changes: 28 additions & 0 deletions cli/cmd_search.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package cli

import (
"github.com/spf13/cobra"
)

// searchCmd returns the search command.
func (a *App) searchCmd() *cobra.Command {
return &cobra.Command{
Use: "search <query>",
Short: "Search NetEase 163 news",
Long: `Search NetEase News (so.163.com) and display matching articles.

Examples:
netease163 search 人工智能
netease163 search "科技新闻" -n 5`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
n := a.effectiveLimit(20)
a.progressf("searching %q...", args[0])
results, err := a.client.Search(cmd.Context(), args[0], n)
if err != nil {
return mapFetchErr(err)
}
return a.renderOrEmpty(results, len(results))
},
}
}
4 changes: 4 additions & 0 deletions cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ netease163 is an independent tool and is not affiliated with NetEase.`,
root.AddCommand(
app.newsCmd(),
app.channelsCmd(),
app.articleCmd(),
app.hotCmd(),
app.searchCmd(),
app.commentsCmd(),
newVersionCmd(),
)
return root
Expand Down
99 changes: 99 additions & 0 deletions netease163/article.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package netease163

import (
"context"
"encoding/json"
"fmt"
"regexp"
"strings"
)

// Article fetches the full detail for a single article identified by docid or URL.
// If the argument looks like a URL, the docid is extracted from it.
func (c *Client) Article(ctx context.Context, docidOrURL string) (ArticleDetail, error) {
docid := docidOrURL
if strings.Contains(docidOrURL, "/") {
docid = extractDocID(docidOrURL)
}
if docid == "" {
return ArticleDetail{}, fmt.Errorf("cannot extract docid from %q", docidOrURL)
}

url := c.cfg.ArticleBaseURL + "/touch/reconstruct/article/info/" + docid + ".json"
raw, err := c.get(ctx, url)
if err != nil {
return ArticleDetail{}, err
}

var resp wireArticleResp
if err := json.Unmarshal(raw, &resp); err != nil {
return ArticleDetail{}, fmt.Errorf("parse article %s: %w", docid, err)
}
if resp.Code != 200 {
return ArticleDetail{}, fmt.Errorf("article %s: server returned code %d", docid, resp.Code)
}

d := resp.Data
tags := make([]string, 0, len(d.Tags))
for _, t := range d.Tags {
if t.Name != "" {
tags = append(tags, t.Name)
}
}

return ArticleDetail{
DocID: docid,
Title: d.Title,
URL: "https://www.163.com/dy/article/" + docid + ".html",
Source: d.Source,
Editor: d.Editor,
PublishedAt: unixToRFC3339(d.PTime),
Digest: d.Digest,
Body: stripHTML(d.Body),
Keywords: d.Keywords,
Tags: tags,
CommentCount: d.ReplyCount,
}, nil
}

// wire types for article detail API.
type wireArticleResp struct {
Code int `json:"code"`
Data wireArticleData `json:"data"`
}

type wireArticleData struct {
Title string `json:"title"`

Check failure on line 66 in netease163/article.go

View workflow job for this annotation

GitHub Actions / lint

File is not properly formatted (gofmt)
Source string `json:"source"`
Editor string `json:"editor"`
Body string `json:"body"`
Keywords []string `json:"keywords"`
Tags []wireTag `json:"tags"`
ReplyCount int `json:"replyCount"`
PTime string `json:"ptime"`
Digest string `json:"digest"`
}

type wireTag struct {
Name string `json:"name"`
}

// stripHTML removes script/style blocks and HTML tags, converting <p>/<br> to newlines.
var (
reScript = regexp.MustCompile(`(?is)<script[^>]*>.*?</script>`)
reStyle = regexp.MustCompile(`(?is)<style[^>]*>.*?</style>`)
rePBR = regexp.MustCompile(`(?i)</?(?:p|br)\s*/?>`)
reTag = regexp.MustCompile(`<[^>]+>`)
reSpace = regexp.MustCompile(`[ \t]+`)
reLines = regexp.MustCompile(`\n{3,}`)
)

func stripHTML(s string) string {
s = reScript.ReplaceAllString(s, "")
s = reStyle.ReplaceAllString(s, "")
s = rePBR.ReplaceAllString(s, "\n")
s = reTag.ReplaceAllString(s, "")
s = reSpace.ReplaceAllString(s, " ")
s = reLines.ReplaceAllString(s, "\n\n")
return strings.TrimSpace(s)
}
134 changes: 134 additions & 0 deletions netease163/article_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package netease163_test

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/tamnd/netease163-cli/netease163"
)

func newArticleTestClient(ts *httptest.Server) *netease163.Client {
cfg := netease163.DefaultConfig()
cfg.ArticleBaseURL = ts.URL
cfg.Rate = 0
return netease163.NewClient(cfg)
}

func TestArticleParseResponse(t *testing.T) {
payload := map[string]any{
"code": 200,
"data": map[string]any{
"title": "测试文章",
"source": "测试来源",
"editor": "编辑B",
"body": "<p>正文内容</p><br/>更多内容",
"keywords": []string{"kw1", "kw2"},
"tags": []map[string]string{{"name": "tag1"}},
"replyCount": 42,
"ptime": "1718286600",
"digest": "摘要",
},
}
b, _ := json.Marshal(payload)

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(b)
}))
defer srv.Close()

c := newArticleTestClient(srv)
detail, err := c.Article(context.Background(), "TESTDOCID123456")
if err != nil {
t.Fatal(err)
}
if detail.DocID != "TESTDOCID123456" {
t.Errorf("docid = %q, want TESTDOCID123456", detail.DocID)
}
if detail.Title != "测试文章" {
t.Errorf("title = %q", detail.Title)
}
if detail.Source != "测试来源" {
t.Errorf("source = %q", detail.Source)
}
if detail.CommentCount != 42 {
t.Errorf("comment_count = %d, want 42", detail.CommentCount)
}
if len(detail.Keywords) != 2 {
t.Errorf("keywords len = %d, want 2", len(detail.Keywords))
}
if len(detail.Tags) != 1 || detail.Tags[0] != "tag1" {
t.Errorf("tags = %v", detail.Tags)
}
if detail.Body == "" {
t.Error("body should not be empty after HTML strip")
}
}

func TestArticleFromURL(t *testing.T) {
payload := map[string]any{
"code": 200,
"data": map[string]any{
"title": "URL test",
"source": "src",
"keywords": []string{},
"tags": []map[string]string{},
"replyCount": 0,
"ptime": "1718286600",
},
}
b, _ := json.Marshal(payload)

var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
_, _ = w.Write(b)
}))
defer srv.Close()

c := newArticleTestClient(srv)
_, err := c.Article(context.Background(), "https://www.163.com/dy/article/URLTEST1234567.html")
if err != nil {
t.Fatal(err)
}
if gotPath != "/touch/reconstruct/article/info/URLTEST1234567.json" {
t.Errorf("unexpected path: %s", gotPath)
}
}

func TestArticleNotFound(t *testing.T) {
payload := map[string]any{"code": 404, "data": map[string]any{}}
b, _ := json.Marshal(payload)

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(b)
}))
defer srv.Close()

c := newArticleTestClient(srv)
_, err := c.Article(context.Background(), "NOTFOUNDDOCID00")
if err == nil {
t.Fatal("expected error for code 404, got nil")
}
}

func TestArticleSendsUserAgent(t *testing.T) {
payload := map[string]any{
"code": 200,
"data": map[string]any{"title": "t", "source": "s", "ptime": "0", "keywords": []string{}, "tags": []map[string]string{}},
}
b, _ := json.Marshal(payload)

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("User-Agent") == "" {
t.Error("missing User-Agent on article request")
}
_, _ = w.Write(b)
}))
defer srv.Close()

c := newArticleTestClient(srv)
_, _ = c.Article(context.Background(), "AGENTTEST123456")
}
Loading
Loading