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:
av
2026-07-07 20:38:01 +03:00
co-authored by Claude Opus 4.8
parent 6d801ed03b
commit e9ec26d09a
10 changed files with 837 additions and 7 deletions
+138 -3
View File
@@ -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).