Ingest: контекст распознавания из полей magnet-ссылки
Синтезируем контекст из полей самой magnet-ссылки (dn, xl, tr/xs, kt) без сети и дополняем им текст от транспорта: пользовательский текст первым, при пустом — синтез единственный. Обогащённый контекст идёт только в download.Context (его читают recognition и веб-UI); вход namer и отображаемое имя не меняются — строки-факты (Размер:/Трекер:) в display_name не текут. - magnet.Info: поля ExactLength/Sources/Keywords + Info.Context() (синтез, отсев dn-заглушек *-topic-<id>, домен трекера, человекочитаемый размер) - Parse устойчив к ссылке в процент-кодировке (разовый QueryUnescape) - ingest: mergeContext → download.Context, namer на сыром req.Context - Влита дельта capability ingest в openspec/specs, change заархивирован Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+138
-3
@@ -11,6 +11,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -20,6 +22,9 @@ type Info struct {
|
||||
Infohashes []string // все хеши ссылки (гибридный magnet несёт btih и btmh); v1 раньше v2
|
||||
DisplayName string // dn — человекочитаемое имя, если задано
|
||||
Trackers []string // tr — трекеры
|
||||
Sources []string // xs — exact source (ссылки/источники)
|
||||
Keywords []string // kt — ключевые слова
|
||||
ExactLength int64 // xl — размер в байтах; 0, если не задан/некорректен
|
||||
}
|
||||
|
||||
// ErrNotMagnet возвращается, если строка не является magnet-ссылкой.
|
||||
@@ -29,9 +34,8 @@ var ErrNotMagnet = errors.New("not a magnet link")
|
||||
// 32-символьный base32) и btmh (v2: sha256-multihash). При нескольких xt
|
||||
// предпочитается v1.
|
||||
func Parse(raw string) (Info, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || !strings.EqualFold(u.Scheme, "magnet") {
|
||||
u, ok := magnetURL(strings.TrimSpace(raw))
|
||||
if !ok {
|
||||
return Info{}, ErrNotMagnet
|
||||
}
|
||||
vals := u.Query()
|
||||
@@ -60,14 +64,41 @@ func Parse(raw string) (Info, error) {
|
||||
return Info{}, fmt.Errorf("magnet without a usable infohash (xt)")
|
||||
}
|
||||
|
||||
var xl int64
|
||||
if s := strings.TrimSpace(vals.Get("xl")); s != "" {
|
||||
if n, err := strconv.ParseInt(s, 10, 64); err == nil && n > 0 {
|
||||
xl = n
|
||||
}
|
||||
}
|
||||
|
||||
return Info{
|
||||
Infohash: hashes[0],
|
||||
Infohashes: hashes,
|
||||
DisplayName: vals.Get("dn"),
|
||||
Trackers: vals["tr"],
|
||||
Sources: vals["xs"],
|
||||
Keywords: vals["kt"],
|
||||
ExactLength: xl,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// magnetURL разбирает строку как magnet-ссылку. Если ссылку скопировали
|
||||
// целиком в процент-кодировке (magnet%3A%3Fxt%3D…, например вытащили из
|
||||
// другого URL или из HTML-атрибута), пробует раскодировать её один раз и
|
||||
// разобрать снова. Разовый unescape применяется ТОЛЬКО когда прямой разбор не
|
||||
// дал magnet — обычную ссылку (где «+» в query значим) он не затрагивает.
|
||||
func magnetURL(raw string) (*url.URL, bool) {
|
||||
if u, err := url.Parse(raw); err == nil && strings.EqualFold(u.Scheme, "magnet") {
|
||||
return u, true
|
||||
}
|
||||
if dec, err := url.QueryUnescape(raw); err == nil && dec != raw {
|
||||
if u, err := url.Parse(strings.TrimSpace(dec)); err == nil && strings.EqualFold(u.Scheme, "magnet") {
|
||||
return u, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// normalizeBTIH нормализует v1-infohash (SHA-1, 20 байт) к нижнему hex.
|
||||
func normalizeBTIH(h string) (string, error) {
|
||||
switch len(h) {
|
||||
@@ -90,6 +121,110 @@ func normalizeBTIH(h string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// topicStubRe распознаёт dn-заглушку вида «rutracker-topic-6514485» —
|
||||
// идентификатор темы трекера, а не название релиза (частый случай для голых
|
||||
// magnet). Такое имя как строку-название в контекст не берём.
|
||||
var topicStubRe = regexp.MustCompile(`(?i)^[\w.-]+-topic-\w+$`)
|
||||
|
||||
// announceLabelRe — служебные поддомены анонса трекера, которые убираем из
|
||||
// домена происхождения ради чистого сигнала (bt.t-ru.org → t-ru.org).
|
||||
var announceLabelRe = regexp.MustCompile(`^(?:bt\d*|www|announce|tracker|open)\.`)
|
||||
|
||||
// Context синтезирует человекочитаемый контекст распознавания из полей самой
|
||||
// ссылки (без сети): название релиза (dn, если это не заглушка-идентификатор),
|
||||
// размер (xl), происхождение по домену трекера/источника (tr/xs) и ключевые
|
||||
// слова (kt). Возвращает строки-факты, склеенные через "\n"; пустую строку —
|
||||
// если пригодных полей нет. Результат предназначен для download.Context
|
||||
// (recognition, веб-UI), но НЕ для вывода отображаемого имени.
|
||||
func (i Info) Context() string {
|
||||
var lines []string
|
||||
|
||||
if name := strings.TrimSpace(i.DisplayName); name != "" && !topicStubRe.MatchString(name) {
|
||||
lines = append(lines, name)
|
||||
}
|
||||
if i.ExactLength > 0 {
|
||||
lines = append(lines, "Размер: "+humanSize(i.ExactLength))
|
||||
}
|
||||
if d := originDomains(append(append([]string{}, i.Trackers...), i.Sources...)); d != "" {
|
||||
lines = append(lines, "Трекер: "+d)
|
||||
}
|
||||
if kw := joinKeywords(i.Keywords); kw != "" {
|
||||
lines = append(lines, "Ключевые слова: "+kw)
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// humanSize форматирует размер в байтах человекочитаемо (≈ 2.1 GiB).
|
||||
func humanSize(n int64) string {
|
||||
const unit = 1024
|
||||
if n < unit {
|
||||
return fmt.Sprintf("%d B", n)
|
||||
}
|
||||
f := float64(n)
|
||||
i := -1
|
||||
for _, u := range []string{"KiB", "MiB", "GiB", "TiB", "PiB"} {
|
||||
f /= unit
|
||||
i++
|
||||
if f < unit {
|
||||
return fmt.Sprintf("≈ %.1f %s", f, u)
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("≈ %.1f PiB", f)
|
||||
}
|
||||
|
||||
// originDomains извлекает уникальные домены происхождения из tr/xs (только
|
||||
// http(s)/udp-ссылки с хостом), убирая служебные поддомены анонса. Порядок
|
||||
// сохраняется; результат — через ", ".
|
||||
func originDomains(refs []string) string {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, ref := range refs {
|
||||
u, err := url.Parse(strings.TrimSpace(ref))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "http", "https", "udp":
|
||||
default:
|
||||
continue
|
||||
}
|
||||
host := strings.ToLower(u.Hostname())
|
||||
if host == "" {
|
||||
continue
|
||||
}
|
||||
// Срезаем служебный поддомен анонса, но лишь пока остаётся хотя бы две
|
||||
// метки — иначе для хоста вида «tracker.org» получили бы голый TLD.
|
||||
if s := announceLabelRe.ReplaceAllString(host, ""); strings.Contains(s, ".") {
|
||||
host = s
|
||||
}
|
||||
if seen[host] {
|
||||
continue
|
||||
}
|
||||
seen[host] = true
|
||||
out = append(out, host)
|
||||
}
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
|
||||
// joinKeywords нормализует kt: отдельные ключевые слова без пустых и дублей.
|
||||
// В magnet kt разделяются «+», но url.Query() декодирует «+» в пробел ещё при
|
||||
// разборе, поэтому режем по пробельным символам (strings.Fields).
|
||||
func joinKeywords(kt []string) string {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, v := range kt {
|
||||
for kw := range strings.FieldsSeq(v) {
|
||||
if seen[kw] {
|
||||
continue
|
||||
}
|
||||
seen[kw] = true
|
||||
out = append(out, kw)
|
||||
}
|
||||
}
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
|
||||
// normalizeBTMH нормализует v2-infohash. Multihash sha256 имеет вид
|
||||
// 1220<64-hex>; возвращаем сами 64-hex (так его отдаёт qBittorrent в
|
||||
// infohash_v2).
|
||||
|
||||
Reference in New Issue
Block a user