diff --git a/internal/model/reader.go b/internal/model/reader.go index daf6bc7..9c3a7d6 100644 --- a/internal/model/reader.go +++ b/internal/model/reader.go @@ -28,6 +28,9 @@ type ReaderBookSource struct { RespondTime int64 `json:"respond_time"` // 最近一次调试响应耗时(ms) // HasLogin 是否声明了登录能力(loginUrl/loginUi),列表接口按需计算,不落库。 HasLogin bool `gorm:"-" json:"has_login"` + // NeedsBrowser 是否依赖 WebView/无头浏览器(webView 选项或 webjs 规则)。 + // 服务端没有浏览器,这类源注定不可用,列表接口按需计算让前端能提前提示。 + NeedsBrowser bool `gorm:"-" json:"needs_browser"` } // ReaderSourceState 书源会话状态:对应 legado 中按书源 key 存储的 diff --git a/internal/service/reader/content_rule_script_test.go b/internal/service/reader/content_rule_script_test.go new file mode 100644 index 0000000..16684c5 --- /dev/null +++ b/internal/service/reader/content_rule_script_test.go @@ -0,0 +1,55 @@ +package reader + +import ( + "strings" + "testing" + + "github.com/truewhile/MeBox/internal/model" +) + +// TestContentRuleJSReceivesJoinedString 覆盖拷贝漫画(优++)的正文规则写法: +// +// $..url +// result.split("\n").map(x=>'').join("\n") +// +// JSON 段先命中「多张图片的 url 列表」,JS 段拿到的必须是按 \n 拼好的字符串 +// (对应 legado BookContent 用 analyzeRule.getString 求值正文规则), +// 喂成数组就会 TypeError: Object has no member 'split',整章图片全取不到。 +func TestContentRuleJSReceivesJoinedString(t *testing.T) { + svc, _ := newLoginTestService(t) + + const srcURL = "https://www.mangacopy.com/" + const contentRule = "$..url\nresult.split(\"\\n\").map(x=>'').join(\"\\n\")" + header := "" + src := &model.ReaderBookSource{Name: "拷贝漫画(优++)", SourceURL: srcURL} + bs := &BookSource{ + BookSourceURL: srcURL, + RuleContent: &ContentRule{Content: strPtr(contentRule)}, + Header: &header, + } + + body := `{"results":{"chapter":{"contents":[` + + `{"url":"https://img.example.com/1.webp"},` + + `{"url":"https://img.example.com/2.webp"}]}}}` + + sess := svc.newSession(t.Context(), src, bs) + defer sess.close() + ar := sess.newAnalyzer("", 0, body, "https://api.mangacopy.com/api/v3/chapter/x") + + got, err := ar.GetString(contentRule, nil, false) + if err != nil { + t.Fatalf("正文规则求值失败: %v", err) + } + want := "\n" + if got != want { + t.Fatalf("正文 = %q\n期望 %q", got, want) + } + // 图片提取(漫画分支):每行一个 ,应抽出 2 个真实地址 + refs := 0 + for _, line := range strings.Split(got, "\n") { + refs += len(imageRefsInLine(line)) + } + if refs != 2 { + t.Fatalf("从正文抽出 %d 张图,期望 2", refs) + } +} diff --git a/internal/service/reader/rate_limit.go b/internal/service/reader/rate_limit.go new file mode 100644 index 0000000..5aca6b2 --- /dev/null +++ b/internal/service/reader/rate_limit.go @@ -0,0 +1,111 @@ +package reader + +import ( + "context" + "strconv" + "strings" + "sync" + "time" +) + +// sourceRateLimiter 单源限速。 +// +// 书源用 concurrentRate 声明「请求该源的最小间隔」,风控严格的站点靠它避免被封 +// (不实现的话,搜索/目录/正文连着打过去很容易被拦,表现为「有时能用有时不能用」)。 +// 对应 legado 的 ConcurrentRateLimiter,支持两种写法: +// +// "1000" 每次请求至少间隔 1000ms +// "3/1000" 每 1000ms 最多 3 次(折算成 333ms 间隔) +// +// 实现为「按源排槽位」:同一书源的请求被排成固定间隔,天然串行。 +type sourceRateLimiter struct { + mu sync.Mutex + slots map[string]time.Time +} + +// maxRateGapMs 允许的最大间隔,避免书源写出离谱的值把请求卡死。 +const maxRateGapMs = 30000 + +func newSourceRateLimiter() *sourceRateLimiter { + return &sourceRateLimiter{slots: map[string]time.Time{}} +} + +// wait 按源限速等待;key 为空或 rate 无法解析时不等待。 +func (l *sourceRateLimiter) wait(ctx context.Context, key, rate string) { + gap := parseConcurrentRate(rate) + if gap <= 0 || key == "" { + return + } + now := time.Now() + l.mu.Lock() + slot := now + if next, ok := l.slots[key]; ok && next.After(now) { + slot = next + } + l.slots[key] = slot.Add(gap) + l.mu.Unlock() + sleepWithContext(ctx, slot.Sub(now)) +} + +// parseConcurrentRate 解析 concurrentRate,返回两次请求的最小间隔;无法解析返回 0。 +func parseConcurrentRate(rate string) time.Duration { + rate = strings.TrimSpace(rate) + if rate == "" { + return 0 + } + gapMs := 0 + if i := strings.IndexByte(rate, '/'); i > 0 { + count, errCount := strconv.Atoi(strings.TrimSpace(rate[:i])) + interval, errInterval := strconv.Atoi(strings.TrimSpace(rate[i+1:])) + if errCount != nil || errInterval != nil || count <= 0 || interval <= 0 { + return 0 + } + gapMs = interval / count + } else { + n, err := strconv.Atoi(rate) + if err != nil || n <= 0 { + return 0 + } + gapMs = n + } + if gapMs <= 0 { + return 0 + } + if gapMs > maxRateGapMs { + gapMs = maxRateGapMs + } + return time.Duration(gapMs) * time.Millisecond +} + +const ( + // maxRequestAttempts 单次请求最大尝试次数(含首次),兜住书源里写很大的 retry。 + maxRequestAttempts = 5 + // requestRetryDelay 请求重试退避基数,实际等待为 attempt 倍。 + requestRetryDelay = 300 * time.Millisecond +) + +// retryableStatus 该状态码是否值得重试:限流(403/429)与上游抖动(5xx)可重试, +// 404/400 这类确定性错误不重试。规则请求与媒体代理共用。 +func retryableStatus(code int) bool { + switch code { + case 403, 408, 425, 429, 500, 502, 503, 504: + return true + default: + return false + } +} + +// sleepWithContext 可被 ctx 取消的等待;返回 false 表示上下文已结束。 +func sleepWithContext(ctx context.Context, d time.Duration) bool { + if d <= 0 { + return true + } + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} diff --git a/internal/service/reader/rate_limit_test.go b/internal/service/reader/rate_limit_test.go new file mode 100644 index 0000000..f697f8c --- /dev/null +++ b/internal/service/reader/rate_limit_test.go @@ -0,0 +1,73 @@ +package reader + +import ( + "testing" + "time" +) + +// TestParseConcurrentRate 书源 concurrentRate 的两种写法。 +func TestParseConcurrentRate(t *testing.T) { + cases := []struct { + in string + want time.Duration + }{ + {"", 0}, + {"0", 0}, + {"abc", 0}, + {"1000", 1000 * time.Millisecond}, + {" 500 ", 500 * time.Millisecond}, + {"3/1000", 333 * time.Millisecond}, + {"1/2000", 2000 * time.Millisecond}, + {"3/0", 0}, + {"0/1000", 0}, + {"999999", time.Duration(maxRateGapMs) * time.Millisecond}, // 上限夹紧 + } + for _, c := range cases { + if got := parseConcurrentRate(c.in); got != c.want { + t.Errorf("parseConcurrentRate(%q) = %v,期望 %v", c.in, got, c.want) + } + } +} + +// TestRateLimiterSpacesRequests 同一个源连续两次请求之间至少间隔一个周期。 +func TestRateLimiterSpacesRequests(t *testing.T) { + l := newSourceRateLimiter() + start := time.Now() + l.wait(t.Context(), "src", "120") // 120ms + l.wait(t.Context(), "src", "120") + if elapsed := time.Since(start); elapsed < 110*time.Millisecond { + t.Fatalf("两次请求只隔了 %v,没有按 concurrentRate 限速", elapsed) + } + // 不同源互不影响 + start = time.Now() + l.wait(t.Context(), "other", "500") + if elapsed := time.Since(start); elapsed > 50*time.Millisecond { + t.Fatalf("换源后不该等待,实际 %v", elapsed) + } +} + +// TestRateLimiterIgnoresEmptyRate 未声明 concurrentRate 时不引入任何等待。 +func TestRateLimiterIgnoresEmptyRate(t *testing.T) { + l := newSourceRateLimiter() + start := time.Now() + for i := 0; i < 5; i++ { + l.wait(t.Context(), "src", "") + } + if elapsed := time.Since(start); elapsed > 50*time.Millisecond { + t.Fatalf("未声明限速却等待了 %v", elapsed) + } +} + +// TestRetryableStatus 只有可恢复的失败才重试。 +func TestRetryableStatus(t *testing.T) { + for _, code := range []int{403, 429, 500, 502, 503, 504} { + if !retryableStatus(code) { + t.Errorf("状态码 %d 应当可重试", code) + } + } + for _, code := range []int{200, 301, 400, 401, 404, 410} { + if retryableStatus(code) { + t.Errorf("状态码 %d 不该重试", code) + } + } +} diff --git a/internal/service/reader/reader.go b/internal/service/reader/reader.go index 397ddb6..155e2df 100644 --- a/internal/service/reader/reader.go +++ b/internal/service/reader/reader.go @@ -47,16 +47,20 @@ type ReaderService struct { // (java.startBrowser / startBrowserAwait,见 browser_panel.go)。 browserMu sync.Mutex browserPending map[string]*pendingBrowser + + // limiter 单源限速(书源 concurrentRate)。 + limiter *sourceRateLimiter } // NewReaderService 创建服务。 func NewReaderService(cfg *config.Config, log *zap.Logger, repos *repository.Container) *ReaderService { return &ReaderService{ - cfg: cfg, - log: log, - repo: repos.Reader, - http: helper.NewSiteHTTPClient(30, true), - crypto: helper.NewSecretCipher(firstNonEmpty(cfg.Secrets.EncryptionKey, cfg.Secrets.JWTSecret)), + cfg: cfg, + log: log, + repo: repos.Reader, + http: helper.NewSiteHTTPClient(30, true), + crypto: helper.NewSecretCipher(firstNonEmpty(cfg.Secrets.EncryptionKey, cfg.Secrets.JWTSecret)), + limiter: newSourceRateLimiter(), } } @@ -83,9 +87,15 @@ func (s *ReaderService) ImportSources(ctx context.Context, text string) (int, er return 0, fmt.Errorf("未识别到有效书源(支持 JSON 数组/对象或 Base64)") } imported := 0 - for _, raw := range sources { + var failures []string + for i, raw := range sources { bs, err := ParseBookSource(raw) - if err != nil || bs.BookSourceURL == "" { + if err != nil { + failures = append(failures, fmt.Sprintf("第 %d 条解析失败: %v", i+1, err)) + continue + } + if bs.BookSourceURL == "" { + failures = append(failures, fmt.Sprintf("第 %d 条缺少 bookSourceUrl", i+1)) continue } // 已存在则更新,否则新建(按书源 URL 去重) @@ -124,14 +134,23 @@ func (s *ReaderService) ImportSources(ctx context.Context, text string) (int, er existing.LastCheckAt = &now if err := s.repo.UpdateSource(ctx, existing); err == nil { imported++ + } else { + failures = append(failures, fmt.Sprintf("%s: %v", record.Name, err)) } continue } record.LastCheckAt = &now if err := s.repo.CreateSource(ctx, record); err == nil { imported++ + } else { + failures = append(failures, fmt.Sprintf("%s: %v", record.Name, err)) } } + // 一条都没进来时不能只回 0:前端只会弹一句「成功导入 0 个书源」, + // 用户完全不知道哪里不对。把第一条失败原因带回去,让界面能说清楚。 + if imported == 0 && len(failures) > 0 { + return 0, fmt.Errorf("书源导入失败:%s", failures[0]) + } return imported, nil } @@ -207,10 +226,21 @@ func (s *ReaderService) ListSources(ctx context.Context) ([]model.ReaderBookSour } for i := range sources { sources[i].HasLogin = rawSourceHasLogin(sources[i].RawJSON) + sources[i].NeedsBrowser = rawSourceNeedsBrowser(sources[i].RawJSON) } return sources, nil } +// rawSourceNeedsBrowser 粗判书源是否依赖 WebView(webView 请求选项 / webjs 规则)。 +// +// 服务端没有无头浏览器,这类源注定跑不通;列表先标出来,用户不用等到报错才知道。 +// 用字符串粗扫而不是完整解析:这些标记只出现在规则/选项里,误报代价也只是多一个提示。 +func rawSourceNeedsBrowser(rawJSON string) bool { + lower := strings.ToLower(rawJSON) + return strings.Contains(lower, "webview") || strings.Contains(lower, "webjs") || + strings.Contains(lower, "\"webview\"") +} + // rawSourceHasLogin 只解出 loginUrl/loginUi 两个字段判断登录能力。 // 列表接口按需计算,避免为了一个布尔值把每个书源的完整 JSON 都反序列化。 func rawSourceHasLogin(rawJSON string) bool { @@ -249,10 +279,6 @@ func (s *ReaderService) execute(ctx context.Context, req *rule.Request) (string, // executeWithState 在 execute 基础上叠加会话:附加 Cookie / loginHeader, // 并在 captureCookies 为真时把响应 Set-Cookie 回写到会话。 func (s *ReaderService) executeWithState(ctx context.Context, req *rule.Request, state *sourceState, captureCookies bool) (string, string, int, error) { - var bodyReader io.Reader - if req.Body != "" { - bodyReader = strings.NewReader(req.Body) - } // 请求目标是含 query 的完整 URL:对应 legado 的 `get(urlNoQuery, encodedQuery)` // (两端拼起来才是最终地址)。ParseAnalyzeUrl 已把重编码后的 query 放进 // req.URL。早期这里误用 URLNoQuery,导致所有「参数写在 query 里」的 GET @@ -283,36 +309,77 @@ func (s *ReaderService) executeWithState(ctx context.Context, req *rule.Request, } } } - httpReq, err := http.NewRequestWithContext(ctx, req.Method, target, bodyReader) - if err != nil { - return "", "", 0, err + // 请求构造抽成闭包:重试时必须重建请求(body 读取器只能消费一次)。 + buildRequest := func() (*http.Request, error) { + var bodyReader io.Reader + if req.Body != "" { + bodyReader = strings.NewReader(req.Body) + } + httpReq, err := http.NewRequestWithContext(ctx, req.Method, target, bodyReader) + if err != nil { + return nil, err + } + for k, v := range helper.HTTPHeaderPresets() { + httpReq.Header.Set(k, v) + } + for k, v := range req.Headers { + httpReq.Header.Set(k, v) + } + // Accept-Encoding 必须留给 net/http:只有调用方没设置时它才会自动解压, + // 否则压缩响应会以原始字节进入规则层(书源的 JSON.parse 会直接炸)。 + // 书源 JSON 的 header 字段也可能塞了这个头,所以放在最后统一清掉。 + helper.StripAcceptEncoding(httpReq.Header) + if req.Method == "POST" { + switch { + case req.IsForm: + httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + case req.IsJSON: + httpReq.Header.Set("Content-Type", "application/json") + } + } + return httpReq, nil } - for k, v := range helper.HTTPHeaderPresets() { - httpReq.Header.Set(k, v) - } - for k, v := range req.Headers { - httpReq.Header.Set(k, v) - } - // Accept-Encoding 必须留给 net/http:只有调用方没设置时它才会自动解压, - // 否则压缩响应会以原始字节进入规则层(书源的 JSON.parse 会直接炸)。 - // 书源 JSON 的 header 字段也可能塞了这个头,所以放在最后统一清掉。 - helper.StripAcceptEncoding(httpReq.Header) - if req.Method == "POST" { - switch { - case req.IsForm: - httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") - case req.IsJSON: - httpReq.Header.Set("Content-Type", "application/json") + + // retry:URL 选项声明的重试次数(对应 legado AnalyzeUrl 的同名字段)。 + // 只重试可恢复的失败:网络错误、5xx、403/429(防盗链/限流)。确定性 4xx 不重试。 + attempts := 1 + if req.Retry != nil && *req.Retry > 0 { + attempts += *req.Retry + if attempts > maxRequestAttempts { + attempts = maxRequestAttempts } } - resp, err := s.http.Do(httpReq) - if err != nil { - return "", "", 0, err - } - defer resp.Body.Close() - data, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes)) - if err != nil { - return "", "", resp.StatusCode, err + var ( + resp *http.Response + data []byte + ) + for attempt := 1; ; attempt++ { + httpReq, err := buildRequest() + if err != nil { + return "", "", 0, err + } + resp, err = s.http.Do(httpReq) + if err != nil { + if attempt < attempts && sleepWithContext(ctx, requestRetryDelay*time.Duration(attempt)) { + continue + } + return "", "", 0, err + } + data, err = io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes)) + _ = resp.Body.Close() + if err != nil { + if attempt < attempts && sleepWithContext(ctx, requestRetryDelay*time.Duration(attempt)) { + continue + } + return "", "", resp.StatusCode, err + } + if attempt < attempts && retryableStatus(resp.StatusCode) { + if !sleepWithContext(ctx, requestRetryDelay*time.Duration(attempt)) { + return "", "", resp.StatusCode, nil + } + continue + } + break } data = helper.DecompressBody(resp, data) // 声明了 type 的请求按「原始字节的 hex」返回(对应 legado AnalyzeUrl.type), @@ -429,6 +496,11 @@ func (sess *sourceSession) close() { // fetch 执行请求,并按书源配置决定是否自动保存响应里的 Cookie。 func (sess *sourceSession) fetch(req *rule.Request) (string, string, int, error) { + // 单源限速(书源 concurrentRate):挂在这里,规则请求与书源 JS 的 java.ajax + // 都会经过,保证同一个源不会并发轰炸站点。 + if sess.svc.limiter != nil { + sess.svc.limiter.wait(sess.ctx, sess.srcURL(), SPtr(sess.bs.ConcurrentRate)) + } body, finalURL, code, err := sess.svc.executeWithState(sess.ctx, req, sess.state, sess.captureCookies()) if err != nil { return body, finalURL, code, err @@ -572,17 +644,45 @@ func (sess *sourceSession) srcName() string { return srcNameOf(sess.src, sess.bs) } -// headerJSON 书源级请求头 JSON。 -func (sess *sourceSession) headerJSON() string { +// headerJSON 书源级请求头(JSON 文本)。 +// +// 对应 legado BaseSource.getHeaderMap:header 有两种写法 —— 直接的 JSON 对象, +// 或者是 @js:/ 规则在运行时拼出来(拷贝漫画就是后者:用 baseUrl 拼 referer、 +// 带上 platform/version)。之前这里只做 json.Unmarshal,JS 形态会被整体丢掉, +// 请求少了这些必需头,上游照样回 200,但结果是空列表 —— 表现为「书源能导入、 +// 却搜不到任何内容」。JS 求值失败时返回空串,宁可不带自定义头也不发半成品。 +func (sess *sourceSession) headerJSON(runner *rule.JSRunner) string { + raw := "" if sess.src != nil && sess.src.Header != "" { - return sess.src.Header + raw = sess.src.Header + } else { + raw = SPtr(sess.bs.Header) } - return SPtr(sess.bs.Header) + js, isJS := stripJSWrapperOK(raw) + if !isJS { + return raw + } + if runner == nil { + return "" + } + // baseUrl 绑成书源地址:legado 在 getHeaderMap 里用的也是 getKey()(bookSourceUrl)。 + v, err := runner.Run(nil, js, nil, sess.srcURL()) + if err != nil { + if sess.svc.log != nil { + sess.svc.log.Warn("reader: 执行书源请求头规则失败", + zap.String("source", sess.srcName()), zap.Error(err)) + } + return "" + } + if s, ok := v.(string); ok { + return strings.TrimSpace(s) + } + return strings.TrimSpace(fmt.Sprintf("%v", v)) } // applyHeaders 把书源级请求头合并进请求(不覆盖已显式设置的值)。 -func (sess *sourceSession) applyHeaders(req *rule.Request) { - raw := sess.headerJSON() +func (sess *sourceSession) applyHeaders(req *rule.Request, runner *rule.JSRunner) { + raw := sess.headerJSON(runner) if raw == "" { return } @@ -792,7 +892,7 @@ func (s *ReaderService) searchInSource(ctx context.Context, src *model.ReaderBoo if req.Unsupported != nil { return nil, req.Unsupported } - sess.applyHeaders(req) + sess.applyHeaders(req, runner) body, finalURL, _, err := sess.fetch(req) if err != nil { return nil, err @@ -838,6 +938,14 @@ func (s *ReaderService) searchInSource(ctx context.Context, src *model.ReaderBoo }}, }) } + // 一条结果都没有时,用书源声明的校验关键字判断是「真没这本书」还是「被风控了」。 + // legado 用 checkKeyWord 做同样的事(源失效/被拦截时响应里不会出现它)。 + // 这里只把它当成失败原因的提示,不改变「有结果就返回结果」的行为。 + if len(books) == 0 { + if kw := bs.SearchCheckKeyWord(); kw != "" && !strings.Contains(body, kw) { + return nil, fmt.Errorf("搜索结果为空,且响应未包含校验关键字「%s」(源可能已失效或触发风控)", kw) + } + } return books, nil } @@ -927,7 +1035,7 @@ func (s *ReaderService) getBookInfoFrom(ctx context.Context, src *model.ReaderBo if req.Unsupported != nil { return nil, req.Unsupported } - sess.applyHeaders(req) + sess.applyHeaders(req, runner) body, finalURL, _, err := sess.fetch(req) if err != nil { return nil, err @@ -1122,32 +1230,101 @@ func (s *ReaderService) getTocFrom(ctx context.Context, src *model.ReaderBookSou } sess := s.newSession(ctx, src, bs) defer sess.close() + + declaredType := -1 + var chapters []TocChapter + seenChapter := map[string]bool{} + visited := map[string]bool{} + // 用队列收集待抓页:书源有两种写法 —— 有的只给「下一页」,有的一次给出全部 + // 后续页(拷贝漫画就是后者,且它的规则锚定 offset=0,只有第一页能算出完整列表)。 + // 每页都再看一次 nextTocUrl 并去重入队,两种写法都能覆盖。 + queue := []string{tocURL} + fetched := 0 + for len(queue) > 0 && fetched < maxTocPages { + current := queue[0] + queue = queue[1:] + if current == "" || visited[current] { + continue + } + visited[current] = true + fetched++ + + pageChapters, nextURLs, declared, err := s.fetchTocPage(ctx, sess, bs, current, bookURL) + if err != nil { + // 第一页失败才算整体失败;后续页失败保留已经拿到的章节 + if fetched == 1 { + return nil, -1, err + } + continue + } + if declared >= 0 { + declaredType = declared + } + for _, ch := range pageChapters { + if seenChapter[ch.URL] { + continue + } + seenChapter[ch.URL] = true + ch.Index = len(chapters) + chapters = append(chapters, ch) + } + for _, u := range nextURLs { + if u != "" && !visited[u] { + queue = append(queue, u) + } + } + } + return chapters, declaredType, nil +} + +// maxTocPages 目录翻页上限。书源的 nextTocUrl 规则可能给出自引用或极长的链路 +// (legado 靠协程取消兜底),这里用硬上限 + 已访问集合双重保护。 +const maxTocPages = 50 + +// fetchTocPage 抓一页目录,返回该页章节、书源声明的后续页地址、声明的书籍类型。 +// 对应 legado BookChapterList.analyzeChapterList(含 nextTocUrl 处理)。 +func (s *ReaderService) fetchTocPage(ctx context.Context, sess *sourceSession, bs *BookSource, tocURL, bookURL string) ([]TocChapter, []string, int, error) { + tr := bs.RuleToc runner := sess.runner("", 0) req, err := rule.ParseAnalyzeUrlWithJS(tocURL, "", 0, sess.srcURL(), runner) if err != nil { - return nil, -1, err + return nil, nil, -1, err } if req.Unsupported != nil { - return nil, -1, req.Unsupported + return nil, nil, -1, req.Unsupported } - sess.applyHeaders(req) + sess.applyHeaders(req, runner) body, finalURL, _, err := sess.fetch(req) if err != nil { - return nil, -1, err + return nil, nil, -1, err } ar := sess.newAnalyzer("", 0, body, finalURL) sess.applyBookContext(ar, bookURL, nil, "", 0) elements, err := ar.GetElements(SPtr(tr.ChapterList)) if err != nil { - return nil, -1, err + return nil, nil, -1, err } declaredType := -1 if t, ok := ar.BookTypeOverride(); ok { declaredType = normalizeBookType(t) } + + // 下一页目录:对应 legado 的 getStringList(nextTocUrl, isUrl=true),并排除当前页 + // 地址。规则可能一次给出全部后续页(拷贝漫画就是这种写法),也可能每页只给一个。 + var nextURLs []string + if SPtr(tr.NextTocURL) != "" { + if urls, err := ar.GetStringList(SPtr(tr.NextTocURL), nil, true); err == nil { + for _, u := range urls { + if u != "" && u != finalURL && u != tocURL { + nextURLs = append(nextURLs, u) + } + } + } + } + var chapters []TocChapter - for i, el := range elements { + for _, el := range elements { title, err := ar.GetString(SPtr(tr.ChapterName), el, false) if err != nil || title == "" { continue @@ -1164,14 +1341,14 @@ func (s *ReaderService) getTocFrom(ctx context.Context, src *model.ReaderBookSou } updateTime, _ := ar.GetString(SPtr(tr.UpdateTime), el, false) chapters = append(chapters, TocChapter{ - Index: i, Title: title, URL: url, IsVolume: isVolume, UpdateTime: updateTime, + Title: title, URL: url, IsVolume: isVolume, UpdateTime: updateTime, }) } // 章节规则可能逐条执行,取最后一次声明(书源是在元素循环前设置的)。 if t, ok := ar.BookTypeOverride(); ok { declaredType = normalizeBookType(t) } - return chapters, declaredType, nil + return chapters, nextURLs, declaredType, nil } // ChapterContent 章节内容(按类型返回文本/音频/图片)。 @@ -1297,7 +1474,7 @@ func (s *ReaderService) FetchMedia(ctx context.Context, book *model.ReaderBook, lastErr = err continue } - if attempt == mediaFetchAttempts || !mediaRetryableStatus(resp.StatusCode) { + if attempt == mediaFetchAttempts || !retryableStatus(resp.StatusCode) { return resp, nil } // 可重试的状态码:读完并关闭响应体再试,避免连接泄漏。 @@ -1315,19 +1492,6 @@ const ( mediaFetchRetryDelay = 400 * time.Millisecond ) -// mediaRetryableStatus 判断上游状态码是否值得重试。 -// 403/429 是图床并发限流的表现,5xx 是上游抖动。 -func mediaRetryableStatus(code int) bool { - switch code { - case http.StatusForbidden, http.StatusRequestTimeout, http.StatusTooManyRequests, - http.StatusInternalServerError, http.StatusBadGateway, - http.StatusServiceUnavailable, http.StatusGatewayTimeout: - return true - default: - return false - } -} - // sourceReferer 把书源的 origin 转成可用的默认 Referer。 // // 只有 origin 本身是合法的 http(s) 地址时才使用;聚合类书源的 origin 是 @@ -1421,7 +1585,7 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo if req.Unsupported != nil { return nil, req.Unsupported } - sess.applyHeaders(req) + sess.applyHeaders(req, runner) body, finalURL, _, err := sess.fetch(req) if err != nil { return nil, err @@ -1430,14 +1594,19 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo ar := sess.newAnalyzer("", 0, body, finalURL) sess.applyBookContext(ar, bookURL, nil, "", 0) - list, err := ar.GetStringList(SPtr(cr.Content), nil, false) + // 正文规则用 getString 求值(对应 legado BookContent:analyzeRule.getString(contentRule.content))。 + // 关键差别出在 JS 段:getStringList 会把上一段的结果按「列表」交给 JS,而 legado + // 交给 JS 的是按 \n 拼好的字符串。拷贝漫画的正文规则正是 result.split("\n"), + // 喂成列表就报 Object has no member 'split',整章图片都取不到。 + // 对 jsoup / xpath 规则,getString 与 getStringList+join 结果一致,文本源不受影响。 + page, err := ar.GetString(SPtr(cr.Content), nil, false) if err != nil { return nil, err } if t, ok := ar.BookTypeOverride(); ok { declaredType = normalizeBookType(t) } - parts = append(parts, strings.Join(list, "\n")) + parts = append(parts, page) if SPtr(cr.NextContentURL) == "" { break } @@ -1487,6 +1656,46 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo return out, nil } +// rewriteContentImageMarkers 把网络文本正文里「整行就是 」的内容改写成 +// [img]<地址> 标记行,前端据此渲染成图片。 +// +// 对应 legado 的 ruleContent.imageStyle:文本型漫画源(bookSourceType=0,但正文规则 +// 直接给 标签,如拷贝漫画)就是靠它把图片显示出来的;不转换的话读者看到的是 +// 原始的 文本。只处理「整行是标签」的行,不碰正常文本源里夹在段落 +// 中间的内嵌图片,避免改变原有语义。 +func rewriteContentImageMarkers(content, baseURL string, proxy func(string) string) string { + if !strings.Contains(content, " 0 { + for _, ref := range refs { + abs := rule.GetAbsoluteURL(baseURL, ref) + if abs == "" { + continue + } + if proxy != nil { + abs = proxy(abs) + } + out = append(out, imgMarkerPrefix+abs) + changed = true + } + continue + } + } + out = append(out, line) + } + if !changed { + return content + } + return strings.Join(out, "\n") +} + // imageRefsInLine 取出一行正文里的图片地址。 // // - 正文规则给的就是地址 → 原样返回; @@ -1974,6 +2183,13 @@ func (s *ReaderService) GetContentForBook(ctx context.Context, userID, bookID st for i, img := range out.Images { out.Images[i] = s.ProxyURL(book.ID, img) } + // 文本型漫画源:正文里的 标签换成前端认识的 [img] 标记(同样走签名代理, + // 这样需要防盗链头的图片也能显示)。见 rewriteContentImageMarkers 的说明。 + if out.Type == "text" && strings.Contains(out.Content, " 要转成前端认识的 [img] 标记, +// 且地址要绝对化、过签名代理(有些图需要书源防盗链头才取得到)。 +func TestRewriteContentImageMarkers(t *testing.T) { + proxy := func(u string) string { return "/proxy?u=" + url.QueryEscape(u) } + content := "第一章开始\n" + + "\n" + + "\n" + + "中间夹了图片 的这一行不动\n" + + "结束" + got := rewriteContentImageMarkers(content, "https://site.example.com/ch/1", proxy) + lines := strings.Split(got, "\n") + if len(lines) != 5 { + t.Fatalf("行数 = %d,期望 5\n%s", len(lines), got) + } + if lines[0] != "第一章开始" || lines[4] != "结束" { + t.Fatalf("普通文本行被改动: %q / %q", lines[0], lines[4]) + } + if !strings.HasPrefix(lines[1], imgMarkerPrefix) { + t.Fatalf("第 2 行没有变成 [img] 标记: %q", lines[1]) + } + if !strings.Contains(lines[1], url.QueryEscape("https://img.example.com/1.webp")) { + t.Fatalf("第 2 行地址没走代理: %q", lines[1]) + } + // 相对地址按章节地址绝对化 + if !strings.Contains(lines[2], url.QueryEscape("https://site.example.com/img/2.webp")) { + t.Fatalf("相对图片地址没有绝对化: %q", lines[2]) + } + // 段落中间的内嵌图片保持原样,避免破坏文本源语义 + if !strings.Contains(lines[3], " 不该被改写: %q", lines[3]) + } +} + +// TestRewriteContentImageMarkersWithoutProxy 不需要代理时(如预览链路)直接落到绝对地址。 +func TestRewriteContentImageMarkersWithoutProxy(t *testing.T) { + got := rewriteContentImageMarkers(``, "https://site.example.com/ch/1", nil) + if got != imgMarkerPrefix+"https://img.example.com/1.webp" { + t.Fatalf("got %q", got) + } + // 没有 的内容原样返回(含 null 字节之外的普通文本) + const plain = "纯文本\n第二行" + if got := rewriteContentImageMarkers(plain, "", nil); got != plain { + t.Fatalf("无图内容被改动: %q", got) + } +} + +// TestSearchCheckKeyWord 校验关键字的取值规则(对应 legado getCheckKeyword): +// 含 http/::/++/-- 的值是地址或扩展标记,不当作关键字。 +func TestSearchCheckKeyWord(t *testing.T) { + mk := func(kw string) *BookSource { + return &BookSource{RuleSearch: &SearchRule{CheckKeyWord: &kw}} + } + cases := []struct { + in string + want string + }{ + {"登录武林系统", "登录武林系统"}, + {" 空格清理 ", "空格清理"}, + {"", ""}, + {"http://example.com", ""}, + {"a::b", ""}, + {"a++b", ""}, + {"a--b", ""}, + } + for _, c := range cases { + if got := mk(c.in).SearchCheckKeyWord(); got != c.want { + t.Errorf("SearchCheckKeyWord(%q) = %q,期望 %q", c.in, got, c.want) + } + } + if (&BookSource{}).SearchCheckKeyWord() != "" { + t.Fatal("没有 ruleSearch 时应返回空") + } +} + +// TestSearchReportsCheckKeyWordMiss 搜索 0 条且响应里没有书源声明的校验关键字时, +// 必须给出「疑似风控」的原因,而不是静默返回 0 条 —— 用户才知道是源的问题。 +func TestSearchReportsCheckKeyWordMiss(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + // 风控/失效时站点返回的就是这种没有结果的响应 + _, _ = fmt.Fprint(w, `{"code":200,"results":{"list":[]}}`) + })) + defer srv.Close() + + kw := "登录武林系统" + header := "" + src := &model.ReaderBookSource{Name: "校验关键字测试源", SourceURL: srv.URL} + bs := &BookSource{ + BookSourceURL: srv.URL, + Header: &header, + SearchURL: strPtr(srv.URL + "/search?q={{key}}"), + RuleSearch: &SearchRule{ + BookList: strPtr("$.results.list[*]"), + Name: strPtr("$.name"), + BookURL: strPtr("$.url"), + CheckKeyWord: &kw, + }, + } + + svc, _ := newLoginTestService(t) + _, err := svc.searchInSource(t.Context(), src, bs, "火影", 1) + if err == nil { + t.Fatal("0 结果且校验关键字缺失时应返回错误") + } + if !strings.Contains(err.Error(), kw) { + t.Fatalf("错误信息里应带上校验关键字,实际: %v", err) + } +} + +// TestSearchKeepsEmptyResultWhenKeywordPresent 响应里有关键字(站点正常,只是没有匹配的书) +// 时不该报错,只是结果为空。 +func TestSearchKeepsEmptyResultWhenKeywordPresent(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{"code":200,"hint":"登录武林系统","results":{"list":[]}}`) + })) + defer srv.Close() + + kw := "登录武林系统" + header := "" + src := &model.ReaderBookSource{Name: "校验关键字测试源", SourceURL: srv.URL} + bs := &BookSource{ + BookSourceURL: srv.URL, + Header: &header, + SearchURL: strPtr(srv.URL + "/search?q={{key}}"), + RuleSearch: &SearchRule{ + BookList: strPtr("$.results.list[*]"), + Name: strPtr("$.name"), + BookURL: strPtr("$.url"), + CheckKeyWord: &kw, + }, + } + + svc, _ := newLoginTestService(t) + books, err := svc.searchInSource(t.Context(), src, bs, "火影", 1) + if err != nil { + t.Fatalf("站点正常时不该报错: %v", err) + } + if len(books) != 0 { + t.Fatalf("结果数 = %d,期望 0", len(books)) + } +} diff --git a/internal/service/reader/rule/analyzer.go b/internal/service/reader/rule/analyzer.go index 5c22694..2b25483 100644 --- a/internal/service/reader/rule/analyzer.go +++ b/internal/service/reader/rule/analyzer.go @@ -332,13 +332,14 @@ func (a *AnalyzeRule) getStringListRules(ruleList []*SourceRule, mContent any, i result = strings.Split(s, "\n") } if isUrl { + // 这里必须接受任何切片形态:书源的下一页地址/章节地址规则常常由 JS 生成, + // goja 导出的是 []any。早期只认 []string,JS 给的地址数组会被静默丢掉, + // 表现为「nextTocUrl 明明有规则却永远不翻页」。 var urlList []string - if lst, ok := result.([]string); ok { - for _, u := range lst { - abs := a.absolutize(u) - if abs != "" && !containsStr(urlList, abs) { - urlList = append(urlList, abs) - } + for _, u := range resultStrings(result) { + abs := a.absolutize(u) + if abs != "" && !containsStr(urlList, abs) { + urlList = append(urlList, abs) } } return urlList, nil @@ -363,6 +364,32 @@ func (a *AnalyzeRule) getStringListRules(ruleList []*SourceRule, mContent any, i } } +// resultStrings 把任意分析结果归一成字符串切片([]string / []any / []*html.Node / 单值)。 +func resultStrings(result any) []string { + switch t := result.(type) { + case nil: + return nil + case []string: + return t + case []any: + out := make([]string, 0, len(t)) + for _, v := range t { + out = append(out, anyToString(v)) + } + return out + case []*html.Node: + out := make([]string, 0, len(t)) + for _, v := range t { + out = append(out, outerHTML(v)) + } + return out + case string: + return strings.Split(t, "\n") + default: + return []string{anyToString(result)} + } +} + // GetString 对应 getString(rule, mContent, isUrl)。 func (a *AnalyzeRule) GetString(ruleStr string, mContent any, isUrl bool) (string, error) { if ruleStr == "" { diff --git a/internal/service/reader/rule/analyzer_url_list_test.go b/internal/service/reader/rule/analyzer_url_list_test.go new file mode 100644 index 0000000..e5702fa --- /dev/null +++ b/internal/service/reader/rule/analyzer_url_list_test.go @@ -0,0 +1,40 @@ +package rule + +import ( + "strings" + "testing" +) + +// TestGetStringListURLFromJSArray URL 类规则(nextTocUrl / chapterUrl / coverUrl…) +// 由 JS 生成时,goja 导出的是 []any。早期实现只认 []string,JS 给的地址数组会被 +// 静默丢掉,表现为「书源写了 nextTocUrl 却永远不翻页」。 +func TestGetStringListURLFromJSArray(t *testing.T) { + ar := NewAnalyzeRule() + ar.SetContent(`{"total":399,"limit":100}`, "https://api.example.com/toc?limit=100&offset=0") + ar.SetJSRunner(func(js string, result any) (any, error) { + return NewJSRunner(JSConfig{BaseURL: "https://api.example.com/toc?limit=100&offset=0"}).Run(nil, js, result, "https://api.example.com/toc?limit=100&offset=0") + }) + + // JS 返回数组(真实书源就是这种写法:一次给出全部后续页) + rule := `['https://api.example.com/toc?offset=100','https://api.example.com/toc?offset=200']` + urls, err := ar.GetStringList(rule, nil, true) + if err != nil { + t.Fatalf("err=%v", err) + } + if len(urls) != 2 { + t.Fatalf("拿到 %d 个地址,期望 2(JS 数组被丢掉了): %v", len(urls), urls) + } + if urls[0] != "https://api.example.com/toc?offset=100" || urls[1] != "https://api.example.com/toc?offset=200" { + t.Fatalf("地址不对: %v", urls) + } + + // 相对地址要按当前页绝对化 + ruleRel := `['/toc?offset=300']` + urls, err = ar.GetStringList(ruleRel, nil, true) + if err != nil { + t.Fatalf("err=%v", err) + } + if len(urls) != 1 || !strings.HasPrefix(urls[0], "https://api.example.com/toc?offset=300") { + t.Fatalf("相对地址没有绝对化: %v", urls) + } +} diff --git a/internal/service/reader/rule/bridge.go b/internal/service/reader/rule/bridge.go index 23de256..d3316eb 100644 --- a/internal/service/reader/rule/bridge.go +++ b/internal/service/reader/rule/bridge.go @@ -13,6 +13,7 @@ import ( "strings" "github.com/dop251/goja" + "github.com/google/uuid" ) // 本文件对应 legado JsExtensions / JsEncodeUtils / RegexJsExtensions 中 @@ -401,9 +402,38 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object { set("getWebViewUA", func(call goja.FunctionCall) goja.Value { return vm.ToValue(defaultWebViewUA) }) - set("t2s", func(call goja.FunctionCall) goja.Value { return vm.ToValue(stringArg(call, 0)) }) - set("s2t", func(call goja.FunctionCall) goja.Value { return vm.ToValue(stringArg(call, 0)) }) + // 简繁转换(对应 legado ChineseUtils → quick-transfer)。这里按单字映射实现, + // 见 zh_convert.go 的说明。 + set("t2s", func(call goja.FunctionCall) goja.Value { return vm.ToValue(zhToSimplified(stringArg(call, 0))) }) + set("s2t", func(call goja.FunctionCall) goja.Value { return vm.ToValue(zhToTraditional(stringArg(call, 0))) }) set("htmlFormat", func(call goja.FunctionCall) goja.Value { return vm.ToValue(stringArg(call, 0)) }) + // randomUUID:部分书源拿它生成设备标识/请求 ID(缺失会让整条规则抛异常)。 + set("randomUUID", func(call goja.FunctionCall) goja.Value { return vm.ToValue(uuid.NewString()) }) + // toNumChapter:章节标题里的数字规整成阿拉伯数字(见 zh_number.go)。 + set("toNumChapter", func(call goja.FunctionCall) goja.Value { return vm.ToValue(numChapter(stringArg(call, 0))) }) + // toURL(url[, baseUrl]):对应 legado JsURL(host/origin/pathname/searchParams)。 + set("toURL", func(call goja.FunctionCall) goja.Value { + base := "" + if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { + base = call.Arguments[1].String() + } + obj, err := newJSURLObject(vm, stringArg(call, 0), base) + if err != nil { + bridgeErr("toURL", err) + } + return obj + }) + // logType:调试用,打印值的类型(对应 legado 的日志实现)。 + set("logType", func(call goja.FunctionCall) goja.Value { + if r.cfg.Log != nil { + if len(call.Arguments) == 0 || goja.IsNull(call.Arguments[0]) || goja.IsUndefined(call.Arguments[0]) { + r.cfg.Log("null") + } else { + r.cfg.Log(fmt.Sprintf("%T", call.Arguments[0].Export())) + } + } + return goja.Null() + }) // ── 书源会话状态(legado 中 `java` 与 `source` 是同一对象) ── if r.state != nil { @@ -496,21 +526,26 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object { // open / searchBook —— legado 中仅做原生界面跳转(打开搜索页 / 登录页), // 服务端没有对应页面,谎报成功会让书源走错分支 - // 需要真正无头浏览器/本地文件系统的能力:明确抛出不支持 - unsupported := func(name string) func(goja.FunctionCall) goja.Value { + // 需要真正无头浏览器/本地文件系统的能力:明确抛出不支持,并把原因说清楚 + // (书源调试面板会把这句原样显示出来,用户能立刻判断该不该继续折腾这个源)。 + unsupported := func(name, reason string) func(goja.FunctionCall) goja.Value { return func(call goja.FunctionCall) goja.Value { - panic(vm.ToValue("java." + name + " 需要浏览器或本地文件能力,服务端不支持")) + panic(vm.ToValue(fmt.Sprintf("java.%s 服务端不支持:%s", name, reason))) } } for _, name := range []string{ "webView", "webViewGetSource", "webViewGetOverrideUrl", "openVideoPlayer", "getVerificationCode", + } { + set(name, unsupported(name, "需要无头浏览器(WebView)")) + } + for _, name := range []string{ "importScript", "cacheFile", "downloadFile", "readFile", "readTxtFile", "deleteFile", "unzipFile", "un7zFile", "unrarFile", "unArchiveFile", "getTxtInFolder", "getZipStringContent", "getZipByteArrayContent", "getRarStringContent", "get7zStringContent", } { - set(name, unsupported(name)) + set(name, unsupported(name, "需要本地文件/压缩包能力")) } return o diff --git a/internal/service/reader/rule/errors.go b/internal/service/reader/rule/errors.go index 07f6299..1f7b004 100644 --- a/internal/service/reader/rule/errors.go +++ b/internal/service/reader/rule/errors.go @@ -11,5 +11,5 @@ var ( // JS 引擎(goja + java.* 桥)在 P2 阶段接入,届时移除本错误路径。 ErrJsUnsupported = errors.New("书源使用了 JS 规则,当前阶段暂不支持") // ErrWebJSUnsupported 书源依赖 webView/webJs 抓取,服务端无头浏览器不在支持范围。 - ErrWebJSUnsupported = errors.New("书源依赖 webView 抓取,暂不支持") + ErrWebJSUnsupported = errors.New("书源依赖 webView/webJs(需要无头浏览器),服务端不支持;该源请在阅读 App 内使用") ) diff --git a/internal/service/reader/rule/json.go b/internal/service/reader/rule/json.go index d9b2d4f..02dcceb 100644 --- a/internal/service/reader/rule/json.go +++ b/internal/service/reader/rule/json.go @@ -2,6 +2,8 @@ package rule import ( "encoding/json" + "sort" + "strconv" "strings" ) @@ -38,6 +40,8 @@ func jsonRead(root any, path string) any { } // jsonValueToString 对应 Kotlin 的 ob.toString() / joinToString("\n")。 +// 列表按 "\n" 拼接(legado 的 getString 就是这么做的);对象走 Java 的 Map.toString +// 形态(见 javaValueString),书源的正则大量依赖这个形态。 func jsonValueToString(ob any) string { switch t := ob.(type) { case nil: @@ -51,7 +55,63 @@ func jsonValueToString(ob any) string { } return strings.Join(parts, "\n") default: - return anyToString(t) + return javaValueString(t) + } +} + +// javaValueString 按 Java 的 Map/List toString 形态输出 JSON 值。 +// +// 书源规则里的正则就是照着这个形态写的,最典型的是拷贝漫画的作者规则 +// `$.author##.*name=(.*?)\,.*##$1`:它期望拿到 `{name=岸本斉史, alias=…}`。 +// 之前这里用 Go 的 fmt 输出 `map[alias:… name:…]`,`name=` 根本匹配不到, +// 作者字段就退化成一整串 map 文本。 +// +// 已知差异:Java 的 LinkedTreeMap 保留 JSON 里的键顺序,而 Go 的 +// map[string]interface{} 不保序(JSONPath 库也只认这个类型),这里按 **键排序** +// 输出以保证确定性。绝大多数「取某个键」的正则不受影响,但依赖原始键序的正则 +// (如 `.*name=(.*?)\,` 且 name 是最后一个键)仍可能与阅读 App 的结果不同。 +func javaValueString(v any) string { + switch t := v.(type) { + case nil: + return "null" + case string: + return t + case bool: + return strconv.FormatBool(t) + case float64: + return strconv.FormatFloat(t, 'f', -1, 64) + case int: + return strconv.Itoa(t) + case int64: + return strconv.FormatInt(t, 10) + case json.Number: + return t.String() + case []any: + parts := make([]string, len(t)) + for i, e := range t { + parts[i] = javaValueString(e) + } + return "[" + strings.Join(parts, ", ") + "]" + case map[string]any: + keys := make([]string, 0, len(t)) + for k := range t { + keys = append(keys, k) + } + sort.Strings(keys) + var sb strings.Builder + sb.WriteByte('{') + for i, k := range keys { + if i > 0 { + sb.WriteString(", ") + } + sb.WriteString(k) + sb.WriteByte('=') + sb.WriteString(javaValueString(t[k])) + } + sb.WriteByte('}') + return sb.String() + default: + return anyToString(v) } } diff --git a/internal/service/reader/rule/jsurl.go b/internal/service/reader/rule/jsurl.go new file mode 100644 index 0000000..b8ea449 --- /dev/null +++ b/internal/service/reader/rule/jsurl.go @@ -0,0 +1,89 @@ +package rule + +import ( + "fmt" + "net/url" + "strings" + + "github.com/dop251/goja" +) + +// newJSURLObject 构造 legado JsURL 的等效对象(java.toURL)。 +// +// 对应 io.legado.app.utils.JsURL:它只暴露四个属性 —— +// +// host 主机名 +// origin "scheme://host[:port]"(默认端口不写) +// pathname 路径(原样,未解码) +// searchParams 查询参数(值已 URL 解码;同名只留最后一个,与 legado 一致) +// +// legado 返回的是 Java Map,Rhino 下书源会写 searchParams.get("x"), +// 而 goja 里更自然的是 searchParams.x,所以两者都支持。 +func newJSURLObject(vm *goja.Runtime, raw, base string) (*goja.Object, error) { + u, err := parseJSURL(raw, base) + if err != nil { + return nil, err + } + obj := vm.NewObject() + origin := u.Scheme + "://" + u.Hostname() + if port := u.Port(); port != "" { + origin += ":" + port + } + _ = obj.Set("host", u.Hostname()) + _ = obj.Set("origin", origin) + _ = obj.Set("pathname", u.EscapedPath()) + + params := vm.NewObject() + for key, values := range u.Query() { + if len(values) == 0 { + continue + } + _ = params.Set(key, values[len(values)-1]) + } + // searchParams.get(name):兼容 Java Map 的取法;不存在时给 null(对应 Java 的 null)。 + get := func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) == 0 { + return goja.Null() + } + v := params.Get(call.Arguments[0].String()) + if v == nil || goja.IsUndefined(v) { + return goja.Null() + } + return v + } + _ = params.Set("get", get) + _ = obj.Set("searchParams", params) + return obj, nil +} + +// parseJSURL 解析 java.toURL 的入参。base 非空时按基础地址解析相对路径 +// (对应 Java 的 URL(base, url));解析不出绝对地址时报错(Java 会抛 MalformedURLException)。 +func parseJSURL(raw, base string) (*url.URL, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, fmt.Errorf("toURL: 地址为空") + } + if base != "" { + b, err := url.Parse(strings.TrimSpace(base)) + if err != nil || !b.IsAbs() { + return nil, fmt.Errorf("toURL: baseUrl 不是绝对地址: %s", base) + } + rel, err := url.Parse(raw) + if err != nil { + return nil, fmt.Errorf("toURL: %v", err) + } + u := b.ResolveReference(rel) + if !u.IsAbs() || u.Host == "" { + return nil, fmt.Errorf("toURL: 解析结果不是绝对地址: %s", raw) + } + return u, nil + } + u, err := url.Parse(raw) + if err != nil { + return nil, fmt.Errorf("toURL: %v", err) + } + if u.Scheme == "" || u.Host == "" { + return nil, fmt.Errorf("toURL: 不是绝对地址: %s", raw) + } + return u, nil +} diff --git a/internal/service/reader/rule/jsurl_test.go b/internal/service/reader/rule/jsurl_test.go new file mode 100644 index 0000000..befbd22 --- /dev/null +++ b/internal/service/reader/rule/jsurl_test.go @@ -0,0 +1,92 @@ +package rule + +import ( + "strings" + "testing" +) + +// TestJavaValueStringMatchesJavaMapToString 书源正则依赖 Java 的 Map.toString 形态。 +// 真实案例:拷贝漫画的 author 规则 `$.author##.*name=(.*?)\,.*##$1`, +// 期望拿到 `{name=岸本斉史, alias=…}`;Go 的 `map[...]` 形态匹配不到,作者会退化成一串 map 文本。 +func TestJavaValueStringMatchesJavaMapToString(t *testing.T) { + ar := NewAnalyzeRule() + ar.SetContent(`{"author":[{"name":"岸本斉史","alias":"岸本齐史,キシモトマサ","path_word":"anbenqishi"}]}`, "https://api.example.com") + + got, err := ar.GetString(`$.author##.*name=(.*?)\,.*##$1`, nil, false) + if err != nil { + t.Fatalf("err=%v", err) + } + if got != "岸本斉史" { + t.Fatalf("author = %q,期望 岸本斉史(Java Map 形态没对上)", got) + } + + // 对象/数组/标量的字符串形态 + str, err := ar.GetString(`$.author[0]`, nil, false) + if err != nil { + t.Fatalf("err=%v", err) + } + for _, want := range []string{"{", "name=岸本斉史", "alias=岸本齐史,キシモトマサ", "path_word=anbenqishi"} { + if !strings.Contains(str, want) { + t.Errorf("对象字符串 %q 缺少 %q", str, want) + } + } + if strings.Contains(str, "map[") { + t.Errorf("仍是 Go 的 map 形态: %q", str) + } +} + +// TestJavaValueStringScalars 标量与嵌套结构按 Java 习惯输出。 +func TestJavaValueStringScalars(t *testing.T) { + cases := []struct { + in any + want string + }{ + {nil, "null"}, + {"x", "x"}, + {true, "true"}, + {float64(399), "399"}, + {float64(1.5), "1.5"}, + {[]any{float64(1), "a"}, "[1, a]"}, + {map[string]any{"b": float64(1), "a": "x"}, "{a=x, b=1}"}, + {map[string]any{"n": []any{"x", "y"}}, "{n=[x, y]}"}, + } + for _, c := range cases { + if got := javaValueString(c.in); got != c.want { + t.Errorf("javaValueString(%#v) = %q,期望 %q", c.in, got, c.want) + } + } +} + +// TestJavaToURL legado JsURL 的四个属性(并兼容 Java Map 的 searchParams.get)。 +func TestJavaToURL(t *testing.T) { + r := NewJSRunner(JSConfig{}) + ar := NewAnalyzeRule() + + check := func(expr, want string) { + t.Helper() + v, err := r.Run(ar, expr, nil, "") + if err != nil { + t.Fatalf("%s 失败: %v", expr, err) + } + if got := anyToString(v); got != want { + t.Errorf("%s = %q,期望 %q", expr, got, want) + } + } + + check(`java.toURL('https://api.example.com:8443/a/b?x=1&y=%E4%B8%AD%E6%96%87').host`, "api.example.com") + check(`java.toURL('https://api.example.com:8443/a/b?x=1&y=%E4%B8%AD%E6%96%87').origin`, "https://api.example.com:8443") + check(`java.toURL('https://api.example.com/a/b').origin`, "https://api.example.com") + check(`java.toURL('https://api.example.com/a/b?x=1').pathname`, "/a/b") + check(`java.toURL('https://api.example.com/a/b?x=1&y=%E4%B8%AD%E6%96%87').searchParams.x`, "1") + check(`java.toURL('https://api.example.com/a/b?x=1&y=%E4%B8%AD%E6%96%87').searchParams.y`, "中文") + check(`java.toURL('https://api.example.com/a/b?x=1').searchParams.get('x')`, "1") + // 相对地址按 baseUrl 解析(对应 Java 的 URL(base, url)) + check(`java.toURL('/next?p=2', 'https://api.example.com/a/b').origin`, "https://api.example.com") + check(`java.toURL('/next?p=2', 'https://api.example.com/a/b').pathname`, "/next") + // 不存在的参数给 null(对应 Java Map.get 返回 null) + check(`String(java.toURL('https://api.example.com/a').searchParams.get('nope'))`, "null") + // 非绝对地址必须报错(Java 抛 MalformedURLException),不能静默给一个空对象 + if _, err := r.Run(ar, `java.toURL('not-a-url').host`, nil, ""); err == nil { + t.Error("非绝对地址应当报错") + } +} diff --git a/internal/service/reader/rule/url.go b/internal/service/reader/rule/url.go index 579da85..bd93af3 100644 --- a/internal/service/reader/rule/url.go +++ b/internal/service/reader/rule/url.go @@ -48,6 +48,8 @@ type Request struct { // Unsupported 非 nil 表示该请求依赖当前阶段不支持的能力, // 值为对应错误(webView;JS 在接入 runner 后已支持)。 Unsupported error + // Retry 对应 URL 选项的 retry:可恢复失败时的额外重试次数。 + Retry *int } // ParseAnalyzeUrl 对应 AnalyzeUrl.init:URL 规则 → 可执行请求。 @@ -202,6 +204,7 @@ func ParseAnalyzeUrlWithJS(mUrl, key string, page int, baseUrl string, runner *J } } req.Charset = option.Charset + req.Retry = option.Retry // 对应 legado AnalyzeUrl.type:值本身不参与判断,只要非空就把响应按 // 「原始字节的 hex」返回。书源借此把 data: 地址当参数信封用。 if option.Type != "" { diff --git a/internal/service/reader/rule/zh_convert.go b/internal/service/reader/rule/zh_convert.go new file mode 100644 index 0000000..6bb3c57 --- /dev/null +++ b/internal/service/reader/rule/zh_convert.go @@ -0,0 +1,103 @@ +// Code generated from OpenCC character dictionaries (via the opencc-js data files +// already vendored in this repo at web/node_modules/opencc-js, Apache-2.0). DO NOT EDIT. +// +// 用途:书源规则里的 java.t2s / java.s2t(对应 legado 的 ChineseUtils,底层是 +// quick-transfer 的简繁转换库)。这里只做「单字」映射,够覆盖简繁标题与关键字 +// 转换的主要场景;词组级消歧(如「乾隆」不做成「干隆」)与 legado 额外排除的 +// 台湾用语(雪梨/起司/…)尚未对齐,遇到具体书源再补。 +package rule + +import "sync" + +// zhT2SData 繁体→简体("繁 简|繁 简|…")。 +const zhT2SData = `㑯 㑔|㑳 㑇|㑶 㐹|㓨 刾|㗲 𠵾|㘚 㘎|㜄 㚯|㜏 㛣|㜢 𡞱|㠏 㟆|㠣 𫵷|㥮 㤘|㩜 㨫|㩳 㧐|㩵 擜|㺏 𤠋|䁪 𥇢|䁻 䀥|䃮 鿎|䊷 䌶|䋙 䌺|䋚 䌻|䋹 䌿|䋻 䌾|䍦 䍠|䎱 䎬|䓣 𬜯|䙡 䙌|䜀 䜧|䝼 䞍|䡵 𫟦|䥇 䦂|䥑 鿏|䥕 𬭯|䥱 䥾|䦛 䦶|䦟 䦷|䧢 𨸟|䮄 𫠊|䯀 䯅|䰾 鲃|䱷 䲣|䱽 䲝|䲁 鳚|䲘 鳤|䴉 鹮|丟 丢|並 并|乾 干|亂 乱|亙 亘|亞 亚|佇 伫|佈 布|佔 占|併 并|來 来|侖 仑|侶 侣|侷 局|俁 俣|係 系|俔 伣|俠 侠|俥 伡|俬 私|倀 伥|倆 俩|倈 俫|倉 仓|個 个|們 们|倖 幸|倫 伦|倲 㑈|偉 伟|偑 㐽|側 侧|偵 侦|偽 伪|傌 㐷|傑 杰|傖 伧|傘 伞|備 备|傢 家|傭 佣|傯 偬|傳 传|傴 伛|債 债|傷 伤|傾 倾|僂 偻|僅 仅|僉 佥|僑 侨|僕 仆|僞 伪|僤 𫢸|僥 侥|僨 偾|僱 雇|價 价|儀 仪|儁 俊|儂 侬|億 亿|儈 侩|儉 俭|儎 傤|儐 傧|儔 俦|儕 侪|儘 尽|償 偿|優 优|儲 储|儷 俪|儸 㑩|儺 傩|儻 傥|儼 俨|兇 凶|兌 兑|兒 儿|兗 兖|內 内|兩 两|冊 册|冑 胄|冪 幂|凈 净|凍 冻|凜 凛|凱 凯|別 别|刪 删|剄 刭|則 则|剋 克|剎 刹|剗 刬|剛 刚|剝 剥|剮 剐|剴 剀|創 创|剷 铲|劃 划|劄 札|劇 剧|劉 刘|劊 刽|劌 刿|劍 剑|劏 㓥|劑 剂|劚 㔉|勁 劲|動 动|務 务|勛 勋|勝 胜|勞 劳|勢 势|勣 𪟝|勩 勚|勱 劢|勳 勋|勵 励|勸 劝|勻 匀|匭 匦|匯 汇|匱 匮|區 区|協 协|卹 恤|卻 却|卽 即|厙 厍|厠 厕|厤 历|厭 厌|厲 厉|厴 厣|參 参|叄 叁|叢 丛|吒 咤|吳 吴|吶 呐|呂 吕|咼 呙|員 员|唄 呗|唸 念|問 问|啓 启|啞 哑|啟 启|啢 唡|喎 㖞|喚 唤|喪 丧|喫 吃|喬 乔|單 单|喲 哟|嗆 呛|嗇 啬|嗊 唝|嗎 吗|嗚 呜|嗩 唢|嗰 𠮶|嗶 哔|嘆 叹|嘍 喽|嘓 啯|嘔 呕|嘖 啧|嘗 尝|嘜 唛|嘩 哗|嘮 唠|嘯 啸|嘰 叽|嘵 哓|嘸 呒|嘽 啴|噁 恶|噓 嘘|噚 㖊|噝 咝|噠 哒|噥 哝|噦 哕|噯 嗳|噲 哙|噴 喷|噸 吨|噹 当|嚀 咛|嚇 吓|嚌 哜|嚐 尝|嚕 噜|嚙 啮|嚥 咽|嚦 呖|嚧 𠰷|嚨 咙|嚮 向|嚲 亸|嚳 喾|嚴 严|嚶 嘤|囀 啭|囁 嗫|囂 嚣|囅 冁|囈 呓|囉 啰|囌 苏|囑 嘱|囪 囱|圇 囵|國 国|圍 围|園 园|圓 圆|圖 图|團 团|垻 坝|埡 垭|埨 𫭢|埰 采|執 执|堅 坚|堊 垩|堖 垴|堝 埚|堯 尧|報 报|場 场|塊 块|塋 茔|塏 垲|塒 埘|塗 涂|塚 冢|塢 坞|塤 埙|塵 尘|塸 𫭟|塹 堑|塿 𪣻|墊 垫|墜 坠|墠 𫮃|墮 堕|墰 坛|墳 坟|墶 垯|墻 墙|墾 垦|壇 坛|壋 垱|壎 埙|壓 压|壗 𡋤|壘 垒|壙 圹|壚 垆|壜 坛|壞 坏|壟 垄|壠 垅|壢 坜|壩 坝|壪 塆|壯 壮|壺 壶|壼 壸|壽 寿|夠 够|夢 梦|夥 伙|夾 夹|奐 奂|奧 奥|奩 奁|奪 夺|奬 奖|奮 奋|奼 姹|妝 妆|姍 姗|姦 奸|娙 𫰛|娛 娱|婁 娄|婦 妇|婭 娅|媧 娲|媯 妫|媰 㛀|媼 媪|媽 妈|嫋 袅|嫗 妪|嫵 妩|嫺 娴|嫻 娴|嫿 婳|嬀 妫|嬃 媭|嬈 娆|嬋 婵|嬌 娇|嬙 嫱|嬡 嫒|嬤 嬷|嬪 嫔|嬰 婴|嬸 婶|孃 娘|孋 㛤|孌 娈|孫 孙|學 学|孻 𡥧|孿 孪|宮 宫|寀 采|寢 寝|實 实|寧 宁|審 审|寫 写|寬 宽|寵 宠|寶 宝|將 将|專 专|尋 寻|對 对|導 导|尷 尴|屆 届|屍 尸|屓 屃|屜 屉|屢 屡|層 层|屨 屦|屬 属|岡 冈|峯 峰|峴 岘|島 岛|峽 峡|崍 崃|崑 昆|崗 岗|崙 仑|崢 峥|崬 岽|嵐 岚|嵗 岁|嵽 𫶇|嵾 㟥|嶁 嵝|嶄 崭|嶇 岖|嶔 嵚|嶗 崂|嶠 峤|嶢 峣|嶧 峄|嶨 峃|嶮 崄|嶸 嵘|嶺 岭|嶼 屿|嶽 岳|巋 岿|巒 峦|巔 巅|巖 岩|巘 𪩘|巰 巯|巹 卺|帥 帅|師 师|帳 帐|帶 带|幀 帧|幃 帏|幓 㡎|幗 帼|幘 帻|幟 帜|幣 币|幫 帮|幬 帱|幷 并|幹 干|幾 几|庫 库|廁 厕|廂 厢|廄 厩|廈 厦|廎 庼|廕 荫|廚 厨|廝 厮|廞 𫷷|廟 庙|廠 厂|廡 庑|廢 废|廣 广|廩 廪|廬 庐|廳 厅|弒 弑|弔 吊|弳 弪|張 张|強 强|彄 𫸩|彆 别|彈 弹|彌 弥|彎 弯|彔 录|彙 汇|彠 彟|彥 彦|彫 雕|彲 彨|彿 佛|後 后|徑 径|從 从|徠 徕|復 复|徵 征|徹 彻|恆 恒|恥 耻|悅 悦|悞 悮|悵 怅|悶 闷|悽 凄|惡 恶|惱 恼|惲 恽|惻 恻|愛 爱|愜 惬|愨 悫|愴 怆|愷 恺|愾 忾|慄 栗|態 态|慍 愠|慘 惨|慚 惭|慟 恸|慣 惯|慤 悫|慪 怄|慫 怂|慮 虑|慳 悭|慶 庆|慺 㥪|慼 戚|慾 欲|憂 忧|憊 惫|憐 怜|憑 凭|憒 愦|憖 慭|憚 惮|憤 愤|憫 悯|憮 怃|憲 宪|憶 忆|懇 恳|應 应|懌 怿|懍 懔|懞 蒙|懟 怼|懣 懑|懤 㤽|懨 恹|懲 惩|懶 懒|懷 怀|懸 悬|懺 忏|懼 惧|懾 慑|戀 恋|戇 戆|戔 戋|戧 戗|戩 戬|戰 战|戱 戯|戲 戏|戶 户|扞 捍|拋 抛|拚 拼|挩 捝|挱 挲|挾 挟|捨 舍|捫 扪|捱 挨|捲 卷|掃 扫|掄 抡|掆 㧏|掗 挜|掙 挣|掛 挂|採 采|揀 拣|揚 扬|換 换|揮 挥|揯 搄|損 损|搖 摇|搗 捣|搧 扇|搵 揾|搶 抢|摑 掴|摜 掼|摟 搂|摯 挚|摳 抠|摶 抟|摺 折|摻 掺|撈 捞|撏 挦|撐 撑|撓 挠|撝 㧑|撟 挢|撣 掸|撥 拨|撫 抚|撲 扑|撳 揿|撻 挞|撾 挝|撿 捡|擁 拥|擄 掳|擇 择|擊 击|擋 挡|擓 㧟|擔 担|據 据|擠 挤|擡 抬|擣 捣|擬 拟|擯 摈|擰 拧|擱 搁|擲 掷|擴 扩|擷 撷|擺 摆|擻 擞|擼 撸|擽 㧰|擾 扰|攄 摅|攆 撵|攏 拢|攔 拦|攖 撄|攙 搀|攛 撺|攜 携|攝 摄|攢 攒|攣 挛|攤 摊|攪 搅|攬 揽|敎 教|敓 敚|敗 败|敘 叙|敵 敌|數 数|斂 敛|斃 毙|斆 敩|斕 斓|斬 斩|斷 断|於 于|旂 旗|旣 既|昇 升|時 时|晉 晋|晛 𬀪|晝 昼|暈 晕|暉 晖|暐 𬀩|暘 旸|暢 畅|暫 暂|曄 晔|曆 历|曇 昙|曉 晓|曏 向|曖 暧|曠 旷|曥 𣆐|曨 昽|曬 晒|書 书|會 会|朥 𦛨|朧 胧|朮 术|東 东|枴 拐|柵 栅|柺 拐|査 查|桱 𣐕|桿 杆|梔 栀|梘 枧|梜 𬂩|條 条|梟 枭|梲 棁|棄 弃|棊 棋|棖 枨|棗 枣|棟 栋|棡 㭎|棧 栈|棲 栖|棶 梾|椏 桠|椲 㭏|楊 杨|楓 枫|楨 桢|業 业|極 极|榘 矩|榦 干|榪 杩|榮 荣|榲 榅|榿 桤|構 构|槍 枪|槓 杠|槤 梿|槧 椠|槨 椁|槮 椮|槳 桨|槶 椢|槼 椝|樁 桩|樂 乐|樅 枞|樑 梁|樓 楼|標 标|樞 枢|樢 㭤|樣 样|樧 榝|樫 㭴|樳 桪|樸 朴|樹 树|樺 桦|樿 椫|橈 桡|橋 桥|機 机|橢 椭|橫 横|橯 𣓿|檁 檩|檉 柽|檔 档|檜 桧|檟 槚|檢 检|檣 樯|檮 梼|檯 台|檳 槟|檸 柠|檻 槛|櫃 柜|櫍 𬃊|櫓 橹|櫚 榈|櫛 栉|櫝 椟|櫞 橼|櫟 栎|櫥 橱|櫧 槠|櫨 栌|櫪 枥|櫫 橥|櫬 榇|櫱 蘖|櫳 栊|櫸 榉|櫻 樱|欄 栏|欅 榉|權 权|欏 椤|欒 栾|欓 𣗋|欖 榄|欞 棂|欽 钦|歎 叹|歐 欧|歟 欤|歡 欢|歲 岁|歷 历|歸 归|歿 殁|殘 残|殞 殒|殤 殇|殨 㱮|殫 殚|殭 僵|殮 殓|殯 殡|殰 㱩|殲 歼|殺 杀|殻 壳|殼 壳|毀 毁|毆 殴|毿 毵|氂 牦|氈 毡|氌 氇|氣 气|氫 氢|氬 氩|氳 氲|氾 泛|汎 泛|汙 污|決 决|沒 没|沖 冲|況 况|泝 溯|洩 泄|洶 汹|浹 浃|浿 𬇙|涇 泾|涗 涚|涼 凉|淒 凄|淚 泪|淥 渌|淨 净|淩 凌|淪 沦|淵 渊|淶 涞|淺 浅|渙 涣|減 减|渢 沨|渦 涡|測 测|渾 浑|湊 凑|湋 𣲗|湞 浈|湧 涌|湯 汤|溈 沩|準 准|溝 沟|溫 温|溮 浉|溳 涢|溼 湿|滄 沧|滅 灭|滌 涤|滎 荥|滙 汇|滬 沪|滯 滞|滲 渗|滷 卤|滸 浒|滻 浐|滾 滚|滿 满|漁 渔|漊 溇|漍 𬇹|漚 沤|漢 汉|漣 涟|漬 渍|漲 涨|漵 溆|漸 渐|漿 浆|潁 颍|潑 泼|潔 洁|潕 𣲘|潙 沩|潚 㴋|潛 潜|潤 润|潯 浔|潰 溃|潷 滗|潿 涠|澀 涩|澆 浇|澇 涝|澐 沄|澗 涧|澠 渑|澤 泽|澦 滪|澩 泶|澫 𬇕|澮 浍|澱 淀|澾 㳠|濁 浊|濃 浓|濄 㳡|濆 𣸣|濕 湿|濘 泞|濚 溁|濛 蒙|濜 浕|濟 济|濤 涛|濧 㳔|濫 滥|濰 潍|濱 滨|濺 溅|濼 泺|濾 滤|瀂 澛|瀅 滢|瀆 渎|瀇 㲿|瀉 泻|瀋 沈|瀏 浏|瀕 濒|瀘 泸|瀝 沥|瀟 潇|瀠 潆|瀦 潴|瀧 泷|瀨 濑|瀰 弥|瀲 潋|瀾 澜|灃 沣|灄 滠|灑 洒|灒 𪷽|灕 漓|灘 滩|灙 𣺼|灝 灏|灡 㳕|灣 湾|灤 滦|灧 滟|灩 滟|災 灾|為 为|烏 乌|烴 烃|無 无|煉 炼|煒 炜|煙 烟|煢 茕|煥 焕|煩 烦|煬 炀|煱 㶽|熅 煴|熒 荧|熗 炝|熰 𬉼|熱 热|熲 颎|熾 炽|燀 𬊤|燁 烨|燈 灯|燉 炖|燒 烧|燖 𬊈|燙 烫|燜 焖|營 营|燦 灿|燬 毁|燭 烛|燴 烩|燶 㶶|燻 熏|燼 烬|燾 焘|爍 烁|爐 炉|爛 烂|爭 争|爲 为|爺 爷|爾 尔|牀 床|牆 墙|牘 牍|牴 抵|牽 牵|犖 荦|犛 牦|犢 犊|犧 牺|狀 状|狹 狭|狽 狈|猙 狰|猶 犹|猻 狲|獁 犸|獃 呆|獄 狱|獅 狮|獎 奖|獨 独|獪 狯|獫 猃|獮 狝|獰 狞|獱 㺍|獲 获|獵 猎|獷 犷|獸 兽|獺 獭|獻 献|獼 猕|玀 猡|現 现|琱 雕|琺 珐|琿 珲|瑋 玮|瑒 玚|瑣 琐|瑤 瑶|瑩 莹|瑪 玛|瑲 玱|璉 琏|璊 𫞩|璕 𬍤|璗 𬍡|璡 琎|璣 玑|璦 瑷|璫 珰|璯 㻅|環 环|璵 玙|璸 瑸|璽 玺|璿 璇|瓅 𬍛|瓊 琼|瓏 珑|瓔 璎|瓚 瓒|瓛 𤩽|甌 瓯|甕 瓮|產 产|産 产|畝 亩|畢 毕|畫 画|異 异|畵 画|當 当|疇 畴|疊 叠|痙 痉|痠 酸|痾 疴|瘂 痖|瘋 疯|瘍 疡|瘓 痪|瘞 瘗|瘡 疮|瘧 疟|瘮 瘆|瘲 疭|瘺 瘘|瘻 瘘|療 疗|癆 痨|癇 痫|癉 瘅|癒 愈|癘 疠|癟 瘪|癡 痴|癢 痒|癤 疖|癥 症|癧 疬|癩 癞|癬 癣|癭 瘿|癮 瘾|癰 痈|癱 瘫|癲 癫|發 发|皁 皂|皚 皑|皰 疱|皸 皲|皺 皱|盃 杯|盜 盗|盞 盏|盡 尽|監 监|盤 盘|盧 卢|盪 荡|眞 真|眥 眦|眾 众|睍 𪾢|睏 困|睜 睁|睞 睐|瞘 眍|瞜 䁖|瞞 瞒|瞶 瞆|瞼 睑|矇 蒙|矓 眬|矚 瞩|矯 矫|硃 朱|硜 硁|硤 硖|硨 砗|硯 砚|碕 埼|碩 硕|碭 砀|碸 砜|確 确|碼 码|碽 䂵|磑 硙|磚 砖|磠 硵|磣 碜|磧 碛|磯 矶|磽 硗|磾 䃅|礄 硚|礎 础|礐 𬒈|礙 碍|礦 矿|礪 砺|礫 砾|礬 矾|礱 砻|祕 秘|祿 禄|禍 祸|禎 祯|禕 祎|禡 祃|禦 御|禪 禅|禮 礼|禰 祢|禱 祷|禿 秃|秈 籼|稅 税|稈 秆|稏 䅉|稜 棱|稟 禀|種 种|稱 称|穀 谷|穇 䅟|穌 稣|積 积|穎 颖|穠 秾|穡 穑|穢 秽|穩 稳|穫 获|穭 穞|窩 窝|窪 洼|窮 穷|窯 窑|窵 窎|窶 窭|窺 窥|竄 窜|竅 窍|竇 窦|竈 灶|竊 窃|竪 竖|競 竞|筆 笔|筍 笋|筧 笕|筴 䇲|箇 个|箋 笺|箏 筝|箚 札|節 节|範 范|築 筑|篋 箧|篔 筼|篠 筿|篢 𬕂|篤 笃|篩 筛|篳 筚|篸 𥮾|簀 箦|簍 篓|簑 蓑|簞 箪|簡 简|簣 篑|簫 箫|簹 筜|簽 签|簾 帘|籃 篮|籅 𥫣|籌 筹|籔 䉤|籙 箓|籛 篯|籜 箨|籟 籁|籠 笼|籤 签|籩 笾|籪 簖|籬 篱|籮 箩|籲 吁|粵 粤|糉 粽|糝 糁|糞 粪|糧 粮|糰 团|糲 粝|糴 籴|糶 粜|糹 纟|糾 纠|紀 纪|紂 纣|紃 𬘓|約 约|紅 红|紆 纡|紇 纥|紈 纨|紉 纫|紋 纹|納 纳|紐 纽|紓 纾|純 纯|紕 纰|紖 纼|紗 纱|紘 纮|紙 纸|級 级|紛 纷|紜 纭|紝 纴|紞 𬘘|紡 纺|紬 䌷|紮 扎|細 细|紱 绂|紲 绁|紳 绅|紵 纻|紹 绍|紺 绀|紼 绋|紿 绐|絀 绌|終 终|絃 弦|組 组|絅 䌹|絆 绊|絎 绗|結 结|絕 绝|絛 绦|絝 绔|絞 绞|絡 络|絢 绚|給 给|絨 绒|絪 𬘡|絰 绖|統 统|絲 丝|絳 绛|絶 绝|絹 绢|絺 𫄨|綁 绑|綃 绡|綄 𬘫|綆 绠|綈 绨|綉 绣|綌 绤|綎 𬘩|綏 绥|綐 䌼|綑 捆|經 经|綖 𫄧|綜 综|綝 𬘭|綞 缍|綠 绿|綡 𫟅|綢 绸|綣 绻|綧 𬘯|綪 𬘬|綫 线|綬 绶|維 维|綯 绹|綰 绾|綱 纲|網 网|綳 绷|綴 缀|綵 彩|綸 纶|綹 绺|綺 绮|綻 绽|綽 绰|綾 绫|綿 绵|緄 绲|緇 缁|緊 紧|緋 绯|緑 绿|緒 绪|緓 绬|緔 绱|緗 缃|緘 缄|緙 缂|線 线|緝 缉|緞 缎|締 缔|緡 缗|緣 缘|緦 缌|編 编|緩 缓|緬 缅|緯 纬|緱 缑|緲 缈|練 练|緶 缏|緹 缇|緻 致|緼 缊|縈 萦|縉 缙|縊 缢|縋 缒|縐 绉|縑 缣|縕 缊|縗 缞|縛 缚|縝 缜|縞 缟|縟 缛|縣 县|縧 绦|縫 缝|縭 缡|縮 缩|縯 𬙂|縱 纵|縲 缧|縳 䌸|縴 纤|縵 缦|縶 絷|縷 缕|縹 缥|總 总|績 绩|繃 绷|繅 缫|繆 缪|繒 缯|織 织|繕 缮|繚 缭|繞 绕|繡 绣|繢 缋|繩 绳|繪 绘|繫 系|繭 茧|繮 缰|繯 缳|繰 缲|繳 缴|繶 𫄷|繸 䍁|繹 绎|繻 𦈡|繼 继|繽 缤|繾 缱|繿 䍀|纁 𫄸|纆 𬙊|纇 颣|纈 缬|纊 纩|續 续|纍 累|纏 缠|纓 缨|纔 才|纕 𬙋|纖 纤|纘 缵|纜 缆|缽 钵|罃 䓨|罈 坛|罌 罂|罎 坛|罰 罚|罵 骂|罷 罢|羅 罗|羆 罴|羈 羁|羋 芈|羣 群|羥 羟|羨 羡|義 义|羶 膻|習 习|翫 玩|翬 翚|翹 翘|翽 翙|耬 耧|耮 耢|聖 圣|聞 闻|聯 联|聰 聪|聲 声|聳 耸|聵 聩|聶 聂|職 职|聹 聍|聽 听|聾 聋|肅 肃|脅 胁|脈 脉|脛 胫|脣 唇|脩 修|脫 脱|脹 胀|腎 肾|腖 胨|腡 脶|腦 脑|腫 肿|腳 脚|腸 肠|膃 腽|膕 腘|膚 肤|膞 䏝|膠 胶|膢 𦝼|膩 腻|膽 胆|膾 脍|膿 脓|臉 脸|臍 脐|臏 膑|臘 腊|臚 胪|臟 脏|臠 脔|臢 臜|臥 卧|臨 临|臺 台|與 与|興 兴|舉 举|舊 旧|舖 铺|舘 馆|艙 舱|艤 舣|艦 舰|艫 舻|艱 艰|艷 艳|芻 刍|苧 苎|茲 兹|荊 荆|莊 庄|莖 茎|莢 荚|莧 苋|華 华|菴 庵|菸 烟|萇 苌|萊 莱|萬 万|萴 荝|萵 莴|葉 叶|葒 荭|葤 荮|葦 苇|葯 药|葷 荤|蒍 𫇭|蒐 搜|蒓 莼|蒔 莳|蒕 蒀|蒞 莅|蒼 苍|蓀 荪|蓆 席|蓋 盖|蓮 莲|蓯 苁|蓴 莼|蓽 荜|蔄 𬜬|蔔 卜|蔘 参|蔞 蒌|蔣 蒋|蔥 葱|蔦 茑|蔭 荫|蔯 𫈟|蔿 𫇭|蕁 荨|蕆 蒇|蕎 荞|蕒 荬|蕓 芸|蕕 莸|蕘 荛|蕢 蒉|蕩 荡|蕪 芜|蕭 萧|蕷 蓣|薀 蕰|薈 荟|薊 蓟|薌 芗|薑 姜|薔 蔷|薘 荙|薟 莶|薦 荐|薩 萨|薳 䓕|薴 苧|薵 䓓|薹 苔|薺 荠|藍 蓝|藎 荩|藝 艺|藥 药|藪 薮|藭 䓖|藴 蕴|藶 苈|藹 蔼|藺 蔺|蘀 萚|蘄 蕲|蘆 芦|蘇 苏|蘊 蕴|蘋 苹|蘚 藓|蘞 蔹|蘟 𦻕|蘢 茏|蘭 兰|蘺 蓠|蘿 萝|虆 蔂|虉 𬟁|處 处|虛 虚|虜 虏|號 号|虧 亏|虯 虬|蛺 蛱|蛻 蜕|蜆 蚬|蝀 𬟽|蝕 蚀|蝟 猬|蝦 虾|蝨 虱|蝸 蜗|螄 蛳|螞 蚂|螢 萤|螮 䗖|螻 蝼|螿 螀|蟄 蛰|蟈 蝈|蟎 螨|蟣 虮|蟬 蝉|蟯 蛲|蟲 虫|蟳 𫊻|蟶 蛏|蟻 蚁|蠁 蚃|蠅 蝇|蠆 虿|蠍 蝎|蠐 蛴|蠑 蝾|蠔 蚝|蠟 蜡|蠣 蛎|蠨 蟏|蠱 蛊|蠶 蚕|蠻 蛮|衆 众|衊 蔑|術 术|衕 同|衚 胡|衛 卫|衝 冲|袞 衮|袷 夹|裊 袅|裏 里|補 补|裝 装|裡 里|製 制|複 复|褌 裈|褘 袆|褲 裤|褳 裢|褸 褛|褻 亵|襀 𫌀|襇 裥|襉 裥|襏 袯|襖 袄|襝 裣|襠 裆|襤 褴|襪 袜|襬 摆|襯 衬|襲 袭|襴 襕|覈 核|見 见|覎 觃|規 规|覓 觅|視 视|覘 觇|覡 觋|覥 觍|覦 觎|親 亲|覬 觊|覯 觏|覲 觐|覷 觑|覺 觉|覽 览|覿 觌|觀 观|觴 觞|觶 觯|觸 触|訁 讠|訂 订|訃 讣|計 计|訊 讯|訌 讧|討 讨|訏 𬣙|訐 讦|訒 讱|訓 训|訕 讪|訖 讫|託 托|記 记|訛 讹|訝 讶|訟 讼|訢 䜣|訣 诀|訥 讷|訩 讻|訪 访|設 设|許 许|訴 诉|訶 诃|診 诊|註 注|証 证|詀 𧮪|詁 诂|詆 诋|詎 讵|詐 诈|詒 诒|詔 诏|評 评|詖 诐|詗 诇|詘 诎|詛 诅|詝 𬣞|詞 词|詠 咏|詡 诩|詢 询|詣 诣|試 试|詩 诗|詪 𬣳|詫 诧|詬 诟|詭 诡|詮 诠|詰 诘|話 话|該 该|詳 详|詵 诜|詷 𫍣|詼 诙|詿 诖|誄 诔|誅 诛|誆 诓|誇 夸|誌 志|認 认|誑 诳|誒 诶|誕 诞|誘 诱|誚 诮|語 语|誠 诚|誡 诫|誣 诬|誤 误|誥 诰|誦 诵|誨 诲|說 说|説 说|誰 谁|課 课|誶 谇|誹 诽|誼 谊|誾 訚|調 调|諂 谄|諄 谆|談 谈|諉 诿|請 请|諍 诤|諏 诹|諑 诼|諒 谅|諓 𬣡|論 论|諗 谂|諛 谀|諜 谍|諝 谞|諞 谝|諟 𬤊|諡 谥|諢 诨|諤 谔|諦 谛|諧 谐|諫 谏|諭 谕|諮 咨|諱 讳|諲 𬤇|諳 谙|諴 𫍯|諶 谌|諷 讽|諸 诸|諺 谚|諼 谖|諾 诺|謀 谋|謁 谒|謂 谓|謄 誊|謅 诌|謊 谎|謎 谜|謏 𫍲|謐 谧|謔 谑|謖 谡|謗 谤|謙 谦|謚 谥|講 讲|謝 谢|謠 谣|謡 谣|謨 谟|謫 谪|謬 谬|謭 谫|謳 讴|謹 谨|謾 谩|譁 哗|證 证|譎 谲|譏 讥|譓 𬤝|譖 谮|識 识|譙 谯|譚 谭|譜 谱|譞 𫍽|譟 噪|譫 谵|譭 毁|譯 译|議 议|譴 谴|護 护|譸 诪|譽 誉|譾 谫|讀 读|讅 谉|變 变|讋 詟|讌 䜩|讎 雠|讒 谗|讓 让|讕 谰|讖 谶|讚 赞|讜 谠|讞 谳|谿 溪|豈 岂|豎 竖|豐 丰|豔 艳|豬 猪|豶 豮|貍 狸|貓 猫|貙 䝙|貝 贝|貞 贞|貟 贠|負 负|財 财|貢 贡|貧 贫|貨 货|販 贩|貪 贪|貫 贯|責 责|貯 贮|貰 贳|貲 赀|貳 贰|貴 贵|貶 贬|買 买|貸 贷|貺 贶|費 费|貼 贴|貽 贻|貿 贸|賀 贺|賁 贲|賂 赂|賃 赁|賄 贿|賅 赅|資 资|賈 贾|賊 贼|賑 赈|賒 赊|賓 宾|賕 赇|賙 赒|賚 赉|賜 赐|賞 赏|賠 赔|賡 赓|賢 贤|賣 卖|賤 贱|賦 赋|賧 赕|質 质|賫 赍|賬 账|賭 赌|賰 䞐|賴 赖|賵 赗|賺 赚|賻 赙|購 购|賽 赛|賾 赜|贄 贽|贅 赘|贇 赟|贈 赠|贊 赞|贋 赝|贍 赡|贏 赢|贐 赆|贓 赃|贔 赑|贖 赎|贗 赝|贛 赣|贜 赃|赬 赪|趕 赶|趙 赵|趨 趋|趲 趱|跡 迹|踐 践|踰 逾|踴 踊|蹌 跄|蹕 跸|蹟 迹|蹠 跖|蹣 蹒|蹤 踪|蹺 跷|躂 跶|躉 趸|躊 踌|躋 跻|躍 跃|躎 䟢|躑 踯|躒 跞|躓 踬|躕 蹰|躚 跹|躡 蹑|躥 蹿|躦 躜|躪 躏|軀 躯|車 车|軋 轧|軌 轨|軍 军|軏 𫐄|軑 轪|軒 轩|軔 轫|軛 轭|軝 𬨂|軟 软|軤 轷|軫 轸|軲 轱|軸 轴|軹 轵|軺 轺|軻 轲|軼 轶|軾 轼|較 较|輄 𨐈|輅 辂|輇 辁|輈 辀|載 载|輊 轾|輋 𪨶|輒 辄|輓 挽|輔 辅|輕 轻|輗 𫐐|輛 辆|輜 辎|輝 辉|輞 辋|輟 辍|輥 辊|輦 辇|輩 辈|輪 轮|輬 辌|輮 𫐓|輯 辑|輳 辏|輶 𬨎|輸 输|輻 辐|輼 辒|輾 辗|輿 舆|轀 辒|轂 毂|轄 辖|轅 辕|轆 辘|轉 转|轍 辙|轎 轿|轔 辚|轟 轰|轡 辔|轢 轹|轤 轳|辦 办|辭 辞|辮 辫|辯 辩|農 农|迴 回|逕 径|這 这|連 连|週 周|進 进|遊 游|運 运|過 过|達 达|違 违|遙 遥|遜 逊|遞 递|遠 远|遡 溯|適 适|遲 迟|遶 绕|遷 迁|選 选|遺 遗|遼 辽|邁 迈|還 还|邇 迩|邊 边|邏 逻|邐 逦|郟 郏|郵 邮|鄆 郓|鄉 乡|鄒 邹|鄔 邬|鄖 郧|鄧 邓|鄩 𬩽|鄭 郑|鄰 邻|鄲 郸|鄳 𫑡|鄴 邺|鄶 郐|鄺 邝|酇 酂|酈 郦|醃 腌|醖 酝|醜 丑|醞 酝|醟 蒏|醣 糖|醫 医|醬 酱|醱 酦|醲 𬪩|釀 酿|釁 衅|釃 酾|釅 酽|釋 释|釐 厘|釒 钅|釓 钆|釔 钇|釕 钌|釗 钊|釘 钉|釙 钋|針 针|釣 钓|釤 钐|釦 扣|釧 钏|釩 钒|釴 𬬩|釵 钗|釷 钍|釹 钕|釺 钎|釾 䥺|釿 𬬱|鈀 钯|鈁 钫|鈃 钘|鈄 钭|鈅 钥|鈇 𫓧|鈈 钚|鈉 钠|鈍 钝|鈎 钩|鈐 钤|鈑 钣|鈒 钑|鈔 钞|鈕 钮|鈞 钧|鈡 钟|鈣 钙|鈥 钬|鈦 钛|鈧 钪|鈮 铌|鈰 铈|鈳 钶|鈴 铃|鈷 钴|鈸 钹|鈹 铍|鈺 钰|鈽 钸|鈾 铀|鈿 钿|鉀 钾|鉅 巨|鉆 钻|鉈 铊|鉉 铉|鉊 𬬿|鉋 铇|鉍 铋|鉑 铂|鉕 钷|鉗 钳|鉚 铆|鉛 铅|鉝 𫟷|鉞 钺|鉢 钵|鉤 钩|鉥 𬬸|鉦 钲|鉧 𬭁|鉬 钼|鉭 钽|鉮 𬬹|鉳 锫|鉶 铏|鉷 𫟹|鉸 铰|鉺 铒|鉻 铬|鉿 铪|銀 银|銃 铳|銅 铜|銈 𫓯|銍 铚|銑 铣|銓 铨|銖 铢|銘 铭|銚 铫|銛 铦|銜 衔|銠 铑|銣 铷|銥 铱|銦 铟|銨 铵|銩 铥|銪 铕|銫 铯|銬 铐|銱 铞|銳 锐|銶 𨱇|銷 销|銹 锈|銻 锑|銼 锉|鋁 铝|鋃 锒|鋅 锌|鋇 钡|鋌 铤|鋏 铗|鋐 𬭎|鋒 锋|鋗 𫓶|鋙 铻|鋝 锊|鋟 锓|鋣 铘|鋤 锄|鋥 锃|鋦 锔|鋨 锇|鋩 铓|鋪 铺|鋭 锐|鋮 铖|鋯 锆|鋰 锂|鋱 铽|鋶 锍|鋸 锯|鋹 𬬮|鋼 钢|錀 𬬭|錁 锞|錄 录|錆 锖|錇 锫|錈 锩|錏 铔|錐 锥|錒 锕|錕 锟|錘 锤|錙 锱|錚 铮|錛 锛|錞 𬭚|錟 锬|錠 锭|錡 锜|錢 钱|錤 𫓹|錦 锦|錨 锚|錩 锠|錫 锡|錮 锢|錯 错|録 录|錳 锰|錶 表|錸 铼|錼 镎|鍀 锝|鍁 锨|鍃 锪|鍅 钫|鍆 钔|鍇 锴|鍈 锳|鍊 炼|鍋 锅|鍍 镀|鍔 锷|鍘 铡|鍚 钖|鍛 锻|鍠 锽|鍤 锸|鍥 锲|鍩 锘|鍬 锹|鍭 𬭤|鍰 锾|鍵 键|鍶 锶|鍺 锗|鍼 针|鍾 钟|鎂 镁|鎄 锿|鎇 镅|鎊 镑|鎌 镰|鎓 𬭩|鎔 镕|鎖 锁|鎘 镉|鎚 锤|鎛 镈|鎝 𨱏|鎡 镃|鎢 钨|鎣 蓥|鎦 镏|鎧 铠|鎩 铩|鎪 锼|鎬 镐|鎭 镇|鎮 镇|鎰 镒|鎲 镋|鎳 镍|鎵 镓|鎶 鿔|鎸 镌|鎿 镎|鏃 镞|鏇 旋|鏈 链|鏌 镆|鏍 镙|鏏 𬭬|鏐 镠|鏑 镝|鏗 铿|鏘 锵|鏜 镗|鏝 镘|鏞 镛|鏟 铲|鏡 镜|鏢 镖|鏤 镂|鏨 錾|鏰 镚|鏵 铧|鏷 镤|鏹 镪|鏺 䥽|鏻 𬭸|鏽 锈|鐃 铙|鐄 𨱑|鐇 𫔍|鐋 铴|鐍 𫔎|鐏 𨱔|鐐 镣|鐒 铹|鐓 镦|鐔 镡|鐘 钟|鐙 镫|鐝 镢|鐠 镨|鐥 䦅|鐦 锎|鐧 锏|鐨 镄|鐩 𬭼|鐫 镌|鐮 镰|鐯 䦃|鐲 镯|鐳 镭|鐵 铁|鐶 镮|鐸 铎|鐺 铛|鐽 𫟼|鐿 镱|鑄 铸|鑊 镬|鑌 镔|鑑 鉴|鑒 鉴|鑔 镲|鑕 锧|鑞 镴|鑠 铄|鑣 镳|鑥 镥|鑪 𬬻|鑭 镧|鑰 钥|鑱 镵|鑲 镶|鑷 镊|鑹 镩|鑼 锣|鑽 钻|鑾 銮|鑿 凿|钁 镢|钂 镋|長 长|門 门|閂 闩|閃 闪|閆 闫|閈 闬|閉 闭|開 开|閌 闶|閎 闳|閏 闰|閑 闲|閒 闲|間 间|閔 闵|閘 闸|閡 阂|閣 阁|閤 合|閥 阀|閨 闺|閩 闽|閫 阃|閬 阆|閭 闾|閱 阅|閲 阅|閶 阊|閹 阉|閻 阎|閼 阏|閽 阍|閾 阈|閿 阌|闃 阒|闆 板|闇 暗|闈 闱|闉 𬮱|闊 阔|闋 阕|闌 阑|闍 阇|闐 阗|闑 𫔶|闒 阘|闓 闿|闔 阖|闕 阙|闖 闯|關 关|闞 阚|闠 阓|闡 阐|闢 辟|闤 阛|闥 闼|陘 陉|陝 陕|陞 升|陣 阵|陰 阴|陳 陈|陸 陆|陽 阳|隉 陧|隊 队|階 阶|隑 𬮿|隕 陨|際 际|隤 𬯎|隨 随|險 险|隮 𬯀|隯 陦|隱 隐|隴 陇|隸 隶|隻 只|雋 隽|雖 虽|雙 双|雛 雏|雜 杂|雞 鸡|離 离|難 难|雲 云|電 电|霑 沾|霢 霡|霧 雾|霽 霁|靂 雳|靄 霭|靆 叇|靈 灵|靉 叆|靚 靓|靜 静|靝 靔|靦 腼|靨 靥|鞏 巩|鞝 绱|鞦 秋|鞽 鞒|韁 缰|韃 鞑|韆 千|韉 鞯|韋 韦|韌 韧|韍 韨|韓 韩|韙 韪|韜 韬|韝 鞲|韞 韫|韻 韵|響 响|頁 页|頂 顶|頃 顷|項 项|順 顺|頇 顸|須 须|頊 顼|頌 颂|頍 𫠆|頎 颀|頏 颃|預 预|頑 顽|頒 颁|頓 顿|頔 𬱖|頗 颇|領 领|頜 颌|頠 𬱟|頡 颉|頤 颐|頦 颏|頫 𫖯|頭 头|頮 颒|頰 颊|頲 颋|頴 颕|頵 𫖳|頷 颔|頸 颈|頹 颓|頻 频|頽 颓|顆 颗|題 题|額 额|顎 颚|顏 颜|顒 颙|顓 颛|顔 颜|顗 𫖮|願 愿|顙 颡|顛 颠|類 类|顢 颟|顥 颢|顧 顾|顫 颤|顬 颥|顯 显|顰 颦|顱 颅|顳 颞|顴 颧|風 风|颭 飐|颮 飑|颯 飒|颱 台|颳 刮|颶 飓|颸 飔|颺 飏|颻 飖|颼 飕|飀 飗|飄 飘|飆 飙|飈 飚|飛 飞|飠 饣|飢 饥|飣 饤|飥 饦|飩 饨|飪 饪|飫 饫|飭 饬|飯 饭|飱 飧|飲 饮|飴 饴|飼 饲|飽 饱|飾 饰|飿 饳|餃 饺|餄 饸|餅 饼|餈 糍|餉 饷|養 养|餌 饵|餎 饹|餏 饻|餑 饽|餒 馁|餓 饿|餕 馂|餖 饾|餗 𫗧|餘 余|餚 肴|餛 馄|餜 馃|餞 饯|餡 馅|館 馆|餬 糊|餱 糇|餳 饧|餵 喂|餶 馉|餷 馇|餸 𩠌|餺 馎|餼 饩|餾 馏|餿 馊|饁 馌|饃 馍|饅 馒|饈 馐|饉 馑|饊 馓|饋 馈|饌 馔|饑 饥|饒 饶|饗 飨|饘 𫗴|饜 餍|饞 馋|饢 馕|馬 马|馭 驭|馮 冯|馱 驮|馳 驰|馴 驯|馹 驲|馼 𫘜|駁 驳|駃 𫘝|駉 𬳶|駐 驻|駑 驽|駒 驹|駓 𬳵|駔 驵|駕 驾|駘 骀|駙 驸|駛 驶|駝 驼|駟 驷|駡 骂|駢 骈|駪 𬳽|駭 骇|駰 骃|駱 骆|駸 骎|駼 𬳿|駿 骏|騁 骋|騂 骍|騄 𫘧|騅 骓|騊 𫘦|騌 骔|騍 骒|騎 骑|騏 骐|騑 𬴂|騖 骛|騙 骗|騞 𬴃|騠 𫘨|騤 骙|騧 䯄|騫 骞|騭 骘|騮 骝|騰 腾|騱 𫘬|騵 𫘪|騶 驺|騷 骚|騸 骟|騾 骡|驀 蓦|驁 骜|驂 骖|驃 骠|驄 骢|驅 驱|驊 骅|驌 骕|驍 骁|驎 𬴊|驏 骣|驕 骄|驗 验|驚 惊|驛 驿|驟 骤|驢 驴|驤 骧|驥 骥|驦 骦|驪 骊|驫 骉|骯 肮|髏 髅|髒 脏|體 体|髕 髌|髖 髋|髮 发|鬆 松|鬍 胡|鬚 须|鬢 鬓|鬥 斗|鬧 闹|鬨 哄|鬩 阋|鬮 阄|鬱 郁|鬹 鬶|魎 魉|魘 魇|魚 鱼|魛 鱽|魟 𫚉|魢 鱾|魨 鲀|魯 鲁|魴 鲂|魷 鱿|魺 鲄|鮀 𬶍|鮁 鲅|鮃 鲆|鮆 𫚖|鮈 𬶋|鮊 鲌|鮋 鲉|鮍 鲏|鮎 鲇|鮐 鲐|鮑 鲍|鮒 鲋|鮓 鲊|鮚 鲒|鮜 鲘|鮝 鲞|鮞 鲕|鮟 𩽾|鮠 𬶏|鮡 𬶐|鮣 䲟|鮦 鲖|鮪 鲔|鮫 鲛|鮭 鲑|鮮 鲜|鮳 鲓|鮶 鲪|鮸 𩾃|鮺 鲝|鯀 鲧|鯁 鲠|鯇 鲩|鯉 鲤|鯊 鲨|鯒 鲬|鯔 鲻|鯕 鲯|鯖 鲭|鯗 鲞|鯛 鲷|鯝 鲴|鯡 鲱|鯢 鲵|鯤 鲲|鯧 鲳|鯨 鲸|鯪 鲮|鯫 鲰|鯰 鲶|鯴 鲺|鯷 鳀|鯻 𬶟|鯽 鲫|鯿 鳊|鰁 鳈|鰂 鲗|鰃 鳂|鰆 䲠|鰈 鲽|鰉 鳇|鰊 𬶠|鰌 䲡|鰍 鳅|鰏 鲾|鰐 鳄|鰒 鳆|鰓 鳃|鰛 鳁|鰜 鳒|鰟 鳑|鰠 鳋|鰣 鲥|鰤 𫚕|鰥 鳏|鰧 䲢|鰨 鳎|鰩 鳐|鰭 鳍|鰮 鳁|鰱 鲢|鰲 鳌|鰳 鳓|鰵 鳘|鰶 𬶭|鰷 鲦|鰹 鲣|鰺 鲹|鰻 鳗|鰼 鳛|鰾 鳔|鱀 𬶨|鱂 鳉|鱅 鳙|鱇 𩾌|鱈 鳕|鱉 鳖|鱒 鳟|鱔 鳝|鱖 鳜|鱗 鳞|鱘 鲟|鱚 𬶮|鱝 鲼|鱟 鲎|鱠 鲙|鱣 鳣|鱤 鳡|鱧 鳢|鱨 鲿|鱭 鲚|鱯 鳠|鱲 𫚭|鱷 鳄|鱸 鲈|鱺 鲡|鳥 鸟|鳧 凫|鳩 鸠|鳬 凫|鳲 鸤|鳳 凤|鳴 鸣|鳶 鸢|鳾 䴓|鴆 鸩|鴇 鸨|鴉 鸦|鴒 鸰|鴕 鸵|鴛 鸳|鴝 鸲|鴞 鸮|鴟 鸱|鴣 鸪|鴦 鸯|鴨 鸭|鴯 鸸|鴰 鸹|鴴 鸻|鴷 䴕|鴻 鸿|鴿 鸽|鵁 䴔|鵂 鸺|鵃 鸼|鵏 𬷕|鵐 鹀|鵑 鹃|鵒 鹆|鵓 鹁|鵜 鹈|鵝 鹅|鵟 𫛭|鵠 鹄|鵡 鹉|鵪 鹌|鵬 鹏|鵮 鹐|鵯 鹎|鵰 雕|鵲 鹊|鵷 鹓|鵾 鹍|鶄 䴖|鶇 鸫|鶉 鹑|鶊 鹒|鶓 鹋|鶖 鹙|鶘 鹕|鶚 鹗|鶠 𬸘|鶡 鹖|鶥 鹛|鶩 鹜|鶪 䴗|鶬 鸧|鶯 莺|鶱 𬸣|鶲 鹟|鶴 鹤|鶹 鹠|鶺 鹡|鶻 鹘|鶼 鹣|鶿 鹚|鷀 鹚|鷁 鹢|鷂 鹞|鷄 鸡|鷉 䴘|鷊 鹝|鷓 鹧|鷖 鹥|鷗 鸥|鷙 鸷|鷚 鹨|鷟 𬸦|鷥 鸶|鷦 鹪|鷫 鹔|鷭 𬸪|鷯 鹩|鷲 鹫|鷳 鹇|鷴 鹇|鷸 鹬|鷹 鹰|鷺 鹭|鷽 鸴|鸂 㶉|鸇 鹯|鸊 䴙|鸌 鹱|鸏 鹲|鸑 𬸚|鸕 鸬|鸘 鹴|鸚 鹦|鸛 鹳|鸝 鹂|鸞 鸾|鹵 卤|鹹 咸|鹺 鹾|鹼 碱|鹽 盐|麗 丽|麥 麦|麩 麸|麪 面|麫 面|麬 𤿲|麯 曲|麳 𪎌|麴 曲|麵 面|麼 么|麽 么|黃 黄|黌 黉|點 点|黨 党|黲 黪|黴 霉|黶 黡|黷 黩|黽 黾|黿 鼋|鼂 鼌|鼉 鼍|鼕 冬|鼴 鼹|齊 齐|齋 斋|齎 赍|齏 齑|齒 齿|齔 龀|齕 龁|齗 龂|齘 𬹼|齙 龅|齜 龇|齟 龃|齠 龆|齡 龄|齣 出|齦 龈|齧 啮|齪 龊|齬 龉|齮 𬺈|齯 𫠜|齲 龋|齶 腭|齷 龌|齼 𬺓|龍 龙|龎 厐|龐 庞|龑 䶮|龔 龚|龕 龛|龜 龟|鿁 䜤|鿓 鿒|𠁞 𠀾|𠌥 𠌥|𠏢 𠏢|𠐊 𠐊|𠗣 㓆|𠞆 𠞆|𠠎 𠠎|𠬙 𠬙|𠼤 𠼤|𠽃 𠽃|𠿕 𠿕|𡂡 𡂡|𡃄 𡃄|𡃕 𠴛|𡃤 𡃤|𡄔 𡄔|𡄣 𡄣|𡅏 𠲥|𡅯 𡅯|𡑍 𫭼|𡑭 𡋗|𡓁 𡓁|𡓾 𡋀|𡔖 𡍣|𡞵 㛟|𡟫 𡟫|𡠹 㛿|𡢃 㛠|𡮉 𡭜|𡮣 𡭬|𡳳 𡳃|𡸗 𡸗|𡹬 𡹬|𡻕 岁|𡽗 𡽗|𡾱 㟜|𡿖 𡿖|𢍰 𢍰|𢠼 𢠼|𢣐 𢣐|𢣚 𢘝|𢣭 𢣭|𢤩 𢤩|𢤱 𢤱|𢤿 𢤿|𢯷 𢯷|𢶒 𢶒|𢶫 𢫞|𢷮 𢷮|𢹿 𢬦|𢺳 𢺳|𣈶 暅|𣋋 𣋋|𣍐 𣍐|𣙎 㭣|𣜬 𣜬|𣝕 𣝕|𣞻 𣘓|𣠩 𣞎|𣠲 𣑶|𣯩 𣯩|𣯴 𣯴|𣯶 毶|𣽏 𣽏|𣾷 㳢|𣿉 𣿉|𤁣 𣺽|𤄷 𤄷|𤅶 𣷷|𤑳 𤑳|𤑹 𤑹|𤒎 𤒎|𤒻 𤒻|𤓌 𤓌|𤓎 𤓎|𤓩 𤊰|𤘀 𤘀|𤛮 𤛮|𤛱 𤛱|𤜆 𤜆|𤠮 𤠮|𤢟 𤢟|𤢻 𤢻|𤩂 𤩂|𤪺 㻘|𤫩 㻏|𤬅 𤬅|𤳷 𤳷|𤳸 𤳄|𤷃 𤷃|𤸫 𤸫|𤺔 𤺔|𥊝 𥅿|𥌃 𥅘|𥏝 𥏝|𥕥 𥐰|𥖅 𥐯|𥖲 𥖲|𥗇 𥗇|𥗽 𬒗|𥜐 𥜐|𥜰 𥜰|𥞵 𥞵|𥢢 䅪|𥢶 𥢶|𥢷 𥢷|𥨐 𥨐|𥪂 𥪂|𥯤 𥯤|𥴨 𥴨|𥴼 𥴼|𥵃 𥵃|𥵊 𥵊|𥶽 𥶽|𥸠 𥮋|𥻦 𥻦|𥼽 𥹥|𥽖 𥽖|𥾯 𥾯|𥿊 𥿊|𦀖 𦀖|𦂅 𦂅|𦃄 𦃄|𦃩 𦃩|𦅇 𦅇|𦅈 𦅈|𦆲 𦆲|𦒀 𦒀|𦔖 𦔖|𦘧 𡳒|𦟼 𦟼|𦠅 𦠅|𦡝 𦡝|𦢈 𦢈|𦣎 𦟗|𦧺 𦧺|𦪙 䑽|𦪽 𦪽|𦱌 𦱌|𦾟 𦾟|𧎈 𧎈|𧒯 𧒯|𧔥 𧔥|𧕟 𧕟|𧜗 䘞|𧜵 䙊|𧝞 䘛|𧞫 𧞫|𧟀 𧝧|𧡴 𧡴|𧢄 𧢄|𧦝 𧦝|𧦧 𧦧|𧩕 𧩕|𧩙 䜥|𧩼 𧩼|𧫝 𧫝|𧬤 𧬤|𧭈 𧭈|𧭹 𧭹|𧳟 𧳟|𧵳 䞌|𧶔 𧶔|𧶧 䞎|𧷎 𧷎|𧸘 𧸘|𧹈 𧹈|𧽯 𧽯|𨂐 𨂐|𨄣 𨄣|𨅍 𨅍|𨆪 𨆪|𨇁 𨇁|𨇞 𨇞|𨇤 𨇤|𨇰 𨇰|𨇽 𨇽|𨈊 𨈊|𨈌 𨈌|𨊰 䢀|𨊸 䢁|𨊻 𨊻|𨋢 䢂|𨌈 𨌈|𨍰 𨍰|𨎌 𨎌|𨎮 𨎮|𨏠 𨏠|𨏥 𨏥|𨞺 𨞺|𨟊 𨟊|𨢿 𨢿|𨣈 𨣈|𨣞 𨣞|𨣧 𨣧|𨤻 𨤰|𨥛 𨥛|𨥟 𨥟|𨦫 䦀|𨧀 𬭊|𨧜 䦁|𨧰 𨧰|𨧱 𨧱|𨨏 𬭛|𨨛 𨨛|𨨢 𨨢|𨩰 𨩰|𨪕 𨪕|𨫒 𨫒|𨬖 𨬖|𨭆 𬭶|𨭎 𬭳|𨭖 𨭖|𨭸 𨭸|𨮂 𨮂|𨮳 𨮳|𨯅 䥿|𨯟 𨯟|𨰃 𨰃|𨰋 𨰋|𨰥 𨰥|𨰲 𨰲|𨲳 𨲳|𨳑 𨳑|𨳕 𨳕|𨴗 𨴗|𨴹 𨴹|𨵩 𨵩|𨵸 𨵸|𨶀 𨶀|𨶏 𨶏|𨶮 𨶮|𨶲 𨶲|𨷲 𨷲|𨼳 𨼳|𨽏 𨽏|𩀨 𩀨|𩅙 𩅙|𩎖 𩎖|𩎢 𩎢|𩏂 𩏂|𩏠 𩏠|𩏪 𩏪|𩏷 𩏷|𩑔 𩑔|𩒎 𩒎|𩓣 𩓣|𩓥 𩓥|𩔑 𩔑|𩔳 𩔳|𩖰 𩖰|𩗀 𩗀|𩗓 𩗓|𩗴 𩗴|𩘀 𩘀|𩘝 𩘝|𩘹 𩘹|𩘺 𩘺|𩙈 𩙈|𩚛 𩚛|𩚥 𩚥|𩚩 𩚩|𩚵 𩚵|𩛆 𩛆|𩛌 𩛌|𩛡 𩛡|𩛩 𩛩|𩜇 𩜇|𩜦 𩜦|𩜵 𩜵|𩝔 𩝔|𩝽 𩝽|𩞄 𩞄|𩞦 𩞦|𩞯 䭪|𩟐 𩟐|𩟗 𩟗|𩠴 𩠠|𩡣 𩡣|𩡺 𩡺|𩢡 𩢡|𩢴 𩢴|𩢸 𩢸|𩢾 𩢾|𩣏 𩣏|𩣑 䯃|𩣫 𩣫|𩣵 𩣵|𩣺 𩣺|𩤊 𩤊|𩤙 𩤙|𩤲 𩤲|𩤸 𩤸|𩥄 𩥄|𩥇 𩥇|𩥉 𩥉|𩥑 𩥑|𩦠 𩦠|𩧆 𩧆|𩭙 𩭙|𩯁 𩯁|𩯳 𩯳|𩰀 𩰀|𩰹 𩰹|𩳤 𩳤|𩴵 𩴵|𩵦 𩵦|𩵩 𩵩|𩵹 𩵹|𩶁 𩶁|𩶘 䲞|𩶰 𩶰|𩶱 𩶱|𩷰 𩷰|𩸃 𩸃|𩸄 𩸄|𩸡 𩸡|𩸦 𩸦|𩻗 𩻗|𩻬 𩻬|𩻮 𩻮|𩼶 𩼶|𩽇 𩽇|𩿅 𩿅|𩿤 𩿤|𩿪 𩿪|𪀖 𪀖|𪀦 𪀦|𪀾 𪀾|𪁈 𪁈|𪁖 𪁖|𪂆 𪂆|𪃍 𪃍|𪃏 𪃏|𪃒 𪃒|𪃧 𪃧|𪄆 𪄆|𪄕 𪄕|𪅂 𪅂|𪆷 𪆷|𪇳 𪇳|𪈼 𪈼|𪉸 𪉸|𪋿 𪋿|𪌭 𪌭|𪍠 𪍠|𪓰 𪓰|𪔵 𪔵|𪘀 𪘀|𪘯 𪘯|𪙏 𪙏|𪟖 𪟖|𪷓 𪷓|𫒡 𫒡|𫜦 𫜦|𰻞 𰻝` + +// zhS2TData 简体→繁体("简 繁|简 繁|…")。 +const zhS2TData = `㐷 傌|㐹 㑶|㐽 偑|㑇 㑳|㑈 倲|㑔 㑯|㑩 儸|㓆 𠗣|㓥 劏|㓰 劃|㔉 劚|㖊 噚|㖞 喎|㘎 㘚|㚯 㜄|㛀 媰|㛟 𡞵|㛠 𡢃|㛣 㜏|㛤 孋|㛿 𡠹|㟆 㠏|㟜 𡾱|㟥 嵾|㡎 幓|㤘 㥮|㤽 懤|㥪 慺|㧏 掆|㧐 㩳|㧑 撝|㧟 擓|㧰 擽|㨫 㩜|㭎 棡|㭏 椲|㭣 𣙎|㭤 樢|㭴 樫|㱩 殰|㱮 殨|㲿 瀇|㳔 濧|㳕 灡|㳠 澾|㳡 濄|㳢 𣾷|㳽 瀰|㴋 潚|㶉 鸂|㶶 燶|㶽 煱|㺍 獱|㻅 璯|㻏 𤫩|㻘 𤪺|䀥 䁻|䁖 瞜|䂵 碽|䃅 磾|䅉 稏|䅟 穇|䅪 𥢢|䇲 筴|䉤 籔|䌶 䊷|䌷 紬|䌸 縳|䌹 絅|䌺 䋙|䌻 䋚|䌼 綐|䌽 綵|䌾 䋻|䌿 䋹|䍀 繿|䍁 繸|䍠 䍦|䎬 䎱|䏝 膞|䑽 𦪙|䓓 薵|䓕 薳|䓖 藭|䓨 罃|䗖 螮|䘛 𧝞|䘞 𧜗|䙊 𧜵|䙌 䙡|䙓 襬|䜣 訢|䜤 鿁|䜥 𧩙|䜧 䜀|䜩 讌|䝙 貙|䞌 𧵳|䞍 䝼|䞎 𧶧|䞐 賰|䟢 躎|䢀 𨊰|䢁 𨊸|䢂 𨋢|䥺 釾|䥽 鏺|䥾 䥱|䥿 𨯅|䦀 𨦫|䦁 𨧜|䦂 䥇|䦃 鐯|䦅 鐥|䦆 钁|䦶 䦛|䦷 䦟|䩄 靦|䭪 𩞯|䯃 𩣑|䯄 騧|䯅 䯀|䲝 䱽|䲞 𩶘|䲟 鮣|䲠 鰆|䲡 鰌|䲢 鰧|䲣 䱷|䴓 鳾|䴔 鵁|䴕 鴷|䴖 鶄|䴗 鶪|䴘 鷉|䴙 鸊|䶮 龑|万 萬|与 與|丑 醜|专 專|业 業|丛 叢|东 東|丝 絲|丢 丟|两 兩|严 嚴|丧 喪|个 個|丰 豐|临 臨|为 爲|丽 麗|举 舉|么 麼|义 義|乌 烏|乐 樂|乔 喬|习 習|乡 鄉|书 書|买 買|乱 亂|争 爭|于 於|亏 虧|云 雲|亘 亙|亚 亞|产 產|亩 畝|亲 親|亵 褻|亸 嚲|亿 億|仅 僅|仆 僕|从 從|仑 侖|仓 倉|仪 儀|们 們|价 價|众 衆|优 優|伙 夥|会 會|伛 傴|伞 傘|伟 偉|传 傳|伡 俥|伣 俔|伤 傷|伥 倀|伦 倫|伧 傖|伪 僞|伫 佇|体 體|余 餘|佣 傭|佥 僉|侠 俠|侣 侶|侥 僥|侦 偵|侧 側|侨 僑|侩 儈|侪 儕|侬 儂|侭 儘|俣 俁|俦 儔|俨 儼|俩 倆|俪 儷|俫 倈|俭 儉|债 債|倾 傾|偬 傯|偻 僂|偾 僨|偿 償|傤 儎|傥 儻|傧 儐|储 儲|傩 儺|儿 兒|兑 兌|兖 兗|党 黨|兰 蘭|关 關|兴 興|兹 茲|养 養|兽 獸|冁 囅|内 內|冈 岡|册 冊|写 寫|军 軍|农 農|冯 馮|冲 衝|决 決|况 況|冻 凍|净 淨|凄 悽|准 準|凉 涼|减 減|凑 湊|凛 凜|几 幾|凤 鳳|凫 鳧|凭 憑|凯 凱|凶 兇|击 擊|凿 鑿|刍 芻|划 劃|刘 劉|则 則|刚 剛|创 創|删 刪|别 別|刬 剗|刭 剄|刹 剎|刽 劊|刾 㓨|刿 劌|剀 剴|剂 劑|剐 剮|剑 劍|剥 剝|剧 劇|劝 勸|办 辦|务 務|劢 勱|动 動|励 勵|劲 勁|劳 勞|势 勢|勋 勳|勚 勩|匀 勻|匦 匭|匮 匱|区 區|医 醫|华 華|协 協|单 單|卖 賣|占 佔|卢 盧|卤 滷|卧 臥|卫 衛|却 卻|卺 巹|厂 廠|厅 廳|历 歷|厉 厲|压 壓|厌 厭|厍 厙|厐 龎|厕 廁|厘 釐|厢 廂|厣 厴|厦 廈|厨 廚|厩 廄|厮 廝|县 縣|叁 叄|参 參|叆 靉|叇 靆|双 雙|发 發|变 變|叙 敘|叠 疊|台 臺|叶 葉|号 號|叹 嘆|叽 嘰|吁 籲|吃 喫|后 後|吓 嚇|吕 呂|吗 嗎|吨 噸|听 聽|启 啓|吴 吳|呐 吶|呒 嘸|呓 囈|呕 嘔|呖 嚦|呗 唄|员 員|呙 咼|呛 嗆|呜 嗚|咏 詠|咙 嚨|咛 嚀|咝 噝|咤 吒|咨 諮|咸 鹹|响 響|哑 啞|哒 噠|哓 嘵|哔 嗶|哕 噦|哗 譁|哙 噲|哜 嚌|哝 噥|哟 喲|唇 脣|唛 嘜|唝 嗊|唠 嘮|唡 啢|唢 嗩|唤 喚|啧 嘖|啬 嗇|啭 囀|啮 齧|啯 嘓|啰 囉|啴 嘽|啸 嘯|喷 噴|喽 嘍|喾 嚳|嗫 囁|嗳 噯|嘘 噓|嘤 嚶|嘱 囑|噜 嚕|嚣 囂|团 團|园 園|囱 囪|围 圍|囵 圇|国 國|图 圖|圆 圓|圣 聖|圹 壙|场 場|坏 壞|块 塊|坚 堅|坛 壇|坜 壢|坝 壩|坞 塢|坟 墳|坠 墜|垄 壟|垅 壠|垆 壚|垒 壘|垦 墾|垩 堊|垫 墊|垭 埡|垯 墶|垱 壋|垲 塏|垴 堖|埘 塒|埙 壎|埚 堝|堑 塹|堕 墮|塆 壪|墙 牆|壮 壯|声 聲|壳 殼|壶 壺|壸 壼|处 處|备 備|复 復|够 夠|头 頭|夸 誇|夹 夾|夺 奪|奁 奩|奂 奐|奋 奮|奖 獎|奥 奧|妆 妝|妇 婦|妈 媽|妩 嫵|妪 嫗|妫 嬀|姗 姍|姹 奼|娄 婁|娅 婭|娆 嬈|娇 嬌|娈 孌|娱 娛|娲 媧|娴 嫺|婳 嫿|婴 嬰|婵 嬋|婶 嬸|媪 媼|媭 嬃|嫒 嬡|嫔 嬪|嫱 嬙|嬷 嬤|孙 孫|学 學|孪 孿|宁 寧|宝 寶|实 實|宠 寵|审 審|宪 憲|宫 宮|宽 寬|宾 賓|寝 寢|对 對|寻 尋|导 導|寿 壽|将 將|尔 爾|尘 塵|尝 嘗|尧 堯|尴 尷|尸 屍|尽 盡|层 層|屃 屓|屉 屜|届 屆|属 屬|屡 屢|屦 屨|屿 嶼|岁 歲|岂 豈|岖 嶇|岗 崗|岘 峴|岚 嵐|岛 島|岩 巖|岭 嶺|岳 嶽|岽 崬|岿 巋|峃 嶨|峄 嶧|峡 峽|峣 嶢|峤 嶠|峥 崢|峦 巒|峰 峯|崂 嶗|崃 崍|崄 嶮|崭 嶄|嵘 嶸|嵚 嶔|嵝 嶁|巅 巔|巩 鞏|巯 巰|币 幣|帅 帥|师 師|帏 幃|帐 帳|帘 簾|帜 幟|带 帶|帧 幀|帮 幫|帱 幬|帻 幘|帼 幗|幂 冪|干 幹|并 並|广 廣|庄 莊|庆 慶|床 牀|庐 廬|庑 廡|库 庫|应 應|庙 廟|庞 龐|废 廢|庼 廎|廪 廩|开 開|异 異|弃 棄|弑 弒|张 張|弥 彌|弪 弳|弯 彎|弹 彈|强 強|归 歸|当 當|录 錄|彟 彠|彦 彥|彨 彲|彻 徹|征 徵|径 徑|徕 徠|忆 憶|忏 懺|忧 憂|忾 愾|怀 懷|态 態|怂 慫|怃 憮|怄 慪|怅 悵|怆 愴|怜 憐|总 總|怼 懟|怿 懌|恋 戀|恒 恆|恳 懇|恶 惡|恸 慟|恹 懨|恺 愷|恻 惻|恼 惱|恽 惲|悦 悅|悫 愨|悬 懸|悭 慳|悮 悞|悯 憫|惊 驚|惧 懼|惨 慘|惩 懲|惫 憊|惬 愜|惭 慚|惮 憚|惯 慣|愠 慍|愤 憤|愦 憒|愿 願|慑 懾|慭 憖|懑 懣|懒 懶|懔 懍|戆 戇|戋 戔|戏 戲|戗 戧|战 戰|戬 戩|戯 戱|户 戶|扑 撲|执 執|扩 擴|扪 捫|扫 掃|扬 揚|扰 擾|抚 撫|抛 拋|抟 摶|抠 摳|抡 掄|抢 搶|护 護|报 報|担 擔|拟 擬|拢 攏|拣 揀|拥 擁|拦 攔|拧 擰|拨 撥|择 擇|挂 掛|挚 摯|挛 攣|挜 掗|挝 撾|挞 撻|挟 挾|挠 撓|挡 擋|挢 撟|挣 掙|挤 擠|挥 揮|挦 撏|捝 挩|捞 撈|损 損|捡 撿|换 換|捣 搗|据 據|掳 擄|掴 摑|掷 擲|掸 撣|掺 摻|掼 摜|揽 攬|揾 搵|揿 撳|搀 攙|搁 擱|搂 摟|搄 揯|搅 攪|携 攜|摄 攝|摅 攄|摆 擺|摇 搖|摈 擯|摊 攤|撄 攖|撑 撐|撵 攆|撷 擷|撸 擼|撺 攛|擜 㩵|擞 擻|攒 攢|敌 敵|敚 敓|敛 斂|敩 斆|数 數|斋 齋|斓 斕|斗 鬥|斩 斬|断 斷|无 無|旧 舊|时 時|旷 曠|旸 暘|昙 曇|昵 暱|昼 晝|昽 曨|显 顯|晋 晉|晒 曬|晓 曉|晔 曄|晕 暈|晖 暉|暂 暫|暅 𣈶|暧 曖|术 術|朴 樸|机 機|杀 殺|杂 雜|权 權|杠 槓|条 條|来 來|杨 楊|杩 榪|杰 傑|极 極|构 構|枞 樅|枢 樞|枣 棗|枥 櫪|枧 梘|枨 棖|枪 槍|枫 楓|枭 梟|柜 櫃|柠 檸|柽 檉|栀 梔|栅 柵|标 標|栈 棧|栉 櫛|栊 櫳|栋 棟|栌 櫨|栎 櫟|栏 欄|树 樹|栖 棲|样 樣|栾 欒|桠 椏|桡 橈|桢 楨|档 檔|桤 榿|桥 橋|桦 樺|桧 檜|桨 槳|桩 樁|桪 樳|梦 夢|梼 檮|梾 棶|梿 槤|检 檢|棁 梲|棂 欞|椁 槨|椝 槼|椟 櫝|椠 槧|椢 槶|椤 欏|椫 樿|椭 橢|椮 槮|楼 樓|榄 欖|榅 榲|榇 櫬|榈 櫚|榉 櫸|榝 樧|槚 檟|槛 檻|槟 檳|槠 櫧|横 橫|樯 檣|樱 櫻|橥 櫫|橱 櫥|橹 櫓|橼 櫞|檩 檁|欢 歡|欤 歟|欧 歐|歼 殲|殁 歿|殇 殤|残 殘|殒 殞|殓 殮|殚 殫|殡 殯|殴 毆|毁 毀|毂 轂|毕 畢|毙 斃|毡 氈|毵 毿|毶 𣯶|氇 氌|气 氣|氢 氫|氩 氬|氲 氳|汇 匯|汉 漢|汤 湯|汹 洶|沄 澐|沟 溝|没 沒|沣 灃|沤 漚|沥 瀝|沦 淪|沧 滄|沨 渢|沩 潙|沪 滬|泞 濘|泪 淚|泶 澩|泷 瀧|泸 瀘|泺 濼|泻 瀉|泼 潑|泽 澤|泾 涇|洁 潔|洒 灑|洼 窪|浃 浹|浅 淺|浆 漿|浇 澆|浈 湞|浉 溮|浊 濁|测 測|浍 澮|济 濟|浏 瀏|浐 滻|浑 渾|浒 滸|浓 濃|浔 潯|浕 濜|涂 塗|涌 湧|涚 涗|涛 濤|涝 澇|涞 淶|涟 漣|涠 潿|涡 渦|涢 溳|涣 渙|涤 滌|润 潤|涧 澗|涨 漲|涩 澀|淀 澱|渊 淵|渌 淥|渍 漬|渎 瀆|渐 漸|渑 澠|渔 漁|渖 瀋|渗 滲|温 溫|游 遊|湾 灣|湿 溼|溁 濚|溃 潰|溅 濺|溆 漵|溇 漊|滗 潷|滚 滾|滞 滯|滟 灩|滠 灄|满 滿|滢 瀅|滤 濾|滥 濫|滦 灤|滨 濱|滩 灘|滪 澦|潆 瀠|潇 瀟|潋 瀲|潍 濰|潜 潛|潴 瀦|澛 瀂|澜 瀾|濑 瀨|濒 瀕|灏 灝|灭 滅|灯 燈|灵 靈|灶 竈|灾 災|灿 燦|炀 煬|炉 爐|炖 燉|炜 煒|炝 熗|点 點|炼 煉|炽 熾|烁 爍|烂 爛|烃 烴|烛 燭|烟 煙|烦 煩|烧 燒|烨 燁|烩 燴|烫 燙|烬 燼|热 熱|焕 煥|焖 燜|焘 燾|煴 熅|熏 燻|爱 愛|爷 爺|牍 牘|牦 犛|牵 牽|牺 犧|犊 犢|状 狀|犷 獷|犸 獁|犹 猶|狈 狽|狝 獮|狞 獰|独 獨|狭 狹|狮 獅|狯 獪|狰 猙|狱 獄|狲 猻|猃 獫|猎 獵|猕 獼|猡 玀|猪 豬|猫 貓|猬 蝟|献 獻|獭 獺|玑 璣|玙 璵|玚 瑒|玛 瑪|玮 瑋|环 環|现 現|玱 瑲|玺 璽|珐 琺|珑 瓏|珰 璫|珲 琿|琎 璡|琏 璉|琐 瑣|琼 瓊|瑶 瑤|瑷 璦|瑸 璸|璎 瓔|瓒 瓚|瓮 甕|瓯 甌|电 電|画 畫|畅 暢|畴 疇|疖 癤|疗 療|疟 瘧|疠 癘|疡 瘍|疬 癧|疭 瘲|疮 瘡|疯 瘋|疱 皰|疴 痾|痈 癰|痉 痙|痒 癢|痖 瘂|痨 癆|痪 瘓|痫 癇|痴 癡|瘅 癉|瘆 瘮|瘗 瘞|瘘 瘻|瘪 癟|瘫 癱|瘾 癮|瘿 癭|癞 癩|癣 癬|癫 癲|皂 皁|皑 皚|皱 皺|皲 皸|盏 盞|盐 鹽|监 監|盖 蓋|盗 盜|盘 盤|眍 瞘|眦 眥|眬 矓|睁 睜|睐 睞|睑 瞼|瞆 瞶|瞒 瞞|瞩 矚|矫 矯|矶 磯|矾 礬|矿 礦|砀 碭|码 碼|砖 磚|砗 硨|砚 硯|砜 碸|砺 礪|砻 礱|砾 礫|础 礎|硁 硜|硕 碩|硖 硤|硗 磽|硙 磑|硚 礄|确 確|硵 磠|碍 礙|碛 磧|碜 磣|碱 鹼|礼 禮|祃 禡|祎 禕|祢 禰|祯 禎|祷 禱|祸 禍|禀 稟|禄 祿|禅 禪|离 離|秃 禿|秆 稈|种 種|秘 祕|积 積|称 稱|秽 穢|秾 穠|稆 穭|税 稅|稣 穌|稳 穩|穑 穡|穞 穭|穷 窮|窃 竊|窍 竅|窎 窵|窑 窯|窜 竄|窝 窩|窥 窺|窦 竇|窭 窶|竖 豎|竞 競|笃 篤|笋 筍|笔 筆|笕 筧|笺 箋|笼 籠|笾 籩|筑 築|筚 篳|筛 篩|筜 簹|筝 箏|筹 籌|筼 篔|签 籤|筿 篠|简 簡|箓 籙|箦 簀|箧 篋|箨 籜|箩 籮|箪 簞|箫 簫|篑 簣|篓 簍|篮 籃|篯 籛|篱 籬|簖 籪|籁 籟|籴 糴|类 類|籼 秈|粜 糶|粝 糲|粤 粵|粪 糞|粮 糧|粽 糉|糁 糝|糇 餱|糍 餈|紧 緊|絷 縶|緼 縕|縆 緪|纟 糹|纠 糾|纡 紆|红 紅|纣 紂|纤 纖|纥 紇|约 約|级 級|纨 紈|纩 纊|纪 紀|纫 紉|纬 緯|纭 紜|纮 紘|纯 純|纰 紕|纱 紗|纲 綱|纳 納|纴 紝|纵 縱|纶 綸|纷 紛|纸 紙|纹 紋|纺 紡|纻 紵|纼 紖|纽 紐|纾 紓|线 線|绀 紺|绁 紲|绂 紱|练 練|组 組|绅 紳|细 細|织 織|终 終|绉 縐|绊 絆|绋 紼|绌 絀|绍 紹|绎 繹|经 經|绐 紿|绑 綁|绒 絨|结 結|绔 絝|绕 繞|绖 絰|绗 絎|绘 繪|给 給|绚 絢|绛 絳|络 絡|绝 絕|绞 絞|统 統|绠 綆|绡 綃|绢 絹|绣 繡|绤 綌|绥 綏|绦 絛|继 繼|绨 綈|绩 績|绪 緒|绫 綾|绬 緓|续 續|绮 綺|绯 緋|绰 綽|绱 鞝|绲 緄|绳 繩|维 維|绵 綿|绶 綬|绷 繃|绸 綢|绹 綯|绺 綹|绻 綣|综 綜|绽 綻|绾 綰|绿 綠|缀 綴|缁 緇|缂 緙|缃 緗|缄 緘|缅 緬|缆 纜|缇 緹|缈 緲|缉 緝|缊 縕|缋 繢|缌 緦|缍 綞|缎 緞|缏 緶|缐 線|缑 緱|缒 縋|缓 緩|缔 締|缕 縷|编 編|缗 緡|缘 緣|缙 縉|缚 縛|缛 縟|缜 縝|缝 縫|缞 縗|缟 縞|缠 纏|缡 縭|缢 縊|缣 縑|缤 繽|缥 縹|缦 縵|缧 縲|缨 纓|缩 縮|缪 繆|缫 繅|缬 纈|缭 繚|缮 繕|缯 繒|缰 繮|缱 繾|缲 繰|缳 繯|缴 繳|缵 纘|罂 罌|网 網|罗 羅|罚 罰|罢 罷|罴 羆|羁 羈|羟 羥|羡 羨|群 羣|翘 翹|翙 翽|翚 翬|耢 耮|耧 耬|耸 聳|耻 恥|聂 聶|聋 聾|职 職|聍 聹|联 聯|聩 聵|聪 聰|肃 肅|肠 腸|肤 膚|肮 骯|肴 餚|肾 腎|肿 腫|胀 脹|胁 脅|胆 膽|胜 勝|胧 朧|胨 腖|胪 臚|胫 脛|胶 膠|脉 脈|脍 膾|脏 髒|脐 臍|脑 腦|脓 膿|脔 臠|脚 腳|脱 脫|脶 腡|脸 臉|腊 臘|腌 醃|腘 膕|腭 齶|腻 膩|腼 靦|腽 膃|腾 騰|膑 臏|膻 羶|臜 臢|舆 輿|舣 艤|舰 艦|舱 艙|舻 艫|艰 艱|艳 豔|艺 藝|节 節|芈 羋|芗 薌|芜 蕪|芦 蘆|苁 蓯|苇 葦|苈 藶|苋 莧|苌 萇|苍 蒼|苎 苧|苏 蘇|苧 薴|苹 蘋|范 範|茎 莖|茏 蘢|茑 蔦|茔 塋|茕 煢|茧 繭|荆 荊|荐 薦|荙 薘|荚 莢|荛 蕘|荜 蓽|荝 萴|荞 蕎|荟 薈|荠 薺|荡 蕩|荣 榮|荤 葷|荥 滎|荦 犖|荧 熒|荨 蕁|荩 藎|荪 蓀|荫 蔭|荬 蕒|荭 葒|荮 葤|药 藥|莅 蒞|莱 萊|莲 蓮|莳 蒔|莴 萵|莶 薟|获 獲|莸 蕕|莹 瑩|莺 鶯|莼 蓴|萚 蘀|萝 蘿|萤 螢|营 營|萦 縈|萧 蕭|萨 薩|葱 蔥|蒀 蒕|蒇 蕆|蒉 蕢|蒋 蔣|蒌 蔞|蒏 醟|蓝 藍|蓟 薊|蓠 蘺|蓣 蕷|蓥 鎣|蓦 驀|蔂 虆|蔷 薔|蔹 蘞|蔺 藺|蔼 藹|蕰 薀|蕲 蘄|蕴 蘊|薮 藪|藓 蘚|藴 蘊|蘖 櫱|虏 虜|虑 慮|虚 虛|虫 蟲|虬 虯|虮 蟣|虱 蝨|虽 雖|虾 蝦|虿 蠆|蚀 蝕|蚁 蟻|蚂 螞|蚃 蠁|蚕 蠶|蚝 蠔|蚬 蜆|蛊 蠱|蛎 蠣|蛏 蟶|蛮 蠻|蛰 蟄|蛱 蛺|蛲 蟯|蛳 螄|蛴 蠐|蜕 蛻|蜗 蝸|蜡 蠟|蝇 蠅|蝈 蟈|蝉 蟬|蝎 蠍|蝼 螻|蝾 蠑|螀 螿|螨 蟎|蟏 蠨|衅 釁|衔 銜|补 補|衬 襯|衮 袞|袄 襖|袅 嫋|袆 褘|袜 襪|袭 襲|袯 襏|装 裝|裆 襠|裈 褌|裢 褳|裣 襝|裤 褲|裥 襉|褛 褸|褴 襤|襕 襴|见 見|观 觀|觃 覎|规 規|觅 覓|视 視|觇 覘|览 覽|觉 覺|觊 覬|觋 覡|觌 覿|觍 覥|觎 覦|觏 覯|觐 覲|觑 覷|觞 觴|触 觸|觯 觶|訚 誾|詟 讋|誉 譽|誊 謄|讠 訁|计 計|订 訂|讣 訃|认 認|讥 譏|讦 訐|讧 訌|讨 討|让 讓|讪 訕|讫 訖|讬 託|训 訓|议 議|讯 訊|记 記|讱 訒|讲 講|讳 諱|讴 謳|讵 詎|讶 訝|讷 訥|许 許|讹 訛|论 論|讻 訩|讼 訟|讽 諷|设 設|访 訪|诀 訣|证 證|诂 詁|诃 訶|评 評|诅 詛|识 識|诇 詗|诈 詐|诉 訴|诊 診|诋 詆|诌 謅|词 詞|诎 詘|诏 詔|诐 詖|译 譯|诒 詒|诓 誆|诔 誄|试 試|诖 詿|诗 詩|诘 詰|诙 詼|诚 誠|诛 誅|诜 詵|话 話|诞 誕|诟 詬|诠 詮|诡 詭|询 詢|诣 詣|诤 諍|该 該|详 詳|诧 詫|诨 諢|诩 詡|诪 譸|诫 誡|诬 誣|语 語|诮 誚|误 誤|诰 誥|诱 誘|诲 誨|诳 誑|说 說|诵 誦|诶 誒|请 請|诸 諸|诹 諏|诺 諾|读 讀|诼 諑|诽 誹|课 課|诿 諉|谀 諛|谁 誰|谂 諗|调 調|谄 諂|谅 諒|谆 諄|谇 誶|谈 談|谉 讅|谊 誼|谋 謀|谌 諶|谍 諜|谎 謊|谏 諫|谐 諧|谑 謔|谒 謁|谓 謂|谔 諤|谕 諭|谖 諼|谗 讒|谘 諮|谙 諳|谚 諺|谛 諦|谜 謎|谝 諞|谞 諝|谟 謨|谠 讜|谡 謖|谢 謝|谣 謠|谤 謗|谥 諡|谦 謙|谧 謐|谨 謹|谩 謾|谪 謫|谫 譾|谬 謬|谭 譚|谮 譖|谯 譙|谰 讕|谱 譜|谲 譎|谳 讞|谴 譴|谵 譫|谶 讖|豮 豶|贝 貝|贞 貞|负 負|贠 貟|贡 貢|财 財|责 責|贤 賢|败 敗|账 賬|货 貨|质 質|贩 販|贪 貪|贫 貧|贬 貶|购 購|贮 貯|贯 貫|贰 貳|贱 賤|贲 賁|贳 貰|贴 貼|贵 貴|贶 貺|贷 貸|贸 貿|费 費|贺 賀|贻 貽|贼 賊|贽 贄|贾 賈|贿 賄|赀 貲|赁 賃|赂 賂|赃 贓|资 資|赅 賅|赆 贐|赇 賕|赈 賑|赉 賚|赊 賒|赋 賦|赌 賭|赍 齎|赎 贖|赏 賞|赐 賜|赑 贔|赒 賙|赓 賡|赔 賠|赕 賧|赖 賴|赗 賵|赘 贅|赙 賻|赚 賺|赛 賽|赜 賾|赝 贗|赞 贊|赟 贇|赠 贈|赡 贍|赢 贏|赣 贛|赪 赬|赵 趙|赶 趕|趋 趨|趱 趲|趸 躉|跃 躍|跄 蹌|跖 蹠|跞 躒|践 踐|跶 躂|跷 蹺|跸 蹕|跹 躚|跻 躋|踌 躊|踪 蹤|踬 躓|踯 躑|蹑 躡|蹒 蹣|蹰 躕|蹿 躥|躏 躪|躜 躦|躯 軀|輼 轀|车 車|轧 軋|轨 軌|轩 軒|轪 軑|轫 軔|转 轉|轭 軛|轮 輪|软 軟|轰 轟|轱 軲|轲 軻|轳 轤|轴 軸|轵 軹|轶 軼|轷 軤|轸 軫|轹 轢|轺 軺|轻 輕|轼 軾|载 載|轾 輊|轿 轎|辀 輈|辁 輇|辂 輅|较 較|辄 輒|辅 輔|辆 輛|辇 輦|辈 輩|辉 輝|辊 輥|辋 輞|辌 輬|辍 輟|辎 輜|辏 輳|辐 輻|辑 輯|辒 轀|输 輸|辔 轡|辕 轅|辖 轄|辗 輾|辘 轆|辙 轍|辚 轔|辞 辭|辟 闢|辩 辯|辫 辮|边 邊|辽 遼|达 達|迁 遷|过 過|迈 邁|运 運|还 還|这 這|进 進|远 遠|违 違|连 連|迟 遲|迩 邇|迳 逕|迹 跡|适 適|选 選|逊 遜|递 遞|逦 邐|逻 邏|遗 遺|遥 遙|邓 鄧|邝 鄺|邬 鄔|邮 郵|邹 鄒|邺 鄴|邻 鄰|郁 鬱|郏 郟|郐 鄶|郑 鄭|郓 鄆|郦 酈|郧 鄖|郸 鄲|酂 酇|酝 醞|酦 醱|酱 醬|酽 釅|酾 釃|酿 釀|醖 醞|采 採|释 釋|里 裏|鉴 鑑|銮 鑾|錾 鏨|钅 釒|钆 釓|钇 釔|针 針|钉 釘|钊 釗|钋 釙|钌 釕|钍 釷|钎 釺|钏 釧|钐 釤|钑 鈒|钒 釩|钓 釣|钔 鍆|钕 釹|钖 鍚|钗 釵|钘 鈃|钙 鈣|钚 鈈|钛 鈦|钜 鉅|钝 鈍|钞 鈔|钟 鍾|钠 鈉|钡 鋇|钢 鋼|钣 鈑|钤 鈐|钥 鑰|钦 欽|钧 鈞|钨 鎢|钩 鉤|钪 鈧|钫 鈁|钬 鈥|钭 鈄|钮 鈕|钯 鈀|钰 鈺|钱 錢|钲 鉦|钳 鉗|钴 鈷|钵 鉢|钶 鈳|钷 鉕|钸 鈽|钹 鈸|钺 鉞|钻 鑽|钼 鉬|钽 鉭|钾 鉀|钿 鈿|铀 鈾|铁 鐵|铂 鉑|铃 鈴|铄 鑠|铅 鉛|铆 鉚|铇 鉋|铈 鈰|铉 鉉|铊 鉈|铋 鉍|铌 鈮|铍 鈹|铎 鐸|铏 鉶|铐 銬|铑 銠|铒 鉺|铓 鋩|铔 錏|铕 銪|铖 鋮|铗 鋏|铘 鋣|铙 鐃|铚 銍|铛 鐺|铜 銅|铝 鋁|铞 銱|铟 銦|铠 鎧|铡 鍘|铢 銖|铣 銑|铤 鋌|铥 銩|铦 銛|铧 鏵|铨 銓|铩 鎩|铪 鉿|铫 銚|铬 鉻|铭 銘|铮 錚|铯 銫|铰 鉸|铱 銥|铲 鏟|铳 銃|铴 鐋|铵 銨|银 銀|铷 銣|铸 鑄|铹 鐒|铺 鋪|铻 鋙|铼 錸|铽 鋱|链 鏈|铿 鏗|销 銷|锁 鎖|锂 鋰|锃 鋥|锄 鋤|锅 鍋|锆 鋯|锇 鋨|锈 鏽|锉 銼|锊 鋝|锋 鋒|锌 鋅|锍 鋶|锎 鐦|锏 鐧|锐 銳|锑 銻|锒 鋃|锓 鋟|锔 鋦|锕 錒|锖 錆|锗 鍺|锘 鍩|错 錯|锚 錨|锛 錛|锜 錡|锝 鍀|锞 錁|锟 錕|锠 錩|锡 錫|锢 錮|锣 鑼|锤 錘|锥 錐|锦 錦|锧 鑕|锨 鍁|锩 錈|锪 鍃|锫 錇|锬 錟|锭 錠|键 鍵|锯 鋸|锰 錳|锱 錙|锲 鍥|锳 鍈|锴 鍇|锵 鏘|锶 鍶|锷 鍔|锸 鍤|锹 鍬|锺 鍾|锻 鍛|锼 鎪|锽 鍠|锾 鍰|锿 鎄|镀 鍍|镁 鎂|镂 鏤|镃 鎡|镄 鐨|镅 鎇|镆 鏌|镇 鎮|镈 鎛|镉 鎘|镊 鑷|镋 钂|镌 鐫|镍 鎳|镎 鎿|镏 鎦|镐 鎬|镑 鎊|镒 鎰|镓 鎵|镔 鑌|镕 鎔|镖 鏢|镗 鏜|镘 鏝|镙 鏍|镚 鏰|镛 鏞|镜 鏡|镝 鏑|镞 鏃|镟 鏇|镠 鏐|镡 鐔|镢 钁|镣 鐐|镤 鏷|镥 鑥|镦 鐓|镧 鑭|镨 鐠|镩 鑹|镪 鏹|镫 鐙|镬 鑊|镭 鐳|镮 鐶|镯 鐲|镰 鐮|镱 鐿|镲 鑔|镳 鑣|镴 鑞|镵 鑱|镶 鑲|长 長|门 門|闩 閂|闪 閃|闫 閆|闬 閈|闭 閉|问 問|闯 闖|闰 閏|闱 闈|闲 閒|闳 閎|间 間|闵 閔|闶 閌|闷 悶|闸 閘|闹 鬧|闺 閨|闻 聞|闼 闥|闽 閩|闾 閭|闿 闓|阀 閥|阁 閣|阂 閡|阃 閫|阄 鬮|阅 閱|阆 閬|阇 闍|阈 閾|阉 閹|阊 閶|阋 鬩|阌 閿|阍 閽|阎 閻|阏 閼|阐 闡|阑 闌|阒 闃|阓 闠|阔 闊|阕 闋|阖 闔|阗 闐|阘 闒|阙 闕|阚 闞|阛 闤|队 隊|阳 陽|阴 陰|阵 陣|阶 階|际 際|陆 陸|陇 隴|陈 陳|陉 陘|陕 陝|陦 隯|陧 隉|陨 隕|险 險|随 隨|隐 隱|隶 隸|隽 雋|难 難|雇 僱|雏 雛|雠 讎|雳 靂|雾 霧|霁 霽|霉 黴|霡 霢|霭 靄|靓 靚|靔 靝|静 靜|靥 靨|鞑 韃|鞒 鞽|鞯 韉|鞲 韝|韦 韋|韧 韌|韨 韍|韩 韓|韪 韙|韫 韞|韬 韜|韵 韻|页 頁|顶 頂|顷 頃|顸 頇|项 項|顺 順|须 須|顼 頊|顽 頑|顾 顧|顿 頓|颀 頎|颁 頒|颂 頌|颃 頏|预 預|颅 顱|领 領|颇 頗|颈 頸|颉 頡|颊 頰|颋 頲|颌 頜|颍 潁|颎 熲|颏 頦|颐 頤|频 頻|颒 頮|颓 頹|颔 頷|颕 頴|颖 穎|颗 顆|题 題|颙 顒|颚 顎|颛 顓|颜 顏|额 額|颞 顳|颟 顢|颠 顛|颡 顙|颢 顥|颣 纇|颤 顫|颥 顬|颦 顰|颧 顴|风 風|飏 颺|飐 颭|飑 颮|飒 颯|飓 颶|飔 颸|飕 颼|飖 颻|飗 飀|飘 飄|飙 飆|飚 飈|飞 飛|飨 饗|餍 饜|饣 飠|饤 飣|饥 飢|饦 飥|饧 餳|饨 飩|饩 餼|饪 飪|饫 飫|饬 飭|饭 飯|饮 飲|饯 餞|饰 飾|饱 飽|饲 飼|饳 飿|饴 飴|饵 餌|饶 饒|饷 餉|饸 餄|饹 餎|饺 餃|饻 餏|饼 餅|饽 餑|饾 餖|饿 餓|馀 餘|馁 餒|馂 餕|馃 餜|馄 餛|馅 餡|馆 館|馇 餷|馈 饋|馉 餶|馊 餿|馋 饞|馌 饁|馍 饃|馎 餺|馏 餾|馐 饈|馑 饉|馒 饅|馓 饊|馔 饌|馕 饢|马 馬|驭 馭|驮 馱|驯 馴|驰 馳|驱 驅|驲 馹|驳 駁|驴 驢|驵 駔|驶 駛|驷 駟|驸 駙|驹 駒|驺 騶|驻 駐|驼 駝|驽 駑|驾 駕|驿 驛|骀 駘|骁 驍|骂 罵|骃 駰|骄 驕|骅 驊|骆 駱|骇 駭|骈 駢|骉 驫|骊 驪|骋 騁|验 驗|骍 騂|骎 駸|骏 駿|骐 騏|骑 騎|骒 騍|骓 騅|骔 騌|骕 驌|骖 驂|骗 騙|骘 騭|骙 騤|骚 騷|骛 騖|骜 驁|骝 騮|骞 騫|骟 騸|骠 驃|骡 騾|骢 驄|骣 驏|骤 驟|骥 驥|骦 驦|骧 驤|髅 髏|髋 髖|髌 髕|鬓 鬢|鬶 鬹|魇 魘|魉 魎|鱼 魚|鱽 魛|鱾 魢|鱿 魷|鲀 魨|鲁 魯|鲂 魴|鲃 䰾|鲄 魺|鲅 鮁|鲆 鮃|鲇 鮎|鲈 鱸|鲉 鮋|鲊 鮓|鲋 鮒|鲌 鮊|鲍 鮑|鲎 鱟|鲏 鮍|鲐 鮐|鲑 鮭|鲒 鮚|鲓 鮳|鲔 鮪|鲕 鮞|鲖 鮦|鲗 鰂|鲘 鮜|鲙 鱠|鲚 鱭|鲛 鮫|鲜 鮮|鲝 鮺|鲞 鯗|鲟 鱘|鲠 鯁|鲡 鱺|鲢 鰱|鲣 鰹|鲤 鯉|鲥 鰣|鲦 鰷|鲧 鯀|鲨 鯊|鲩 鯇|鲪 鮶|鲫 鯽|鲬 鯒|鲭 鯖|鲮 鯪|鲯 鯕|鲰 鯫|鲱 鯡|鲲 鯤|鲳 鯧|鲴 鯝|鲵 鯢|鲶 鯰|鲷 鯛|鲸 鯨|鲹 鰺|鲺 鯴|鲻 鯔|鲼 鱝|鲽 鰈|鲾 鰏|鲿 鱨|鳀 鯷|鳁 鰮|鳂 鰃|鳃 鰓|鳄 鱷|鳅 鰍|鳆 鰒|鳇 鰉|鳈 鰁|鳉 鱂|鳊 鯿|鳋 鰠|鳌 鰲|鳍 鰭|鳎 鰨|鳏 鰥|鳐 鰩|鳑 鰟|鳒 鰜|鳓 鰳|鳔 鰾|鳕 鱈|鳖 鱉|鳗 鰻|鳘 鰵|鳙 鱅|鳚 䲁|鳛 鰼|鳜 鱖|鳝 鱔|鳞 鱗|鳟 鱒|鳠 鱯|鳡 鱤|鳢 鱧|鳣 鱣|鳤 䲘|鸟 鳥|鸠 鳩|鸡 雞|鸢 鳶|鸣 鳴|鸤 鳲|鸥 鷗|鸦 鴉|鸧 鶬|鸨 鴇|鸩 鴆|鸪 鴣|鸫 鶇|鸬 鸕|鸭 鴨|鸮 鴞|鸯 鴦|鸰 鴒|鸱 鴟|鸲 鴝|鸳 鴛|鸴 鷽|鸵 鴕|鸶 鷥|鸷 鷙|鸸 鴯|鸹 鴰|鸺 鵂|鸻 鴴|鸼 鵃|鸽 鴿|鸾 鸞|鸿 鴻|鹀 鵐|鹁 鵓|鹂 鸝|鹃 鵑|鹄 鵠|鹅 鵝|鹆 鵒|鹇 鷳|鹈 鵜|鹉 鵡|鹊 鵲|鹋 鶓|鹌 鵪|鹍 鵾|鹎 鵯|鹏 鵬|鹐 鵮|鹑 鶉|鹒 鶊|鹓 鵷|鹔 鷫|鹕 鶘|鹖 鶡|鹗 鶚|鹘 鶻|鹙 鶖|鹚 鷀|鹛 鶥|鹜 鶩|鹝 鷊|鹞 鷂|鹟 鶲|鹠 鶹|鹡 鶺|鹢 鷁|鹣 鶼|鹤 鶴|鹥 鷖|鹦 鸚|鹧 鷓|鹨 鷚|鹩 鷯|鹪 鷦|鹫 鷲|鹬 鷸|鹭 鷺|鹮 䴉|鹯 鸇|鹰 鷹|鹱 鸌|鹲 鸏|鹳 鸛|鹴 鸘|鹾 鹺|麦 麥|麸 麩|麹 麴|麺 麪|麽 麼|黄 黃|黉 黌|黡 黶|黩 黷|黪 黲|黾 黽|鼋 黿|鼌 鼂|鼍 鼉|鼹 鼴|齐 齊|齑 齏|齿 齒|龀 齔|龁 齕|龂 齗|龃 齟|龄 齡|龅 齙|龆 齠|龇 齜|龈 齦|龉 齬|龊 齪|龋 齲|龌 齷|龙 龍|龚 龔|龛 龕|龟 龜|鿎 䃮|鿏 䥑|鿒 鿓|鿔 鎶|𠀾 𠁞|𠆲 儣|𠆿 𠌥|𠇹 俓|𠉂 㒓|𠉗 𠏢|𠋆 儭|𠚳 𠠎|𠛅 剾|𠛆 𠞆|𠛾 𪟖|𠡠 勑|𠮶 嗰|𠯟 哯|𠯠 噅|𠰱 㘉|𠰷 嚧|𠱞 囃|𠲥 𡅏|𠴛 𡃕|𠴢 𡄔|𠵸 𡄣|𠵾 㗲|𡋀 𡓾|𡋗 𡑭|𡋤 壗|𡍣 𡔖|𡒄 壈|𡝠 㜷|𡞋 㜗|𡞱 㜢|𡠟 孎|𡥧 孻|𡭜 𡮉|𡭬 𡮣|𡳃 𡳳|𡳒 𦘧|𡶴 嵼|𡸃 𡽗|𡺃 嶈|𡺄 嶘|𢋈 㢝|𢗓 㦛|𢘙 𢤱|𢘝 𢣚|𢘞 𢣭|𢙏 愻|𢙐 憹|𢙑 𢠼|𢙒 憢|𢙓 懀|𢛯 㦎|𢠁 懎|𢢐 𤢻|𢧐 戰|𢫊 𢷮|𢫞 𢶫|𢫬 摋|𢬍 擫|𢬦 𢹿|𢭏 擣|𢽾 斅|𣃁 斸|𣆐 曥|𣈣 𣋋|𣍨 𦢈|𣍯 腪|𣍰 脥|𣎑 臗|𣏢 槫|𣐕 桱|𣐤 欍|𣑶 𣠲|𣒌 楇|𣓿 橯|𣔌 樤|𣗊 樠|𣗋 欓|𣗙 㰙|𣘐 㯤|𣘓 𣞻|𣘴 檭|𣘷 𣝕|𣚚 欘|𣞎 𣠩|𣨼 殢|𣭤 𣯴|𣯣 𣯩|𣱝 氭|𣲗 湋|𣲘 潕|𣳆 㵗|𣶩 澅|𣶫 𣿉|𣶭 𪷓|𣷷 𤅶|𣸣 濆|𣺼 灙|𣺽 𤁣|𣽷 瀃|𤆡 熓|𤆢 㷍|𤇃 爄|𤇄 熌|𤇭 爖|𤇹 熚|𤈶 熉|𤈷 㷿|𤊀 𤒎|𤊰 𤓩|𤋏 熡|𤎺 𤓎|𤎻 𤑳|𤙯 𤛮|𤝢 𤢟|𤞃 獩|𤞤 玁|𤠋 㺏|𤦀 瓕|𤩽 瓛|𤳄 𤳸|𤶊 癐|𤶧 𤸫|𤻊 㿗|𤽯 㿧|𤾀 皟|𤿲 麬|𥁢 䀉|𥅘 𥌃|𥅴 䀹|𥅿 𥊝|𥆧 瞤|𥇢 䁪|𥎝 䂎|𥐟 礒|𥐯 𥖅|𥐰 𥕥|𥐻 碙|𥞦 𥞵|𥧂 𥨐|𥩟 竚|𥩺 𥪂|𥫣 籅|𥬀 䉙|𥬞 籋|𥬠 篘|𥭉 𥵊|𥮋 𥸠|𥮜 䉲|𥮾 篸|𥱔 𥵃|𥹥 𥼽|𥺅 䊭|𥺇 𥽖|𦈈 𥿊|𦈉 緷|𦈋 綇|𦈌 綀|𦈎 繟|𦈏 緍|𦈐 縺|𦈑 緸|𦈒 𦂅|𦈓 䋿|𦈔 縎|𦈕 緰|𦈖 䌈|𦈗 𦃄|𦈘 䌋|𦈙 䌰|𦈚 縬|𦈛 繓|𦈜 䌖|𦈝 繏|𦈞 䌟|𦈟 䌝|𦈠 䌥|𦈡 繻|𦍠 䍽|𦛨 朥|𦝼 膢|𦟗 𦣎|𦨩 𦪽|𦰏 蓧|𦰴 䕳|𦶟 爇|𦶻 𦾟|𦻕 蘟|𧉐 𧕟|𧉞 䗿|𧌥 𧎈|𧏖 蠙|𧏗 蠀|𧑏 蠾|𧒭 𧔥|𧜭 䙱|𧝝 襰|𧝧 𧟀|𧮪 詀|𧳕 𧳟|𧹑 䞈|𧹒 買|𧹓 𧶔|𧹔 賬|𧹕 䝻|𧹖 賟|𧹗 贃|𧿈 𨇁|𨀁 躘|𨀱 𨄣|𨁴 𨅍|𨂺 𨈊|𨄄 𨈌|𨅛 䠱|𨅫 𨇞|𨅬 躝|𨉗 軉|𨐅 軗|𨐆 𨊻|𨐇 𨏠|𨐈 輄|𨐉 𨎮|𨐊 𨏥|𨑹 䢨|𨟳 𨣞|𨠨 𨣧|𨡙 𨢿|𨡺 𨣈|𨤰 𨤻|𨰾 鎷|𨰿 釳|𨱀 𨥛|𨱁 鈠|𨱂 鈋|𨱃 鈲|𨱄 鈯|𨱅 鉁|𨱆 龯|𨱇 銶|𨱈 鋉|𨱉 鍄|𨱊 𨧱|𨱋 錂|𨱌 鏆|𨱍 鎯|𨱎 鍮|𨱏 鎝|𨱐 𨫒|𨱑 鐄|𨱒 鏉|𨱓 鐎|𨱔 鐏|𨱕 𨮂|𨱖 䥩|𨷿 䦳|𨸀 𨳕|𨸁 𨳑|𨸂 閍|𨸃 閐|𨸄 䦘|𨸅 𨴗|𨸆 𨵩|𨸇 𨵸|𨸉 𨶀|𨸊 𨶏|𨸋 𨶲|𨸌 𨶮|𨸎 𨷲|𨸘 𨽏|𨸟 䧢|𩏼 䪏|𩏽 𩏪|𩏾 𩎢|𩏿 䪘|𩐀 䪗|𩓋 顂|𩖕 𩓣|𩖖 顃|𩖗 䫴|𩙥 颰|𩙦 𩗀|𩙧 䬞|𩙨 𩘹|𩙩 𩘀|𩙪 颷|𩙫 颾|𩙬 𩘺|𩙭 𩘝|𩙮 䬘|𩙯 䬝|𩙰 𩙈|𩟿 𩚛|𩠀 𩚥|𩠁 𩚵|𩠂 𩛆|𩠃 𩛩|𩠅 𩟐|𩠆 𩜦|𩠇 䭀|𩠈 䭃|𩠉 𩜇|𩠊 𩜵|𩠋 𩝔|𩠌 餸|𩠎 𩞄|𩠏 𩞦|𩠠 𩠴|𩡖 𩡣|𩧦 𩡺|𩧨 駎|𩧩 𩤊|𩧪 䮾|𩧫 駚|𩧬 𩢡|𩧭 䭿|𩧮 𩢾|𩧯 驋|𩧰 䮝|𩧱 𩥉|𩧲 駧|𩧳 𩢸|𩧴 駩|𩧵 𩢴|𩧶 𩣏|𩧸 𩣫|𩧺 駶|𩧻 𩣵|𩧼 𩣺|𩧿 䮠|𩨀 騔|𩨁 䮞|𩨂 驄|𩨃 騝|𩨄 騪|𩨅 𩤸|𩨆 𩤙|𩨇 䮫|𩨈 騟|𩨉 𩤲|𩨊 騚|𩨋 𩥄|𩨌 𩥑|𩨍 𩥇|𩨎 龭|𩨏 䮳|𩨐 𩧆|𩩈 䯤|𩬣 𩭙|𩬤 𩰀|𩭹 鬖|𩯒 𩯳|𩰰 𩰹|𩲒 𩳤|𩴌 𩴵|𩽹 魥|𩽺 𩵩|𩽻 𩵹|𩽼 鯶|𩽽 𩶱|𩽾 鮟|𩽿 𩶰|𩾁 鯄|𩾂 䲖|𩾃 鮸|𩾄 𩷰|𩾅 𩸃|𩾆 𩸦|𩾇 鯱|𩾈 䱙|𩾊 䱬|𩾋 䱰|𩾌 鱇|𩾎 𩽇|𪉂 䲰|𪉃 鳼|𪉄 𩿪|𪉅 𪀦|𪉆 鴲|𪉈 鴜|𪉉 𪁈|𪉊 鷨|𪉋 𪀾|𪉌 𪁖|𪉍 鵚|𪉎 𪂆|𪉏 𪃏|𪉐 𪃍|𪉑 鷔|𪉒 𪄕|𪉔 𪄆|𪉕 𪇳|𪎈 䴬|𪎉 麲|𪎊 麨|𪎋 䴴|𪎌 麳|𪑅 䵳|𪔭 𪔵|𪚏 𪘀|𪚐 𪘯|𪜎 𠿕|𪞝 凙|𪟎 㔋|𪟝 勣|𪠀 𧷎|𪠟 㓄|𪠡 𠬙|𪠳 唓|𪠵 㖮|𪠸 嚛|𪠺 𠽃|𪠽 噹|𪡀 嘺|𪡃 嘪|𪡋 噞|𪡏 嗹|𪡛 㗿|𪡞 嘳|𪡺 𡃄|𪢌 㘓|𪢐 𡃤|𪢒 𡂡|𪢕 嚽|𪢖 𡅯|𪢠 囒|𪢮 圞|𪢸 墲|𪣆 埬|𪣒 堚|𪣻 塿|𪤄 𡓁|𪤚 壣|𪥠 𧹈|𪥫 孇|𪥰 嬣|𪥿 嬻|𪧀 孾|𪧘 寠|𪨊 㞞|𪨗 屩|𪨧 崙|𪨩 𡸗|𪨶 輋|𪨷 巗|𪨹 𡹬|𪩇 㟺|𪩎 巊|𪩘 巘|𪩛 𡿖|𪩷 幝|𪩸 幩|𪪏 廬|𪪑 㢗|𪪞 廧|𪪴 𢍰|𪪼 彃|𪫌 徿|𪫡 𢤩|𪫷 㦞|𪫺 憸|𪬚 𢣐|𪬯 𢤿|𪭝 𢯷|𪭢 摐|𪭧 擟|𪭯 𢶒|𪭵 掚|𪭾 撊|𪮃 㨻|𪮋 㩋|𪮖 撧|𪮳 𢺳|𪮶 攋|𪯋 㪎|𪰶 曊|𪱥 膹|𪱷 梖|𪲎 櫅|𪲔 欐|𪲛 檵|𪲮 櫠|𪳍 欇|𪳗 𣜬|𪴙 欑|𪵑 毊|𪵣 霼|𪵱 濿|𪶄 溡|𪶒 𤄷|𪶮 𣽏|𪷍 㵾|𪷽 灒|𪸕 熂|𪸩 煇|𪹀 𤑹|𪹠 𤓌|𪹳 爥|𪹹 𤒻|𪺣 𤘀|𪺪 𤜆|𪺭 犞|𪺷 獊|𪺸 𤠮|𪺻 㺜|𪺽 猌|𪻐 瑽|𪻨 瓄|𪻲 瑻|𪻺 璝|𪼋 㻶|𪼴 𤬅|𪽈 畼|𪽝 𤳷|𪽪 痮|𪽭 𤷃|𪽮 㿖|𪽴 𤺔|𪽷 瘱|𪾔 盨|𪾢 睍|𪾣 眝|𪾦 矑|𪾸 矉|𪿊 𥏝|𪿞 𥖲|𪿫 礮|𪿵 𥗇|𫀌 𥜰|𫀓 𥜐|𫀨 䅐|𫀬 䅳|𫀮 𥢷|𫁂 䆉|𫁟 竱|𫁡 鴗|𫁱 𥶽|𫁲 䉑|𫁳 𥯤|𫁷 䉶|𫁺 𥴼|𫂃 簢|𫂆 簂|𫂈 䉬|𫂖 𥴨|𫂿 𥻦|𫃗 𩏷|𫄙 糺|𫄚 䊺|𫄛 紟|𫄜 䋃|𫄝 𥾯|𫄞 䋔|𫄟 絁|𫄠 絙|𫄡 絧|𫄢 絥|𫄣 繷|𫄤 繨|𫄥 纚|𫄦 𦀖|𫄧 綖|𫄨 絺|𫄩 䋦|𫄪 𦅇|𫄫 綟|𫄬 緤|𫄭 緮|𫄮 䋼|𫄯 𦃩|𫄰 縍|𫄱 繬|𫄲 縸|𫄳 縰|𫄴 繂|𫄵 𦅈|𫄶 繈|𫄷 繶|𫄸 纁|𫄹 纗|𫅅 䍤|𫅗 羵|𫅥 𦒀|𫅭 䎙|𫅼 𦔖|𫆏 聻|𫆝 𦟼|𫆫 𦡝|𫇘 𦧺|𫇛 艣|𫇪 𦱌|𫇭 蔿|𫇴 蒭|𫇽 蕽|𫈉 蕳|𫈎 葝|𫈟 蔯|𫈵 蕝|𫉁 薆|𫉄 藷|𫊪 䗅|𫊮 蠦|𫊸 蟜|𫊹 𧒯|𫊻 蟳|𫋇 蟂|𫋌 蟘|𫋲 䙔|𫋷 襗|𫋹 襓|𫋻 襘|𫌀 襀|𫌇 襵|𫌋 𧞫|𫌨 覼|𫌪 覛|𫌫 𧡴|𫌬 𧢄|𫌭 覹|𫌯 䚩|𫍐 𧭹|𫍙 訑|𫍚 訞|𫍛 訜|𫍜 詓|𫍝 諫|𫍞 𧦝|𫍟 𧦧|𫍠 䛄|𫍡 詑|𫍢 譊|𫍣 詷|𫍤 譑|𫍥 誂|𫍦 譨|𫍧 誺|𫍨 誫|𫍩 諣|𫍪 誋|𫍫 䛳|𫍬 誷|𫍭 𧩕|𫍮 誳|𫍯 諴|𫍰 諰|𫍱 諯|𫍲 謏|𫍳 諥|𫍴 謱|𫍵 謸|𫍶 𧩼|𫍷 謉|𫍸 謆|𫍹 謯|𫍺 𧫝|𫍻 譆|𫍼 𧬤|𫍽 譞|𫍾 𧭈|𫍿 譾|𫎆 豵|𫎌 貗|𫎦 贚|𫎧 䝭|𫎨 𧸘|𫎩 賝|𫎪 䞋|𫎫 贉|𫎬 贑|𫎭 䞓|𫎱 䟐|𫎳 䟆|𫎸 𧽯|𫎺 䟃|𫏃 䠆|𫏆 蹳|𫏋 蹻|𫏌 𨂐|𫏐 蹔|𫏑 𨇽|𫏕 𨆪|𫏞 𨇰|𫏨 𨇤|𫐄 軏|𫐅 軕|𫐆 轣|𫐇 軜|𫐈 軷|𫐉 軨|𫐊 軬|𫐋 𨎌|𫐌 軿|𫐍 𨌈|𫐎 輢|𫐏 輖|𫐐 輗|𫐑 輨|𫐒 輷|𫐓 輮|𫐔 𨍰|𫐕 轊|𫐖 轇|𫐗 轐|𫐘 轗|𫐙 轠|𫐷 遱|𫑘 鄟|𫑡 鄳|𫑷 醶|𫓥 釟|𫓦 釨|𫓧 鈇|𫓨 鈛|𫓩 鏦|𫓪 鈆|𫓫 𨥟|𫓬 鉔|𫓭 鉠|𫓮 𨪕|𫓯 銈|𫓰 銊|𫓱 鐈|𫓲 銁|𫓳 𨰋|𫓴 鉾|𫓵 鋠|𫓶 鋗|𫓷 𫒡|𫓸 錽|𫓹 錤|𫓺 鐪|𫓻 錜|𫓼 𨨛|𫓽 錝|𫓾 錥|𫓿 𨨢|𫔀 鍊|𫔁 鐼|𫔂 鍉|𫔃 𨰲|𫔄 鍒|𫔅 鎍|𫔆 䥯|𫔇 鎞|𫔈 鎙|𫔉 𨰃|𫔊 鏥|𫔋 䥗|𫔌 鏾|𫔍 鐇|𫔎 鐍|𫔏 𨬖|𫔐 𨭸|𫔑 𨭖|𫔒 𨮳|𫔓 𨯟|𫔔 鑴|𫔕 𨰥|𫔖 𨲳|𫔭 開|𫔮 閒|𫔯 閗|𫔰 閞|𫔲 𨴹|𫔴 閵|𫔵 䦯|𫔶 闑|𫔽 𨼳|𫕚 𩀨|𫕥 霣|𫕨 𩅙|𫖃 靧|𫖅 䪊|𫖇 鞾|𫖑 𩎖|𫖒 韠|𫖓 𩏂|𫖔 韛|𫖕 韝|𫖖 𩏠|𫖪 𩑔|𫖫 䪴|𫖬 䪾|𫖭 𩒎|𫖮 顗|𫖯 頫|𫖰 䫂|𫖱 䫀|𫖲 䫟|𫖳 頵|𫖴 𩔳|𫖵 𩓥|𫖶 顅|𫖷 𩔑|𫖸 願|𫖹 顣|𫖺 䫶|𫗇 䫻|𫗈 𩗓|𫗉 𩗴|𫗊 䬓|𫗋 飋|𫗚 𩟗|𫗞 飦|𫗟 䬧|𫗠 餦|𫗡 𩚩|𫗢 飵|𫗣 飶|𫗤 𩛌|𫗥 餫|𫗦 餔|𫗧 餗|𫗨 𩛡|𫗩 饠|𫗪 餧|𫗫 餬|𫗬 餪|𫗭 餵|𫗮 餭|𫗯 餱|𫗰 䭔|𫗱 䭑|𫗳 𩝽|𫗴 饘|𫗵 饟|𫘛 馯|𫘜 馼|𫘝 駃|𫘞 駞|𫘟 駊|𫘠 駤|𫘡 駫|𫘣 駻|𫘤 騃|𫘥 騉|𫘦 騊|𫘧 騄|𫘨 騠|𫘩 騜|𫘪 騵|𫘫 騴|𫘬 騱|𫘭 騻|𫘮 䮰|𫘯 驓|𫘰 驙|𫘱 驨|𫘽 鬠|𫙂 𩯁|𫚈 鱮|𫚉 魟|𫚊 鰑|𫚋 鱄|𫚌 魦|𫚍 魵|𫚎 𩶁|𫚏 䱁|𫚐 䱀|𫚑 鮅|𫚒 鮄|𫚓 鮤|𫚔 鮰|𫚕 鰤|𫚖 鮆|𫚗 鮯|𫚘 𩻮|𫚙 鯆|𫚚 鮿|𫚛 鮵|𫚜 䲅|𫚝 𩸄|𫚞 鯬|𫚟 𩸡|𫚠 䱧|𫚡 鯞|𫚢 鰋|𫚣 鯾|𫚤 鰦|𫚥 鰕|𫚦 鰫|𫚧 鰽|𫚨 𩻗|𫚩 𩻬|𫚪 鱊|𫚫 鱢|𫚬 𩼶|𫚭 鱲|𫛚 鳽|𫛛 鳷|𫛜 鴀|𫛝 鴅|𫛞 鴃|𫛟 鸗|𫛠 𩿤|𫛡 鴔|𫛢 鸋|𫛣 鴥|𫛤 鴐|𫛥 鵊|𫛦 鴮|𫛧 𪀖|𫛨 鵧|𫛩 鴳|𫛪 鴽|𫛫 鶰|𫛬 䳜|𫛭 鵟|𫛮 䳤|𫛯 鶭|𫛰 䳢|𫛱 鵫|𫛲 鵰|𫛳 鵩|𫛴 鷤|𫛵 鶌|𫛶 鶒|𫛷 鶦|𫛸 鶗|𫛹 𪃧|𫛺 䳧|𫛻 𪃒|𫛼 䳫|𫛽 鷅|𫛾 𪆷|𫜀 鷐|𫜁 鷩|𫜂 𪅂|𫜃 鷣|𫜄 鷷|𫜅 䴋|𫜊 𪉸|𫜑 麷|𫜒 䴱|𫜓 𪌭|𫜔 䴽|𫜕 𪍠|𫜙 䵴|𫜟 𪓰|𫜨 䶕|𫜩 齧|𫜪 齩|𫜫 𫜦|𫜬 齰|𫜭 齭|𫜮 齴|𫜯 𪙏|𫜰 齾|𫜲 龓|𫜳 䶲|𫝈 㑮|𫝋 𠐊|𫝦 㛝|𫝧 㜐|𫝨 媈|𫝩 嬦|𫝪 𡟫|𫝫 婡|𫝬 嬇|𫝭 孆|𫝮 孄|𫝵 嶹|𫞅 𦠅|𫞗 潣|𫞚 澬|𫞛 㶆|𫞝 灍|𫞠 爧|𫞡 爃|𫞢 𤛱|𫞣 㹽|𫞥 珼|𫞦 璾|𫞧 𤩂|𫞨 璼|𫞩 璊|𫞷 𥢶|𫟃 絍|𫟄 綋|𫟅 綡|𫟆 緟|𫟇 𦆲|𫟑 䖅|𫟕 䕤|𫟞 訨|𫟟 詊|𫟠 譂|𫟡 誴|𫟢 䜖|𫟤 䡐|𫟥 䡩|𫟦 䡵|𫟫 𨞺|𫟬 𨟊|𫟲 釚|𫟳 釲|𫟴 鈖|𫟵 鈗|𫟶 銏|𫟷 鉝|𫟸 鉽|𫟹 鉷|𫟺 䤤|𫟻 銂|𫟼 鐽|𫟽 𨧰|𫟾 𨩰|𫟿 鎈|𫠀 䥄|𫠁 鑉|𫠂 閝|𫠅 韚|𫠆 頍|𫠇 𩖰|𫠈 䫾|𫠊 䮄|𫠋 騼|𫠌 𩦠|𫠏 𩵦|𫠐 魽|𫠑 䱸|𫠒 鱆|𫠖 𩿅|𫠜 齯|𫢸 僤|𫧃 𣍐|𫧮 𪋿|𫫇 噁|𫬐 㘔|𫭟 塸|𫭢 埨|𫭼 𡑍|𫮃 墠|𫰛 娙|𫵷 㠣|𫶇 嵽|𫷷 廞|𫸩 彄|𬀩 暐|𬀪 晛|𬂩 梜|𬃊 櫍|𬇕 澫|𬇙 浿|𬇹 漍|𬉼 熰|𬊈 燖|𬊤 燀|𬍛 瓅|𬍡 璗|𬍤 璕|𬒈 礐|𬒗 𥗽|𬕂 篢|𬘓 紃|𬘘 紞|𬘡 絪|𬘩 綎|𬘫 綄|𬘬 綪|𬘭 綝|𬘯 綧|𬙂 縯|𬙊 纆|𬙋 纕|𬜬 蔄|𬜯 䓣|𬞟 蘋|𬟁 虉|𬟽 蝀|𬣙 訏|𬣞 詝|𬣡 諓|𬣳 詪|𬤇 諲|𬤊 諟|𬤝 譓|𬨂 軝|𬨎 輶|𬩽 鄩|𬪩 醲|𬬩 釴|𬬭 錀|𬬮 鋹|𬬱 釿|𬬸 鉥|𬬹 鉮|𬬻 鑪|𬬿 鉊|𬭁 鉧|𬭊 𨧀|𬭎 鋐|𬭚 錞|𬭛 𨨏|𬭤 鍭|𬭩 鎓|𬭬 鏏|𬭭 鏚|𬭯 䥕|𬭳 𨭎|𬭶 𨭆|𬭸 鏻|𬭼 鐩|𬮱 闉|𬮿 隑|𬯀 隮|𬯎 隤|𬱖 頔|𬱟 頠|𬳵 駓|𬳶 駉|𬳽 駪|𬳿 駼|𬴂 騑|𬴃 騞|𬴊 驎|𬶋 鮈|𬶍 鮀|𬶏 鮠|𬶐 鮡|𬶟 鯻|𬶠 鰊|𬶨 鱀|𬶭 鰶|𬶮 鱚|𬷕 鵏|𬸘 鶠|𬸚 鸑|𬸣 鶱|𬸦 鷟|𬸪 鷭|𬸯 鷿|𬹼 齘|𬺈 齮|𬺓 齼|𰬸 繐|𰰨 菕|𰶎 譅|𰻝 𰻞|𰾄 鋂|𰾭 鑀|𱊜 𪈼` + +var ( + zhConvertOnce sync.Once + zhT2S map[rune]rune + zhS2T map[rune]rune +) + +func zhParseDict(data string) map[rune]rune { + m := make(map[rune]rune, 4096) + for _, item := range splitZhDict(data) { + key, val := item[0], item[1] + m[key] = val + } + return m +} + +// splitZhDict 把 "繁 简|繁 简" 拆成 [2]rune 列表(避免为每个条目分配字符串)。 +func splitZhDict(data string) [][2]rune { + var out [][2]rune + var pair [2]rune + field := 0 + for _, r := range data { + switch r { + case '|': + if field == 2 { + out = append(out, pair) + } + field = 0 + case ' ': + field = 1 + default: + if field == 2 { + field = 1 + } + pair[field] = r + field++ + } + } + if field == 2 { + out = append(out, pair) + } + return out +} + +func zhInit() { + zhConvertOnce.Do(func() { + zhT2S = zhParseDict(zhT2SData) + zhS2T = zhParseDict(zhS2TData) + }) +} + +// zhToSimplified 繁体转简体(java.t2s)。 +func zhToSimplified(s string) string { return zhConvert(s, false) } + +// zhToTraditional 简体转繁体(java.s2t)。 +func zhToTraditional(s string) string { return zhConvert(s, true) } + +func zhConvert(s string, toTraditional bool) string { + if s == "" { + return s + } + zhInit() + dict := zhT2S + if toTraditional { + dict = zhS2T + } + // 先扫一遍判断有没有需要转换的字符,纯简体/无需转换的文本原样返回。 + hit := false + for _, r := range s { + if _, ok := dict[r]; ok { + hit = true + break + } + } + if !hit { + return s + } + out := make([]rune, 0, len(s)) + for _, r := range s { + if v, ok := dict[r]; ok { + out = append(out, v) + continue + } + out = append(out, r) + } + return string(out) +} diff --git a/internal/service/reader/rule/zh_convert_test.go b/internal/service/reader/rule/zh_convert_test.go new file mode 100644 index 0000000..e689782 --- /dev/null +++ b/internal/service/reader/rule/zh_convert_test.go @@ -0,0 +1,66 @@ +package rule + +import ( + "strings" + "testing" +) + +// TestZhToSimplified 常用繁体字要真的转成简体。 +// 这是书源里 java.t2s(key) / java.t2s(result) 的语义:阅读 App 会转换, +// 不实现时繁体站点既搜不到(关键字没转)也显示不对(标题保持繁体)。 +func TestZhToSimplified(t *testing.T) { + cases := map[string]string{ + "島忍者神龜": "岛忍者神龟", + "航海王": "航海王", + "我們的書": "我们的书", + "學習園地": "学习园地", + "": "", + "abc123": "abc123", // 非中文原样返回 + } + for in, want := range cases { + if got := zhToSimplified(in); got != want { + t.Errorf("zhToSimplified(%q) = %q,期望 %q", in, got, want) + } + } +} + +// TestZhToTraditional 反向转换。 +func TestZhToTraditional(t *testing.T) { + cases := map[string]string{ + "岛忍者神龟": "島忍者神龜", + "我们的书": "我們的書", + "学习园地": "學習園地", + "": "", + } + for in, want := range cases { + if got := zhToTraditional(in); got != want { + t.Errorf("zhToTraditional(%q) = %q,期望 %q", in, got, want) + } + } +} + +// TestZhConvertLeavesPlainTextAlone 不需要转换的文本必须原样返回(含表情/符号)。 +func TestZhConvertLeavesPlainTextAlone(t *testing.T) { + const s = "第1话 ROMANCE DAWN 冒险的序幕 🏴‍☠️" + if got := zhToSimplified(s); got != s { + t.Fatalf("zhToSimplified 改动了无需转换的文本: %q", got) + } +} + +// TestZhDictParsed 词典要真的解析出来了(防止生成文件被截断)。 +func TestZhDictParsed(t *testing.T) { + zhInit() + if len(zhT2S) < 3000 { + t.Fatalf("t2s 词典条目 %d,明显偏少", len(zhT2S)) + } + if len(zhS2T) < 3000 { + t.Fatalf("s2t 词典条目 %d,明显偏少", len(zhS2T)) + } + // 空格/竖线不属于任何键 + if _, ok := zhT2S[' ']; ok { + t.Fatal("词典里混进了分隔符") + } + if strings.ContainsRune(zhT2SData, '\uFFFD') { + t.Fatal("词典数据含替换符,生成过程有损") + } +} diff --git a/internal/service/reader/rule/zh_number.go b/internal/service/reader/rule/zh_number.go new file mode 100644 index 0000000..100819b --- /dev/null +++ b/internal/service/reader/rule/zh_number.go @@ -0,0 +1,93 @@ +package rule + +import ( + "regexp" + "strconv" +) + +// 中文数字 → 阿拉伯数字,用于 java.toNumChapter。 +// +// 对应 legado 的 `AppPattern.titleNumPattern` + `StringUtils.stringToInt`:把 +// 「第一百零八章」规整成「第108章」。书源用它统一章节标题(排序/去重)。 +// 解析不出来时原样返回,不做猜测。 +var chapterTokenRe = regexp.MustCompile(`[0-9]+|[〇零一二三四五六七八九十百千万两兩]+`) + +var ( + zhDigits = map[rune]int{'〇': 0, '零': 0, '一': 1, '二': 2, '两': 2, '兩': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9} + zhUnits = map[rune]int{'十': 10, '百': 100, '千': 1000} + zhBigUnits = map[rune]int{'万': 10000, '億': 100000000, '亿': 100000000} +) + +// numChapter 把标题里第一处数字规整成阿拉伯数字。 +func numChapter(s string) string { + loc := chapterTokenRe.FindStringIndex(s) + if loc == nil { + return s + } + n, ok := parseCNSNum(s[loc[0]:loc[1]]) + if !ok { + return s + } + return s[:loc[0]] + strconv.Itoa(n) + s[loc[1]:] +} + +// parseCNSNum 解析纯阿拉伯数字或中文数字;无法解析返回 false。 +func parseCNSNum(tok string) (int, bool) { + if tok == "" { + return 0, false + } + if n, err := strconv.Atoi(tok); err == nil { + return n, true + } + // 全是数字字符(如「一〇二」这种按位念法)→ 逐位拼起来 + if digitsOnly(tok) { + n := 0 + for _, r := range tok { + n = n*10 + zhDigits[r] + } + return n, true + } + total, section, num := 0, 0, 0 + seen := false + for _, r := range tok { + if d, ok := zhDigits[r]; ok { + num = d + seen = true + continue + } + if u, ok := zhUnits[r]; ok { + if num == 0 { + num = 1 // 「十二」这类省略了前导一的写法 + } + section += num * u + num = 0 + seen = true + continue + } + if big, ok := zhBigUnits[r]; ok { + section += num + if section == 0 { + section = 1 + } + total += section * big + section, num = 0, 0 + seen = true + continue + } + return 0, false + } + if !seen { + return 0, false + } + return total + section + num, true +} + +// digitsOnly 判断字符串是否只由中文数字字符组成。 +func digitsOnly(s string) bool { + for _, r := range s { + if _, ok := zhDigits[r]; !ok { + return false + } + } + return true +} diff --git a/internal/service/reader/rule/zh_number_test.go b/internal/service/reader/rule/zh_number_test.go new file mode 100644 index 0000000..733049c --- /dev/null +++ b/internal/service/reader/rule/zh_number_test.go @@ -0,0 +1,53 @@ +package rule + +import ( + "strings" + "testing" +) + +// TestNumChapter 章节标题里的中文/阿拉伯数字要规整成阿拉伯数字。 +// 书源用 java.toNumChapter 统一标题;缺这个函数时整条规则会抛异常(源直接不可用)。 +func TestNumChapter(t *testing.T) { + cases := map[string]string{ + "第一百零八章": "第108章", + "第十二话": "第12话", + "第3话": "第3话", + "第一话 冒险的序幕": "第1话 冒险的序幕", + "一〇二": "102", + "两千零一": "2001", + "航海王": "航海王", // 没有数字:原样返回 + "": "", + } + for in, want := range cases { + if got := numChapter(in); got != want { + t.Errorf("numChapter(%q) = %q,期望 %q", in, got, want) + } + } +} + +// TestJavaExtraHelpers 书源 JS 里会直接调用的几个函数必须存在且可用。 +func TestJavaExtraHelpers(t *testing.T) { + r := NewJSRunner(JSConfig{}) + + v, err := r.Run(NewAnalyzeRule(), `java.randomUUID()`, nil, "") + if err != nil { + t.Fatalf("java.randomUUID() 失败: %v", err) + } + id := anyToString(v) + if len(id) != 36 || strings.Count(id, "-") != 4 { + t.Fatalf("randomUUID 返回值不像 UUID: %q", id) + } + + v, err = r.Run(NewAnalyzeRule(), `java.toNumChapter('第一百零八章')`, nil, "") + if err != nil { + t.Fatalf("java.toNumChapter() 失败: %v", err) + } + if got := anyToString(v); got != "第108章" { + t.Fatalf("toNumChapter = %q,期望 第108章", got) + } + + // 未声明设备能力时 java.deviceID 仍应抛异常(保持「不谎报运行环境」的语义) + if _, err := r.Run(NewAnalyzeRule(), `java.deviceID()`, nil, ""); err == nil { + t.Fatal("java.deviceID() 应当抛异常") + } +} diff --git a/internal/service/reader/source.go b/internal/service/reader/source.go index 7ad4de2..9f68c02 100644 --- a/internal/service/reader/source.go +++ b/internal/service/reader/source.go @@ -3,7 +3,9 @@ package reader import ( + "bytes" "encoding/json" + "strconv" "strings" ) @@ -51,6 +53,23 @@ func (b *BookSource) HasLogin() bool { return strings.TrimSpace(SPtr(b.LoginURL)) != "" || strings.TrimSpace(SPtr(b.LoginUI)) != "" } +// SearchCheckKeyWord 书源在搜索规则里声明的校验关键字(legado SearchRule.checkKeyWord)。 +// +// 源的作者用它证明「这个地址返回的确实是正常搜索结果」:校验时搜这个关键字, +// 响应里应该出现它;被风控/换域名后响应里就没有了。legado 的 getCheckKeyword +// 对含 http / :: / ++ / -- 的值不认(那是地址或扩展标记,不是关键字),这里保持一致。 +func (b *BookSource) SearchCheckKeyWord() string { + if b == nil || b.RuleSearch == nil { + return "" + } + kw := strings.TrimSpace(SPtr(b.RuleSearch.CheckKeyWord)) + if kw == "" || strings.Contains(kw, "http") || strings.Contains(kw, "::") || + strings.Contains(kw, "++") || strings.Contains(kw, "--") { + return "" + } + return kw +} + // LoginJS 返回 loginUrl 的纯 JS 体(剥掉 @js: / …< 包裹)。 // 对应 legado BaseSource.getLoginJs():loginUi 的按钮 action 会拼在其后执行, // 因此 loginUrl 同时充当登录交互的函数库。 @@ -60,17 +79,26 @@ func (b *BookSource) LoginJS() string { // stripJSWrapper 去掉 JS 规则的 @js: / … 包裹。 func stripJSWrapper(s string) string { - s = strings.TrimSpace(s) - if strings.HasPrefix(s, "@js:") { - return s[len("@js:"):] + out, _ := stripJSWrapperOK(s) + return out +} + +// stripJSWrapperOK 与 stripJSWrapper 相同,但额外告知是否命中 JS 包装。 +// 「可能是 JSON 也可能是 JS 规则」的字段(如书源 header)需要区分这两者。 +func stripJSWrapperOK(s string) (string, bool) { + trimmed := strings.TrimSpace(s) + lower := strings.ToLower(trimmed) + switch { + case strings.HasPrefix(lower, "@js:"): + return trimmed[len("@js:"):], true + case strings.HasPrefix(lower, ""): + body := trimmed[len(""):] + body = strings.TrimSuffix(strings.TrimSpace(body), "") + body = strings.TrimSuffix(strings.TrimSpace(body), "<") + return body, true + default: + return trimmed, false } - if strings.HasPrefix(s, "") { - s = s[len(""):] - s = strings.TrimSuffix(strings.TrimSpace(s), "") - s = strings.TrimSuffix(strings.TrimSpace(s), "<") - return s - } - return s } // SearchRule 搜索规则。 @@ -110,6 +138,9 @@ type TocRule struct { ChapterURL *string `json:"chapterUrl"` IsVolume *string `json:"isVolume"` UpdateTime *string `json:"updateTime"` + // NextTocURL 下一页目录规则(可为多值)。长书目录按 offset/limit 分页时靠它 + // 取全,缺了就只能拿到第一页(如 399 章只出现 100 章)。 + NextTocURL *string `json:"nextTocUrl"` } // ContentRule 正文规则。 @@ -125,25 +156,33 @@ type ContentRule struct { // ExploreRule 发现规则。 type ExploreRule struct { - BookList *string `json:"bookList"` - Name *string `json:"name"` - Author *string `json:"author"` - Kind *string `json:"kind"` - WordCount *string `json:"wordCount"` - LastChapter *string `json:"lastChapter"` - Intro *string `json:"intro"` - CoverURL *string `json:"coverUrl"` - BookURL *string `json:"bookUrl"` - ExploreURL *string `json:"exploreUrl"` - ExploreKinds *string `json:"exploreKinds"` - CheckKeyWord *string `json:"checkKeyWord"` + BookList *string `json:"bookList"` + Name *string `json:"name"` + Author *string `json:"author"` + Kind *string `json:"kind"` + WordCount *string `json:"wordCount"` + LastChapter *string `json:"lastChapter"` + Intro *string `json:"intro"` + CoverURL *string `json:"coverUrl"` + BookURL *string `json:"bookUrl"` + ExploreURL *string `json:"exploreUrl"` + ExploreKinds *string `json:"exploreKinds"` + CheckKeyWord *string `json:"checkKeyWord"` } // ParseBookSource 将书源 JSON 解析为结构体。 +// +// 先按标准 JSON 解一次;失败时用「宽容版」再解一次(见 normalizeSourceJSON)。 +// 阅读生态里的导出工具写法五花八门,同一份书源在别处能用、在这里却整体导入失败, +// 只因为某个字段的类型变了形状 —— 这类差异不值得让用户改 JSON。 func ParseBookSource(raw string) (*BookSource, error) { var bs BookSource if err := json.Unmarshal([]byte(raw), &bs); err != nil { - return nil, err + var relaxed BookSource + if err2 := json.Unmarshal([]byte(normalizeSourceJSON(raw)), &relaxed); err2 != nil { + return nil, err // 报原始错误:字段位置更贴近用户看到的 JSON + } + bs = relaxed } bs.RawJSON = raw if bs.RawVariables != nil && strings.TrimSpace(*bs.RawVariables) != "" { @@ -152,6 +191,71 @@ func ParseBookSource(raw string) (*BookSource, error) { return &bs, nil } +// sourceNumericFields 是书源里可能被写成字符串的数字字段。 +// 例:{"lastUpdateTime":"1788449879889"}(阅读本体导出/第三方源站的常见写法)。 +var sourceNumericFields = []string{ + "customOrder", "weight", "bookSourceType", "lastUpdateTime", "respondTime", +} + +// sourceRuleObjectFields 在 legado 里是对象,但部分导出工具把空规则写成 []。 +// 例:{"ruleExplore":[]}(空发现规则)。 +var sourceRuleObjectFields = []string{ + "ruleExplore", "ruleSearch", "ruleBookInfo", "ruleToc", "ruleContent", +} + +// normalizeSourceJSON 消化书源 JSON 与 legado 实体之间的形状差异: +// - 数字字段被写成字符串 → 转成数字; +// - 规则对象被写成空数组 [] → 转成空对象 {}。 +// +// 只动顶层已知字段,规则 JS 字符串原样保留。解析不出来就原样返回,交给调用方报错。 +func normalizeSourceJSON(raw string) string { + var fields map[string]json.RawMessage + if err := json.Unmarshal([]byte(raw), &fields); err != nil { + return raw + } + changed := false + for _, key := range sourceNumericFields { + value, ok := fields[key] + if !ok { + continue + } + var text string + if json.Unmarshal(value, &text) != nil { + continue // 本来就是数字(或其它类型):不掺和 + } + text = strings.TrimSpace(text) + if text == "" { + // 空字符串当成「没有这个字段」,否则会报类型错误 + delete(fields, key) + changed = true + continue + } + if _, err := strconv.ParseInt(text, 10, 64); err != nil { + continue // 不是整数(如 "1.0"):不猜,让标准解析去报错 + } + fields[key] = json.RawMessage(text) + changed = true + } + for _, key := range sourceRuleObjectFields { + value, ok := fields[key] + if !ok { + continue + } + if bytes.Equal(bytes.TrimSpace(value), []byte("[]")) { + fields[key] = json.RawMessage("{}") + changed = true + } + } + if !changed { + return raw + } + out, err := json.Marshal(fields) + if err != nil { + return raw + } + return string(out) +} + // SourceProps 书源 JSON 原样转为 map(注入 JS 的 `source` 对象)。 func (b *BookSource) SourceProps() map[string]any { if b.RawJSON == "" { diff --git a/internal/service/reader/source_capability_test.go b/internal/service/reader/source_capability_test.go new file mode 100644 index 0000000..bf40058 --- /dev/null +++ b/internal/service/reader/source_capability_test.go @@ -0,0 +1,24 @@ +package reader + +import "testing" + +// TestRawSourceNeedsBrowser webView/webjs 类书源要在列表里先标出来 +// (服务端没有无头浏览器,这类源注定跑不通,不该等用户点进去才报错)。 +func TestRawSourceNeedsBrowser(t *testing.T) { + cases := []struct { + name string + raw string + want bool + }{ + {"webView 选项", `{"searchUrl":"https://a.com/s,{webView:true}"}`, true}, + {"webjs 规则", `{"ruleContent":{"content":"@webjs:getDom()"}}`, true}, + {"规则里提到 webJs", `{"ruleToc":{"chapterList":"if(supportWebJs){}"}}`, true}, + {"普通源", `{"searchUrl":"https://a.com/s?q={{key}}","ruleSearch":{"bookList":"$.list[*]"}}`, false}, + {"空", ``, false}, + } + for _, c := range cases { + if got := rawSourceNeedsBrowser(c.raw); got != c.want { + t.Errorf("%s: rawSourceNeedsBrowser = %v,期望 %v", c.name, got, c.want) + } + } +} diff --git a/internal/service/reader/source_header_test.go b/internal/service/reader/source_header_test.go new file mode 100644 index 0000000..65a948d --- /dev/null +++ b/internal/service/reader/source_header_test.go @@ -0,0 +1,82 @@ +package reader + +import ( + "testing" + + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/service/reader/rule" +) + +// TestApplyHeadersEvaluatesJSHeader 覆盖拷贝漫画(优++)这类书源: +// header 是 @js: 规则,运行时才用 baseUrl 拼出 platform/version/referer。 +// 此前 applyHeaders 只做 json.Unmarshal,JS 形态被整体丢掉,请求少了这些 +// 必需头,上游同样回 200 但结果是空列表 —— 表现为「源能导入却搜不到内容」。 +func TestApplyHeadersEvaluatesJSHeader(t *testing.T) { + svc, _ := newLoginTestService(t) + const header = "@js:\nJSON.stringify({\"platform\":\"1\",\"version\":\"9.9.9\",\"referer\":baseUrl})" + src := &model.ReaderBookSource{ + Name: "拷贝漫画(优++)", + SourceURL: "https://www.mangacopy.com/", + Header: header, + } + bs := &BookSource{BookSourceURL: src.SourceURL, BookSourceName: src.Name, Header: strPtr(header)} + + sess := svc.newSession(t.Context(), src, bs) + defer sess.close() + + req := &rule.Request{Method: "GET", URL: "https://api.mangacopy.com/api/v3/search/comic", Headers: map[string]string{}} + sess.applyHeaders(req, sess.runner("火影", 1)) + + if got := req.Headers["platform"]; got != "1" { + t.Fatalf("platform = %q,期望 \"1\"(JS 形态的 header 没有被执行)", got) + } + if got := req.Headers["version"]; got != "9.9.9" { + t.Fatalf("version = %q,期望 \"9.9.9\"", got) + } + // baseUrl 必须绑成书源地址(对应 legado getHeaderMap 里的 getKey()) + if got := req.Headers["referer"]; got != "https://www.mangacopy.com/" { + t.Fatalf("referer = %q,期望书源地址", got) + } +} + +// TestApplyHeadersKeepsPlainJSONHeader 直接写 JSON 的 header 不能回归。 +func TestApplyHeadersKeepsPlainJSONHeader(t *testing.T) { + svc, _ := newLoginTestService(t) + header := `{"User-Agent":"MyBox/1.0","X-Token":"abc"}` + src := &model.ReaderBookSource{Name: "纯 JSON 源", SourceURL: "https://example.com/", Header: header} + bs := &BookSource{BookSourceURL: src.SourceURL, Header: strPtr(header)} + + sess := svc.newSession(t.Context(), src, bs) + defer sess.close() + + req := &rule.Request{Method: "GET", URL: "https://example.com/search", Headers: map[string]string{}} + sess.applyHeaders(req, sess.runner("", 0)) + + if got := req.Headers["User-Agent"]; got != "MyBox/1.0" { + t.Fatalf("User-Agent = %q", got) + } + if got := req.Headers["X-Token"]; got != "abc" { + t.Fatalf("X-Token = %q", got) + } +} + +// TestApplyHeadersSurvivesBrokenJSHeader 坏掉的 header JS 不能 panic, +// 也不该把半成品请求头发出去。 +func TestApplyHeadersSurvivesBrokenJSHeader(t *testing.T) { + svc, _ := newLoginTestService(t) + const header = "@js:\nthrow new Error('boom')" + src := &model.ReaderBookSource{Name: "坏 header 源", SourceURL: "https://example.com/", Header: header} + bs := &BookSource{BookSourceURL: src.SourceURL, Header: strPtr(header)} + + sess := svc.newSession(t.Context(), src, bs) + defer sess.close() + + req := &rule.Request{Method: "GET", URL: "https://example.com/search", Headers: map[string]string{}} + sess.applyHeaders(req, sess.runner("", 0)) + + if len(req.Headers) != 0 { + t.Fatalf("求值失败的 header 不应写入任何头,实际: %v", req.Headers) + } +} + +func strPtr(s string) *string { return &s } diff --git a/internal/service/reader/source_import_test.go b/internal/service/reader/source_import_test.go new file mode 100644 index 0000000..7ca4937 --- /dev/null +++ b/internal/service/reader/source_import_test.go @@ -0,0 +1,149 @@ +package reader + +import ( + "strings" + "testing" +) + +// messySourceJSON 复刻真实「拷贝漫画(优++)」源文件的形状: +// lastUpdateTime 是字符串、respondTime 是数字、ruleExplore 是空数组、 +// ruleBookInfo/ruleContent 里同时有数字与 JS 字符串。 +// 这类文件在阅读 App 里能导入,在这里曾经整体解析失败。 +const messySourceJSON = `[{ + "bookSourceComment": "//By情无羁25.04.09 使用需要魔法", + "bookSourceGroup": "", + "bookSourceName": "拷贝漫画(优++)", + "bookSourceType": 0, + "bookSourceUrl": "https://www.mangacopy.com/", + "customOrder": 208, + "enabled": true, + "enabledCookieJar": false, + "enabledExplore": true, + "exploreUrl": "@js:\nsort = [];\nJSON.stringify(sort)", + "header": "@js:\nJSON.stringify({\"platform\":\"1\",\"referer\":baseUrl})", + "lastUpdateTime": "1788449879889", + "respondTime": 181130, + "ruleBookInfo": {"init": "$..comic", "intro": "$..brief"}, + "ruleContent": {"content": "$..url\nresult", "imageStyle": "FULL"}, + "ruleExplore": [], + "ruleSearch": {"bookList": "$..list[*]", "name": "$.name"}, + "ruleToc": {"chapterList": "$..list[*]", "chapterName": "name"}, + "searchUrl": "https://api.mangacopy.com/api/v3/search/comic?q={{key}}", + "weight": 0 +}]` + +// TestImportSourcesAcceptsMessySource 覆盖用户的真实场景:数字写法的字符串字段 +// (lastUpdateTime)与空数组写法的规则对象(ruleExplore)不该让整条书源导入失败。 +func TestImportSourcesAcceptsMessySource(t *testing.T) { + svc, _ := newLoginTestService(t) + ctx := t.Context() + + imported, err := svc.ImportSources(ctx, messySourceJSON) + if err != nil { + t.Fatalf("导入失败: %v", err) + } + if imported != 1 { + t.Fatalf("导入数量 = %d,期望 1", imported) + } + + bs, err := svc.repo.GetSourceByURL(ctx, "https://www.mangacopy.com/") + if err != nil || bs == nil { + t.Fatalf("按 URL 查不到导入的书源: %v", err) + } + if bs.Name != "拷贝漫画(优++)" { + t.Fatalf("书源名 = %q", bs.Name) + } + // 关键字段要真的解出来,而不是「解成功了但字段全空」 + parsed, err := ParseBookSource(bs.RawJSON) + if err != nil { + t.Fatalf("重新解析失败: %v", err) + } + if parsed.LastUpdateTime == nil || *parsed.LastUpdateTime != 1788449879889 { + t.Fatalf("lastUpdateTime = %v,期望 1788449879889", parsed.LastUpdateTime) + } + if parsed.RuleExplore == nil { + t.Fatal("ruleExplore 应当被当成空规则对象,而不是解析失败") + } + if SPtr(parsed.RuleSearch.BookList) != "$..list[*]" { + t.Fatalf("ruleSearch.bookList = %q", SPtr(parsed.RuleSearch.BookList)) + } + if SPtr(parsed.RuleContent.ImageStyle) != "FULL" { + t.Fatalf("ruleContent.imageStyle = %q", SPtr(parsed.RuleContent.ImageStyle)) + } + // 落库的 raw 必须是原文(源 JS 可能依赖原样字段),不能被改写。 + // 这里特意按原文里的「冒号后有空格」写法比对:一旦被重新序列化就对不上了。 + if !strings.Contains(bs.RawJSON, `"lastUpdateTime": "1788449879889"`) { + t.Fatalf("RawJSON 被改写了,应当保留原始文本;实际: %s", bs.RawJSON) + } +} + +// TestNormalizeSourceJSON 单独锁定宽容规则本身。 +func TestNormalizeSourceJSON(t *testing.T) { + cases := []struct { + name string + in string + want string // 期望解析后可用的字段值 / 是否报错 + }{ + {"数字字符串", `{"weight":"12"}`, "12"}, + {"已经是数字", `{"weight":12}`, "12"}, + {"空字符串当缺省", `{"weight":""}`, ""}, + {"非整数不猜", `{"weight":"1.5"}`, ""}, + {"空数组规则", `{"ruleExplore":[]}`, "obj"}, + {"正常规则不受影响", `{"ruleExplore":{"bookList":"a"}}`, "obj"}, + } + for _, c := range cases { + got := normalizeSourceJSON(c.in) + switch c.want { + case "obj": + if !strings.Contains(got, "{}") && !strings.Contains(got, `"bookList":"a"`) { + t.Errorf("%s: normalizeSourceJSON(%s) = %s", c.name, c.in, got) + } + case "": + bs, err := ParseBookSource(got) + if err != nil { + t.Errorf("%s: %v", c.name, err) + continue + } + if bs.Weight != nil { + t.Errorf("%s: weight 应当为 nil,实际 %v", c.name, *bs.Weight) + } + case "": + if _, err := ParseBookSource(got); err == nil { + t.Errorf("%s: 期望解析报错(不做无根据的猜测)", c.name) + } + default: + bs, err := ParseBookSource(got) + if err != nil { + t.Errorf("%s: %v", c.name, err) + continue + } + if bs.Weight == nil || *bs.Weight != 12 { + t.Errorf("%s: weight = %v,期望 12", c.name, bs.Weight) + } + } + } +} + +// TestParseBookSourceRejectsGarbage 真的坏掉的 JSON 仍要报错,不能因为宽容而静默吞掉。 +func TestParseBookSourceRejectsGarbage(t *testing.T) { + if _, err := ParseBookSource(`{"bookSourceUrl":123}`); err == nil { + t.Fatal("bookSourceUrl 是数字时应当报错") + } + if _, err := ParseBookSource(`{not json`); err == nil { + t.Fatal("非法 JSON 应当报错") + } +} + +// TestImportSourcesReportsReasonWhenNothingImported 全部失败时必须把原因带回前端, +// 否则界面只会弹「成功导入 0 个书源」,用户无从下手。 +func TestImportSourcesReportsReasonWhenNothingImported(t *testing.T) { + svc, _ := newLoginTestService(t) + + _, err := svc.ImportSources(t.Context(), `[{"bookSourceUrl":123,"bookSourceName":"坏源"}]`) + if err == nil { + t.Fatal("一条都没导入时应当返回错误") + } + if !strings.Contains(err.Error(), "解析失败") { + t.Fatalf("错误信息应说明是解析失败,实际: %v", err) + } +} diff --git a/internal/service/reader/toc_pagination_test.go b/internal/service/reader/toc_pagination_test.go new file mode 100644 index 0000000..2aa1fd3 --- /dev/null +++ b/internal/service/reader/toc_pagination_test.go @@ -0,0 +1,159 @@ +package reader + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/truewhile/MeBox/internal/model" +) + +// TestGetTocFollowsNextTocUrl 长书目录按 offset/limit 分页时必须翻页取全。 +// 这是「目录少章」的根因:nextTocUrl 没实现时只会拿到第一页 +// (实测某漫画站点 total=399/limit=100,MeBox 只得到 100 章)。 +func TestGetTocFollowsNextTocUrl(t *testing.T) { + pages := map[string]string{ + "0": `{"count":4,"list":[{"name":"第1话","url":"/c/1"},{"name":"第2话","url":"/c/2"}]}`, + "2": `{"count":4,"list":[{"name":"第3话","url":"/c/3"},{"name":"第4话","url":"/c/4"}]}`, + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + offset := r.URL.Query().Get("offset") + body, ok := pages[offset] + if !ok { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, body) + })) + defer srv.Close() + + // 与真实书源(拷贝漫画)一致的写法:用 baseUrl 拼下一页地址 + nextTocURL := `baseUrl.indexOf('offset=0') >= 0 ? baseUrl.replace('offset=0','offset=2') : ''` + header := "" + src := &model.ReaderBookSource{Name: "分页目录测试源", SourceURL: srv.URL} + bs := &BookSource{ + BookSourceURL: srv.URL, + Header: &header, + RuleToc: &TocRule{ + ChapterList: strPtr("$.list[*]"), + ChapterName: strPtr("$.name"), + ChapterURL: strPtr("$.url"), + NextTocURL: strPtr(nextTocURL), + }, + } + + svc, _ := newLoginTestService(t) + tocURL := srv.URL + "/toc?limit=2&offset=0" + chapters, _, err := svc.getTocFrom(t.Context(), src, bs, srv.URL+"/book/1", tocURL) + if err != nil { + t.Fatalf("取目录失败: %v", err) + } + if len(chapters) != 4 { + t.Fatalf("章节数 = %d,期望 4(第二页没有被抓取)", len(chapters)) + } + for i, want := range []string{"第1话", "第2话", "第3话", "第4话"} { + if chapters[i].Title != want { + t.Errorf("章节[%d] = %q,期望 %q", i, chapters[i].Title, want) + } + if chapters[i].Index != i { + t.Errorf("章节[%d] 的 Index = %d,应按翻页顺序重排", i, chapters[i].Index) + } + } +} + +// TestGetTocNextTocUrlListsAllPagesFromFirstPage 书源只在第一页能算出完整后续页列表 +// (拷贝漫画的写法:规则锚定 offset=0,第二页上算出来还是同一批地址)。 +// 这种源必须把所有后续页一次性入队,否则只翻一页就停。 +func TestGetTocNextTocUrlListsAllPagesFromFirstPage(t *testing.T) { + pages := map[string]string{ + "0": `{"total":4,"list":[{"name":"第1话","url":"/c/1"},{"name":"第2话","url":"/c/2"}]}`, + "100": `{"total":4,"list":[{"name":"第3话","url":"/c/3"},{"name":"第4话","url":"/c/4"}]}`, + } + var hits []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + offset := r.URL.Query().Get("offset") + hits = append(hits, offset) + body, ok := pages[offset] + if !ok { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{"total":4,"list":[]}`) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, body) + })) + defer srv.Close() + + // 与真实书源一致:一次列出全部后续页,且锚点写死 offset=0(在第二页上等于原地踏步) + nextTocURL := ` +list=[]; +for(i=0;i<=1;i++){list.push(baseUrl.replace('offset=0','offset='+100*i))} +list +` + header := "" + src := &model.ReaderBookSource{Name: "全量下一页测试源", SourceURL: srv.URL} + bs := &BookSource{ + BookSourceURL: srv.URL, + Header: &header, + RuleToc: &TocRule{ + ChapterList: strPtr("$.list[*]"), + ChapterName: strPtr("$.name"), + ChapterURL: strPtr("$.url"), + NextTocURL: strPtr(nextTocURL), + }, + } + + svc, _ := newLoginTestService(t) + chapters, _, err := svc.getTocFrom(t.Context(), src, bs, srv.URL+"/book/1", srv.URL+"/toc?limit=2&offset=0") + if err != nil { + t.Fatalf("取目录失败: %v", err) + } + if len(chapters) != 4 { + t.Fatalf("章节数 = %d,期望 4(第一页列出的后续页没有被全部抓取)", len(chapters)) + } + // 每页只抓一次(队列去重) + if len(hits) != 2 { + t.Fatalf("上游请求次数 = %d(%v),期望 2", len(hits), hits) + } +} + +// TestGetTocNextTocUrlSelfReferenceStops nextTocUrl 指回当前页时必须停下, +// 不能靠「规则永远给下一页」把自己转死。 +func TestGetTocNextTocUrlSelfReferenceStops(t *testing.T) { + var hits int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{"list":[{"name":"第1话","url":"/c/1"}]}`) + })) + defer srv.Close() + + // 永远返回同一页地址(用 baseUrl 原样返回) + nextTocURL := `baseUrl` + header := "" + src := &model.ReaderBookSource{Name: "自引用目录测试源", SourceURL: srv.URL} + bs := &BookSource{ + BookSourceURL: srv.URL, + Header: &header, + RuleToc: &TocRule{ + ChapterList: strPtr("$.list[*]"), + ChapterName: strPtr("$.name"), + ChapterURL: strPtr("$.url"), + NextTocURL: strPtr(nextTocURL), + }, + } + + svc, _ := newLoginTestService(t) + chapters, _, err := svc.getTocFrom(t.Context(), src, bs, srv.URL+"/book/1", srv.URL+"/toc") + if err != nil { + t.Fatalf("取目录失败: %v", err) + } + if len(chapters) != 1 { + t.Fatalf("章节数 = %d,期望 1", len(chapters)) + } + if hits != 1 { + t.Fatalf("上游被请求 %d 次,自引用时应只请求 1 次", hits) + } +}