sintbot 1.0.7 → 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.en.json +22 -0
- package/CHANGELOG.json +23 -0
- package/README.md +4 -8
- package/README.zh-CN.md +4 -6
- package/assets/design-styles/acid-pop.md +2 -0
- package/assets/design-styles/archive-index.md +2 -0
- package/assets/design-styles/art-deco.md +2 -0
- package/assets/design-styles/cinematic-noir.md +2 -0
- package/assets/design-styles/editorial.md +2 -0
- package/assets/design-styles/geometric-pop.md +2 -0
- package/assets/design-styles/hand-drawn.md +2 -0
- package/assets/design-styles/islamic-geometry.md +2 -0
- package/assets/design-styles/liquid-glass.md +2 -0
- package/assets/design-styles/neo-chinese.md +2 -0
- package/assets/design-styles/precision-mono.md +2 -0
- package/assets/design-styles/quiet-luxury.md +2 -0
- package/assets/design-styles/riso-print.md +2 -0
- package/assets/design-styles/soft-friendly.md +2 -0
- package/assets/design-styles/swiss-grid.md +2 -0
- package/assets/design-styles/tech-dark.md +2 -0
- package/bin/sbot.js +2 -2
- package/package.json +1 -1
- package/src/core/engine.js +2 -1
- package/src/core/proxy.js +19 -3
- package/src/design.js +219 -65
- package/src/platform/slack.js +1 -1
- package/src/ui/console.html +295 -37
- package/src/ui/dashboard.js +165 -0
- package/src/ui/server.js +98 -48
- package/src/ui/takeover.js +7 -0
package/src/design.js
CHANGED
|
@@ -46,7 +46,7 @@ function _unquote(v) {
|
|
|
46
46
|
// 预设/风格档的展示元信息。缺字段给 ''(调用方决定怎么兜底,避免这里编内容)。
|
|
47
47
|
function parseStyleMeta(text) {
|
|
48
48
|
const fm = _frontMatter(text) || []
|
|
49
|
-
const out = { name: '', description: '', suits: '', platform: '' }
|
|
49
|
+
const out = { name: '', description: '', suits: '', platform: '', nameEn: '', descriptionEn: '' }
|
|
50
50
|
for (const line of fm) {
|
|
51
51
|
// suits(v0.9.5)= 适用场景标签。风格库涨到二十几种之后,agent 只看名字和一句话
|
|
52
52
|
// 根本挑不准 —— 它得先知道「这套适合做什么」才筛得出该给用户看哪几个。
|
|
@@ -55,8 +55,11 @@ function parseStyleMeta(text) {
|
|
|
55
55
|
// 阅读向,两者不互斥,混进一张词表日后必然筛别扭。**留空 = 载体无关**,这是常态 ——
|
|
56
56
|
// 内置那批(瑞士网格、新中式…)本来就不挑载体,一个都不用补标;只有明确绑死某个载体
|
|
57
57
|
// 的档(某项目的小程序档 / 后台档)才写,那时它是**真信息**而不是凑出来的标签。
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
// name_en / description_en = 控制台英文界面用的名字与一句话(内置预设都写了;自建档可不写,不写就显示原文)。
|
|
59
|
+
// 只给人看:agent 挑风格、出图照旧读中文那两行。
|
|
60
|
+
const m = /^(name|description|suits|platform|name_en|description_en):\s*(.*)$/.exec(line) // 行首=顶层键;缩进的同名子键不算
|
|
61
|
+
const k = m && ({ name_en: 'nameEn', description_en: 'descriptionEn' }[m[1]] || m[1])
|
|
62
|
+
if (m && !out[k]) out[k] = _unquote(m[2])
|
|
60
63
|
}
|
|
61
64
|
return out
|
|
62
65
|
}
|
|
@@ -418,7 +421,7 @@ const ROLE_KEYS = {
|
|
|
418
421
|
// (primary/neutral/line · paper/ink/muted · bg/surface/warn · cream/blue/yellow/red),用户自己
|
|
419
422
|
// 写的更没谱 —— 图标要对所有风格档都画得出来,就不能要求作者按某套键名写。
|
|
420
423
|
// 返回 null 表示一个合法颜色都没有(调用方决定怎么兜底,这里不编颜色)。
|
|
421
|
-
function styleRoles(colors) {
|
|
424
|
+
function styleRoles(colors, { fromExtract = false } = {}) {
|
|
422
425
|
const entries = []
|
|
423
426
|
for (const [k, v] of Object.entries(colors || {})) {
|
|
424
427
|
if (v && typeof v === 'object') continue // 嵌套子段不是颜色
|
|
@@ -448,7 +451,9 @@ function styleRoles(colors) {
|
|
|
448
451
|
const bg = _rgb(roles.bg)
|
|
449
452
|
if (!roles.ink) {
|
|
450
453
|
const pool = free().length ? free() : entries.filter((e) => e.hex !== roles.bg)
|
|
451
|
-
|
|
454
|
+
// 从参考提炼(fromExtract):传进来的顺序就是频次 ⇒ 先取「对比够当正文(≥7)里用得最多的」——
|
|
455
|
+
// 只看对比的话纯黑总会赢,哪怕站点正文其实是 #1F2329(飞书实测)。风格档自己的色板照旧按对比挑。
|
|
456
|
+
const pick = (fromExtract && pool.find((e) => _contrast(e.rgb, bg) >= 7)) || pickBy(pool, (e) => _contrast(e.rgb, bg))
|
|
452
457
|
claim('ink', pick)
|
|
453
458
|
if (!roles.ink) roles.ink = _hex(...(_lum(bg) > 0.5 ? [17, 17, 17] : [240, 240, 240])) // 只有一个颜色:黑白兜底
|
|
454
459
|
}
|
|
@@ -457,11 +462,19 @@ function styleRoles(colors) {
|
|
|
457
462
|
// 强调色 = 最鲜艳的那个。彩度门槛挡掉纯灰(灰色当强调块等于没强调),够不着门槛就退回
|
|
458
463
|
// 「对比最强的剩余色」,再不行就用文字色(宁可朴素也不能画个看不见的块)。
|
|
459
464
|
const pool = free().filter((e) => _chroma(e.rgb) > 0.08)
|
|
460
|
-
|
|
465
|
+
// 从参考提炼:彩色里**用得最多**的那个,不是最鲜艳的 —— 一处点缀的亮金会顶掉站点到处在用的品牌铜色(sintbot.com 实测)
|
|
466
|
+
claim('accent', (fromExtract ? pool[0] : pickBy(pool, (e) => _chroma(e.rgb))) || pickBy(free(), (e) => _contrast(e.rgb, bg)))
|
|
461
467
|
if (!roles.accent) roles.accent = roles.ink
|
|
462
468
|
}
|
|
463
469
|
if (!roles.muted) {
|
|
464
|
-
|
|
470
|
+
if (fromExtract) {
|
|
471
|
+
// 从参考提炼:次要色 = 底与正文之间那一档灰 —— 对比够读(≥2.5)又明显弱于正文(不到正文的六成)。
|
|
472
|
+
// 不取对比最强的剩余色:参考站点的白色描边 / 高光会因此当选,「次要信息」比正文还亮。
|
|
473
|
+
// 候选按传入顺序取第一个(提炼传进来的顺序就是频次)。风格档自己的色板不走这条:
|
|
474
|
+
// 「几何构成」拿蓝当次要色是设计意图,不能按灰阶规则改掉。
|
|
475
|
+
const inkC = _contrast(ink, bg)
|
|
476
|
+
claim('muted', free().find((e) => _chroma(e.rgb) <= 0.08 && _contrast(e.rgb, bg) >= 2.5 && _contrast(e.rgb, bg) < inkC * 0.6))
|
|
477
|
+
} else claim('muted', pickBy(free(), (e) => _contrast(e.rgb, bg)))
|
|
465
478
|
if (!roles.muted) roles.muted = _hex(..._mix(bg, ink, 0.55))
|
|
466
479
|
}
|
|
467
480
|
if (!roles.line) {
|
|
@@ -1070,6 +1083,8 @@ function styleCard(text, extra = {}, { withForm = true } = {}) {
|
|
|
1070
1083
|
// 说清「这份管哪块」—— 一个项目多份档时,这是唯一能把它们分开的字段。
|
|
1071
1084
|
suits: meta.suits,
|
|
1072
1085
|
platform: meta.platform,
|
|
1086
|
+
nameEn: meta.nameEn, // 英文界面用(前端登记进翻译表,中英切换两个方向都走那一套)
|
|
1087
|
+
descriptionEn: meta.descriptionEn,
|
|
1073
1088
|
serif: spec.serif,
|
|
1074
1089
|
mono: spec.mono,
|
|
1075
1090
|
cjkKinds: cjkKinds, // 这套中文走哪一路(16 套现在不是同一条栈)
|
|
@@ -1859,9 +1874,9 @@ function deleteUserStyle(slug, { dir = userStylesDir(), builtinDir = BUILTIN_STY
|
|
|
1859
1874
|
// ── 从参考提炼(确定性提取器,不过模型)────────────────────────────────────────
|
|
1860
1875
|
// 定位要说清楚:这是**起点**不是成品 —— 抓 CSS 里的色值/字族/圆角按频次排序,喂给 styleRoles
|
|
1861
1876
|
// 推四角色回填表单,用户再手调。深度提炼(读懂气质、写 prose)是群里对话那条路的事。
|
|
1862
|
-
//
|
|
1863
|
-
//
|
|
1864
|
-
//
|
|
1877
|
+
// 出网走配置里的代理(core/proxy 的隧道 fetch,与 Slack 适配器同一份):Node 的全局 fetch 不读
|
|
1878
|
+
// HTTP_PROXY 一类环境变量,墙内机器直连海外站点是 8 秒超时(实测 sintbot.com:直连超时,走代理 1.7s)。
|
|
1879
|
+
// 没配代理才退回全局 fetch。
|
|
1865
1880
|
const MAX_CSS_CHARS = 512 * 1024
|
|
1866
1881
|
const EXTRACT_MAX_SHEETS = 6
|
|
1867
1882
|
const EXTRACT_TIMEOUT_MS = 8000
|
|
@@ -1904,22 +1919,61 @@ function stripCssComments(src) {
|
|
|
1904
1919
|
return out
|
|
1905
1920
|
}
|
|
1906
1921
|
|
|
1907
|
-
//
|
|
1908
|
-
//
|
|
1909
|
-
//
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1922
|
+
// 规则块切分 → [{ sel, body, at }]。单趟 indexOf 扫描(O(N)),不走正则 —— 原来那条
|
|
1923
|
+
// `([^{}]{0,400})\{([^{}]{0,4000})\}` 对超过 4000 字的声明体整块跳过,而大站的 :root 变量块动辄上万字
|
|
1924
|
+
// (飞书、Tailwind 主题),根规则上的声明就这样一个都看不见。@media / @supports 这类分组只是往里走一层;
|
|
1925
|
+
// 声明体里再出现 `{`(CSS 嵌套写法)就整块跳到它真正闭合的地方。
|
|
1926
|
+
const ROOT_SEL = /(^|,)\s*(html|body|:root|page)\s*(,|$)/i
|
|
1927
|
+
const DARK_SEL = /dark|night/i
|
|
1928
|
+
function _cssRules(src, tick = _noTick) {
|
|
1929
|
+
const out = []
|
|
1930
|
+
let i = 0
|
|
1931
|
+
while (i < src.length) {
|
|
1932
|
+
if ((out.length & 255) === 0) tick()
|
|
1933
|
+
const ob = src.indexOf('{', i)
|
|
1934
|
+
if (ob < 0) break
|
|
1935
|
+
let sel = src.slice(i, ob)
|
|
1936
|
+
sel = sel.slice(Math.max(sel.lastIndexOf('}'), sel.lastIndexOf(';')) + 1).trim()
|
|
1937
|
+
if (sel.startsWith('@')) { i = ob + 1; continue } // 分组 at-rule:往里走一层
|
|
1938
|
+
const cb = src.indexOf('}', ob + 1)
|
|
1939
|
+
if (cb < 0) break
|
|
1940
|
+
const inner = src.indexOf('{', ob + 1)
|
|
1941
|
+
if (inner >= 0 && inner < cb) {
|
|
1942
|
+
let depth = 1
|
|
1943
|
+
let j = ob + 1
|
|
1944
|
+
while (j < src.length && depth > 0) { const c = src.charCodeAt(j); if (c === 123) depth++; else if (c === 125) depth--; j++ }
|
|
1945
|
+
i = j
|
|
1946
|
+
continue
|
|
1947
|
+
}
|
|
1948
|
+
out.push({ sel: sel.slice(-400), body: src.slice(ob + 1, cb), at: ob })
|
|
1949
|
+
i = cb + 1
|
|
1950
|
+
}
|
|
1951
|
+
return out
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
// 自定义属性表:`--brand-bg: #0F1117` 这类定义按出处分三档,同名取档位最靠前的那一份(同档先到先得):
|
|
1955
|
+
// 0 = 根规则(:root / html / body / page),且不在夜间媒体查询里 —— 站点的默认主题
|
|
1956
|
+
// 1 = 其他普通规则
|
|
1957
|
+
// 2 = 夜间主题(选择器带 dark / night,或在 prefers-color-scheme: dark 里)
|
|
1958
|
+
// 不收这张表,`:root{--bg:#0F1117} body{background:var(--bg)}`(深色站的主流写法)的底色一个都记不到;
|
|
1959
|
+
// 只按先到先得的话,飞书把 `:root[data-ud-theme-mode=dark]{--bg-body:…}` 写在默认主题前面,白底站的底色就成了夜间色。
|
|
1960
|
+
function _rankCssVars(rules, inDark, into = new Map()) {
|
|
1961
|
+
for (const r of rules) {
|
|
1962
|
+
if (r.body.indexOf('--') < 0) continue
|
|
1963
|
+
const rank = (DARK_SEL.test(r.sel) || inDark(r.at)) ? 2 : ROOT_SEL.test(r.sel) ? 0 : 1
|
|
1964
|
+
for (const m of r.body.matchAll(/(--[\w-]+)\s*:\s*([^;]{1,200})/g)) {
|
|
1965
|
+
const k = m[1].toLowerCase()
|
|
1966
|
+
const cur = into.get(k)
|
|
1967
|
+
if (!cur || rank < cur.rank) into.set(k, { v: m[2].trim(), rank })
|
|
1968
|
+
}
|
|
1915
1969
|
}
|
|
1916
|
-
return
|
|
1970
|
+
return into
|
|
1917
1971
|
}
|
|
1918
|
-
// 值里的 var(--x) 展开。depth 封顶
|
|
1919
|
-
//
|
|
1972
|
+
// 值里的 var(--x) 展开。depth 封顶 4:自定义属性允许互相引用,`--a:var(--b); --b:var(--a)`
|
|
1973
|
+
// 不封顶就是死循环;四层覆盖「语义变量 → 主题变量 → 色板变量」这类真实链路(飞书:--bg-body → --N00 → #fff)。
|
|
1920
1974
|
function _resolveCssVars(val, vars, depth = 0) {
|
|
1921
1975
|
const s = String(val == null ? '' : val)
|
|
1922
|
-
if (depth >=
|
|
1976
|
+
if (depth >= 4 || !/var\(/i.test(s)) return s
|
|
1923
1977
|
const next = s.replace(/var\(\s*(--[\w-]+)\s*(?:,\s*([^()]*))?\)/gi,
|
|
1924
1978
|
(all, name, fb) => { const v = vars.get(name.toLowerCase()); return v != null ? v : (fb == null ? '' : fb) })
|
|
1925
1979
|
return next === s ? s : _resolveCssVars(next, vars, depth + 1)
|
|
@@ -1968,28 +2022,29 @@ function _oklchToHex(L, C, H) {
|
|
|
1968
2022
|
}
|
|
1969
2023
|
|
|
1970
2024
|
// 一段文本里的颜色字面量 → 归一后的 #RRGGBB 数组(按出现顺序,允许重复 —— 频次就是从这来的)。
|
|
1971
|
-
// 近乎全透明的色不算(alpha < 0.1):它是「没有颜色」的写法,不是一个色。
|
|
1972
|
-
|
|
2025
|
+
// 近乎全透明的色不算(alpha < minAlpha,默认 0.1):它是「没有颜色」的写法,不是一个色。
|
|
2026
|
+
// 统计色板时门槛要更高(见 PALETTE_MIN_ALPHA);投影、渐变照旧用默认值 —— 投影本来就是半透明的。
|
|
2027
|
+
function colorLiterals(text, { minAlpha = 0.1 } = {}) {
|
|
1973
2028
|
const src = String(text == null ? '' : text)
|
|
1974
2029
|
const out = []
|
|
1975
2030
|
// 长度优先匹配 + \b:`#1234567890` 这种既不是 8 位也不是 6 位的串整体不认,避免截出假色
|
|
1976
2031
|
for (const m of src.matchAll(/#([0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b/g)) {
|
|
1977
2032
|
const h = m[1]
|
|
1978
|
-
if (h.length === 8 && parseInt(h.slice(6), 16) / 255 <
|
|
2033
|
+
if (h.length === 8 && parseInt(h.slice(6), 16) / 255 < minAlpha) continue
|
|
1979
2034
|
const hex = _normHex('#' + (h.length === 8 ? h.slice(0, 6) : h))
|
|
1980
2035
|
if (hex) out.push(hex)
|
|
1981
2036
|
}
|
|
1982
2037
|
for (const m of src.matchAll(/\brgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)\s*(?:[,/]\s*([\d.%]+))?\s*\)/gi)) {
|
|
1983
|
-
if (m[4] != null && _alphaNum(m[4]) <
|
|
2038
|
+
if (m[4] != null && _alphaNum(m[4]) < minAlpha) continue
|
|
1984
2039
|
out.push(_hex(_clamp255(+m[1]), _clamp255(+m[2]), _clamp255(+m[3])))
|
|
1985
2040
|
}
|
|
1986
2041
|
for (const m of src.matchAll(/\bhsla?\(\s*([\d.-]+)(?:deg)?[\s,]+([\d.]+)%[\s,]+([\d.]+)%\s*(?:[,/]\s*([\d.%]+))?\s*\)/gi)) {
|
|
1987
|
-
if (m[4] != null && _alphaNum(m[4]) <
|
|
2042
|
+
if (m[4] != null && _alphaNum(m[4]) < minAlpha) continue
|
|
1988
2043
|
out.push(_hslToHex(m[1], m[2], m[3]))
|
|
1989
2044
|
}
|
|
1990
2045
|
// oklch(L C H) —— L 收小数也收百分比,H 可带 deg,可选 `/ alpha`。色相缺省 0(灰阶写法)。
|
|
1991
2046
|
for (const m of src.matchAll(/\boklch\(\s*([\d.]+%?)[\s,]+([\d.]+%?)(?:[\s,]+([\d.-]+)(?:deg)?)?\s*(?:\/\s*([\d.%]+))?\s*\)/gi)) {
|
|
1992
|
-
if (m[4] != null && _alphaNum(m[4]) <
|
|
2047
|
+
if (m[4] != null && _alphaNum(m[4]) < minAlpha) continue
|
|
1993
2048
|
const L = m[1].endsWith('%') ? parseFloat(m[1]) / 100 : parseFloat(m[1])
|
|
1994
2049
|
const C = m[2].endsWith('%') ? parseFloat(m[2]) * 0.004 : parseFloat(m[2]) // 百分比口径:100% = 0.4
|
|
1995
2050
|
const hex = _oklchToHex(L, C, m[3] == null ? 0 : m[3])
|
|
@@ -2019,6 +2074,42 @@ const HEAD_SIZE_MIN = 24
|
|
|
2019
2074
|
const GRID_MIN_SAMPLES = 4 // 少于 4 个间距值不下结论 —— 一两个 16px 说明不了这是不是网格
|
|
2020
2075
|
const GRID_RATIO = 0.7
|
|
2021
2076
|
const SPACE_PROP = /^(margin|padding)(-(top|right|bottom|left))?$/
|
|
2077
|
+
// 色板只认 alpha ≥ 0.5 的色:`rgba(255,255,255,.1)` 那类是描边与叠层,不该算进色板。
|
|
2078
|
+
// 记进频次的话,深色站的白色描边会当选「次要文字色」(实测 sintbot.com:次要色判成纯白)。
|
|
2079
|
+
const PALETTE_MIN_ALPHA = 0.5
|
|
2080
|
+
// 变量名在说「这是页面的底色 / 文字色」:**以 bg / background / text / ink 收尾**,且不带组件或状态词。
|
|
2081
|
+
// 只要名字里带 background 就算的话,`--globalnav-background-opened-dark` 这种导航栏夜间色会被当成
|
|
2082
|
+
// 整页底色(实测 apple.com.cn:白底站被判成深色)。
|
|
2083
|
+
const ROOT_VAR_SKIP = /(dark|night|invers|invert|overlay|hover|active|focus|opened|selected|disabled|nav|header|footer|menu|button|btn|card|modal|dialog|popover|tooltip|input|code|badge|tag|chip|banner|toast)/
|
|
2084
|
+
// 命名形状:可带一段 ≤4 字母的厂商前缀(`sk-`)+ 页面级修饰词(page/body/app/color…)+ bg/text 本体 + 可选的
|
|
2085
|
+
// default/primary 一类后缀。`--first-screen-bg`(飞书首屏区块的浅蓝)这种区块名不算,否则整页底色判成浅蓝。
|
|
2086
|
+
const ROOT_VAR_WORDS = '(?:(?:color|colors|page|body|app|site|main|global|base|theme|default)-)*'
|
|
2087
|
+
const ROOT_BG_VAR = new RegExp(`^(?:[a-z0-9]{1,4}-)?${ROOT_VAR_WORDS}(?:bg|background)(?:-colou?r)?(?:-(?:body|page|base|default|primary|main))?$`)
|
|
2088
|
+
const ROOT_INK_VAR = new RegExp(`^(?:[a-z0-9]{1,4}-)?${ROOT_VAR_WORDS}(?:ink|text|fg|foreground)(?:-colou?r)?(?:-(?:body|page|base|default|primary|main|title))?$`)
|
|
2089
|
+
|
|
2090
|
+
// 圆角值 → px。rpx 按 750 设计稿折半(小程序),rem/em 按 16px 折算(Tailwind 的 rounded-lg 就是 0.5rem);
|
|
2091
|
+
// 裸数字按 px(JS 主题对象的写法)。认不出(calc()、关键字)⇒ null,当没写。
|
|
2092
|
+
function _radiusPx(v) {
|
|
2093
|
+
const m = /^(-?\d*\.?\d+)(px|rpx|rem|em)?(?![\w.])/i.exec(String(v == null ? '' : v).trim())
|
|
2094
|
+
if (!m) return null
|
|
2095
|
+
const n = parseFloat(m[1])
|
|
2096
|
+
if (!Number.isFinite(n)) return null
|
|
2097
|
+
const u = (m[2] || '').toLowerCase()
|
|
2098
|
+
return u === 'rpx' ? n / 2 : (u === 'rem' || u === 'em') ? n * 16 : n
|
|
2099
|
+
}
|
|
2100
|
+
// `@media (prefers-color-scheme: dark) { … }` 块的区间。这些块里的 html/body/:root 声明是**夜间主题**,
|
|
2101
|
+
// 不是站点的底色;不剔掉的话,一个支持深色模式的浅色站会两边各记一份、谁赢看运气。
|
|
2102
|
+
// 花括号配对是单趟扫描(每个块只扫自己那一段),不走正则回溯。
|
|
2103
|
+
function _darkMediaRanges(src) {
|
|
2104
|
+
const out = []
|
|
2105
|
+
for (const m of src.matchAll(/@media[^{};]{0,200}prefers-color-scheme\s*:\s*dark[^{};]{0,200}\{/gi)) {
|
|
2106
|
+
let depth = 1
|
|
2107
|
+
let i = m.index + m[0].length
|
|
2108
|
+
while (i < src.length && depth > 0) { const c = src.charCodeAt(i); if (c === 123) depth++; else if (c === 125) depth--; i++ }
|
|
2109
|
+
out.push([m.index, i])
|
|
2110
|
+
}
|
|
2111
|
+
return out
|
|
2112
|
+
}
|
|
2022
2113
|
|
|
2023
2114
|
// 只认写死 px 的长度(裸 0 也算 —— `margin:0` 与单位无关)。`1.5rem` 在这里是**读不出 px 数**的:
|
|
2024
2115
|
// 要折算就得先假设根字号,而那正是「不替作者假设」的反面。认不出 ⇒ 当没写。
|
|
@@ -2064,31 +2155,45 @@ function parseCssCandidates(texts, { rootWeight = 6, tick = _noTick } = {}) {
|
|
|
2064
2155
|
const statusHits = { success: new Map(), warning: new Map(), danger: new Map() }
|
|
2065
2156
|
const bump = (map, hex, w) => { const e = map.get(hex) || { hex, count: 0, weight: 0 }; e.count++; e.weight += w; map.set(hex, e) }
|
|
2066
2157
|
const bumpN = (map, k) => map.set(k, (map.get(k) || 0) + 1)
|
|
2067
|
-
|
|
2158
|
+
// 每份文本先切好规则块与夜间区间,两遍都用
|
|
2159
|
+
const docs = (Array.isArray(texts) ? texts : [texts]).map((raw) => {
|
|
2068
2160
|
tick()
|
|
2069
2161
|
const src = stripCssComments(String(raw == null ? '' : raw).slice(0, MAX_CSS_CHARS))
|
|
2070
|
-
const
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2162
|
+
const dark = _darkMediaRanges(src)
|
|
2163
|
+
return { src, rules: _cssRules(src, tick), inDark: (at) => dark.some(([a, b]) => at >= a && at < b) }
|
|
2164
|
+
})
|
|
2165
|
+
// 变量表**跨文件**收(站点常把 token 定义在一份样式表、用在另一份里),出处分档见 _rankCssVars
|
|
2166
|
+
const ranked = new Map()
|
|
2167
|
+
for (const d of docs) _rankCssVars(d.rules, d.inDark, ranked)
|
|
2168
|
+
const vars = new Map([...ranked].map(([k, o]) => [k, o.v]))
|
|
2169
|
+
for (const { src, rules, inDark } of docs) {
|
|
2170
|
+
tick()
|
|
2171
|
+
for (const hex of colorLiterals(src, { minAlpha: PALETTE_MIN_ALPHA })) bump(colors, hex, 1)
|
|
2172
|
+
for (const r of rules) {
|
|
2074
2173
|
// ⚠ 不认 `*`:`*,*::before{...}` 那类 reset 是**全局重置**,不是站点在声明自己的底色。
|
|
2075
2174
|
// 认它的话,任何引了 normalize/reset 的站点底色都会被判成 reset 里那个值(通常是继承用的
|
|
2076
2175
|
// transparent 或纯黑),而这些站点恰恰是最常见的那种。
|
|
2077
|
-
|
|
2078
|
-
|
|
2176
|
+
// `page` 是小程序的根(相当于 body):飞书风格的 `page{background:#F7F8FA}` 就写在这儿
|
|
2177
|
+
const isRoot = ROOT_SEL.test(r.sel) && !inDark(r.at)
|
|
2178
|
+
for (const d of r.body.split(';')) {
|
|
2079
2179
|
if (isRoot) {
|
|
2080
2180
|
const mm = /^\s*(--[\w-]+|background(?:-color)?|color)\s*:\s*([\s\S]+)$/i.exec(d)
|
|
2081
2181
|
if (mm) {
|
|
2082
|
-
const found = colorLiterals(_resolveCssVars(mm[2], vars))
|
|
2182
|
+
const found = colorLiterals(_resolveCssVars(mm[2], vars), { minAlpha: PALETTE_MIN_ALPHA })
|
|
2083
2183
|
for (const hex of found) bump(colors, hex, rootWeight)
|
|
2084
2184
|
const prop = mm[1].toLowerCase()
|
|
2085
2185
|
if (/^background/.test(prop)) for (const hex of found) bump(bgHits, hex, rootWeight)
|
|
2086
2186
|
else if (prop === 'color') for (const hex of found) bump(inkHits, hex, rootWeight)
|
|
2087
2187
|
else if (prop.startsWith('--')) {
|
|
2088
2188
|
// 变量名本身就是一句声明:`--bg` / `--page-background` = 作者在说「这是底色」,
|
|
2089
|
-
//
|
|
2090
|
-
|
|
2091
|
-
|
|
2189
|
+
// 比任何亮度启发式都硬。它被用在哪儿由上面两条各自记账,这里只认名字(口径见 ROOT_BG_VAR)。
|
|
2190
|
+
// 只算一票(权重 1):打包进来的组件库也会在 :root 上定义 `--color-text-primary` 一类变量
|
|
2191
|
+
// (飞书实测:markdown 组件的 #222),html/body 上真写的那条声明(权重 rootWeight)必须压得过它们。
|
|
2192
|
+
const vn = prop.slice(2)
|
|
2193
|
+
if (!ROOT_VAR_SKIP.test(vn)) {
|
|
2194
|
+
if (ROOT_BG_VAR.test(vn)) for (const hex of found) bump(bgHits, hex, 1)
|
|
2195
|
+
else if (ROOT_INK_VAR.test(vn)) for (const hex of found) bump(inkHits, hex, 1)
|
|
2196
|
+
}
|
|
2092
2197
|
}
|
|
2093
2198
|
}
|
|
2094
2199
|
}
|
|
@@ -2128,8 +2233,10 @@ function parseCssCandidates(texts, { rootWeight = 6, tick = _noTick } = {}) {
|
|
|
2128
2233
|
// CSS `font-family: 'Inter', sans-serif;` 值一直到分号/花括号,引号本来就不成对,整串照收
|
|
2129
2234
|
// JS `fontFamily: "Inter, sans-serif",` 值到闭合引号为止,再往后是同一个对象的下一个键
|
|
2130
2235
|
// 合成一条的话必有一种被切错(实测:JS 那条会把后面的 colors 段一起吞进字体名)。
|
|
2131
|
-
|
|
2132
|
-
|
|
2236
|
+
// 图标字体(iconfont / Font Awesome / Material Icons)不是这套设计的文字字体 —— B 站实测排第一的是 iconfont
|
|
2237
|
+
const addFont = (stack) => { const s = String(stack || '').trim(); if (s && !/icon|awesome|glyph/i.test(s.split(',')[0])) fonts.set(s, (fonts.get(s) || 0) + 1) }
|
|
2238
|
+
// 值先展 var():`font-family: var(--f-display)` 不展开,证据里就只剩一个变量名,衬线也认不出来
|
|
2239
|
+
for (const m of src.matchAll(/font-family\s*:\s*([^;}\n]{2,220})/gi)) addFont(_resolveCssVars(m[1], vars).replace(/[,;]\s*$/, ''))
|
|
2133
2240
|
for (const m of src.matchAll(/["']?fontFamily["']?\s*:\s*(?:"([^"\n]{1,200})"|'([^'\n]{1,200})')/g)) addFont(m[1] != null ? m[1] : m[2])
|
|
2134
2241
|
// ── 渐变 / 投影 / 状态色(v0.11.22)。都用**有界**量词、不用懒量词 —— 见 stripCssComments
|
|
2135
2242
|
// 那段:这条管线一次吃 200 份文件,一个 O(N²) 的正则就能把同进程的 daemon 冻死。
|
|
@@ -2162,10 +2269,10 @@ function parseCssCandidates(texts, { rootWeight = 6, tick = _noTick } = {}) {
|
|
|
2162
2269
|
if (slot) bumpN(statusHits[slot], hex)
|
|
2163
2270
|
}
|
|
2164
2271
|
for (const m of src.matchAll(/border-?radius\s*[:=]\s*([^;}\n]{1,60})/gi)) {
|
|
2165
|
-
const v = m[1].trim().replace(/^["']|["'],?$/g, '').trim() // JS 写法的值带引号,先脱壳再判
|
|
2272
|
+
const v = _resolveCssVars(m[1].trim().replace(/^["']|["'],?$/g, '').trim(), vars).trim() // JS 写法的值带引号,先脱壳再判
|
|
2166
2273
|
// 百分比是**正圆**语义(头像、徽章),不是胶囊按钮 —— 混进来会把任何有头像的站点判成胶囊风
|
|
2167
2274
|
if (/%/.test(v)) continue
|
|
2168
|
-
const n =
|
|
2275
|
+
const n = _radiusPx(v)
|
|
2169
2276
|
if (n != null && n >= 0) radii.push(n)
|
|
2170
2277
|
}
|
|
2171
2278
|
}
|
|
@@ -2263,8 +2370,8 @@ function describeExtract({ dark = null, serif = false, mono = false, radius = nu
|
|
|
2263
2370
|
if (mono) parts.push('等宽小字')
|
|
2264
2371
|
if (pill) parts.push('胶囊圆角')
|
|
2265
2372
|
else if (radius != null) parts.push('圆角 ' + (Math.round(radius * 100) / 100) + 'px')
|
|
2266
|
-
|
|
2267
|
-
if (
|
|
2373
|
+
if (bg) parts.push('底色 ' + bg)
|
|
2374
|
+
if (accent && accent !== bg) parts.push('强调色 ' + accent)
|
|
2268
2375
|
if (!parts.length) return ''
|
|
2269
2376
|
const o = _oneline(origin, 60)
|
|
2270
2377
|
return parts.join(' · ') + (o ? '(提自 ' + o + ')' : '')
|
|
@@ -2310,10 +2417,15 @@ function extractFormFromTexts(texts, { name = '', description = '', origin = '',
|
|
|
2310
2417
|
const claimed = new Set(Object.values(named))
|
|
2311
2418
|
let ci = 0
|
|
2312
2419
|
for (const c of top) { if (claimed.has(c.hex)) continue; named['c' + (++ci)] = c.hex }
|
|
2313
|
-
const roles = Object.keys(named).length ? styleRoles(named) : null
|
|
2420
|
+
const roles = Object.keys(named).length ? styleRoles(named, { fromExtract: true }) : null
|
|
2314
2421
|
const fontsJoined = cand.fonts.map((f) => f.stack).join(' , ')
|
|
2315
2422
|
const serif = /serif/i.test(fontsJoined.replace(/sans[-\s]?serif/gi, ''))
|
|
2316
|
-
const mono = /mono/i.test(fontsJoined)
|
|
2423
|
+
const mono = /mono/i.test(fontsJoined) // 证据:样式里出现过等宽族(事实,照实报)
|
|
2424
|
+
// 描述句里的「等宽小字」是在说这套设计的性格,要按字体声明的**占比**判 —— 几乎每个站都给 code
|
|
2425
|
+
// 配了等宽族,出现过就算的话人人都是「等宽小字」。首个族是等宽的声明占到四分之一、且不止一处才算。
|
|
2426
|
+
const monoN = cand.fonts.filter((f) => /mono|courier|consolas|menlo/i.test(String(f.stack).split(',')[0])).reduce((n, f) => n + f.count, 0)
|
|
2427
|
+
const fontN = cand.fonts.reduce((n, f) => n + f.count, 0)
|
|
2428
|
+
const monoStyle = monoN >= 2 && monoN / fontN >= 0.25
|
|
2317
2429
|
const rounded = _roundedFromRadii(cand.radii)
|
|
2318
2430
|
const form = normalizeStyleForm({
|
|
2319
2431
|
name,
|
|
@@ -2344,12 +2456,14 @@ function extractFormFromTexts(texts, { name = '', description = '', origin = '',
|
|
|
2344
2456
|
mono,
|
|
2345
2457
|
rounded: form.rounded,
|
|
2346
2458
|
roundedFound: !!rounded,
|
|
2459
|
+
// 一个颜色都没读到 ⇒ 表单里的四个色是模板默认值,不是提炼结果;前端据此不去动表单
|
|
2460
|
+
colorsFound: !!roles,
|
|
2347
2461
|
},
|
|
2348
2462
|
suggest: {
|
|
2349
2463
|
description: describeExtract({
|
|
2350
2464
|
dark: roles ? roles.dark : null,
|
|
2351
2465
|
serif,
|
|
2352
|
-
mono,
|
|
2466
|
+
mono: monoStyle,
|
|
2353
2467
|
pill: rounded === 'pill',
|
|
2354
2468
|
radius: normalRadii.length ? Math.max(...normalRadii) : null,
|
|
2355
2469
|
bg: roles ? roles.bg : null,
|
|
@@ -2365,8 +2479,10 @@ function extractFormFromTexts(texts, { name = '', description = '', origin = '',
|
|
|
2365
2479
|
}
|
|
2366
2480
|
}
|
|
2367
2481
|
|
|
2368
|
-
// HTML → 内联 <style>
|
|
2369
|
-
//
|
|
2482
|
+
// HTML → 内联 <style> 与 <link rel=stylesheet>(含 `rel=preload as=style`)。**跨域名的也跟**:
|
|
2483
|
+
// 大多数站点的样式表放在 CDN 域名上(飞书 feishucdn、B 站 hdslb、Stripe stripecdn),只认同源时
|
|
2484
|
+
// 这些站一份都读不到(实测 6 个站 3 个报「没找到样式表」)。安全不靠同源:每一跳照样过公网围栏
|
|
2485
|
+
// 与解析结果检查(_fetchGuarded),份数与字节都封顶。同域名的排在前面,名额先给站点自己的。
|
|
2370
2486
|
//
|
|
2371
2487
|
// ⚠ 两处定位都**手写 indexOf,不用正则**(与 stripCssComments 同款,同一个道理)。
|
|
2372
2488
|
// `<style[^>]*>([\s\S]*?)<\/style>` 与 `<link\b[^>]*>` 都是二次方的:前者每遇到一个未闭合的
|
|
@@ -2399,7 +2515,7 @@ function parseHtmlStyleRefs(html, baseUrl, { max = EXTRACT_MAX_SHEETS, tick = _n
|
|
|
2399
2515
|
let j = 0
|
|
2400
2516
|
for (;;) {
|
|
2401
2517
|
tick()
|
|
2402
|
-
if (links.length
|
|
2518
|
+
if (links.length >= max * 3) break // 先多收几条候选,排完序再截到 max
|
|
2403
2519
|
const a = low.indexOf('<link', j)
|
|
2404
2520
|
if (a < 0) break
|
|
2405
2521
|
const gt = low.indexOf('>', a + 5)
|
|
@@ -2411,17 +2527,25 @@ function parseHtmlStyleRefs(html, baseUrl, { max = EXTRACT_MAX_SHEETS, tick = _n
|
|
|
2411
2527
|
// 二次方是同一类问题,只是尺度换成了标签)。真的 <link> 连 200 字节都到不了。
|
|
2412
2528
|
if (gt + 1 - a > LINK_TAG_MAX) continue
|
|
2413
2529
|
const tag = src.slice(a, gt + 1)
|
|
2414
|
-
|
|
2530
|
+
const isSheet = /rel\s*=\s*["']?[^"'>]*stylesheet/i.test(tag)
|
|
2531
|
+
const isPreload = /rel\s*=\s*["']?preload\b/i.test(tag) && /\bas\s*=\s*["']?style\b/i.test(tag) // 先预载再换成样式表的写法
|
|
2532
|
+
if (!isSheet && !isPreload) continue
|
|
2415
2533
|
const hm = /href\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(tag)
|
|
2416
2534
|
const href = hm && (hm[2] != null ? hm[2] : hm[3] != null ? hm[3] : hm[4])
|
|
2417
2535
|
if (!href) continue
|
|
2418
2536
|
let abs = null
|
|
2419
2537
|
try { abs = base ? new URL(href, base) : new URL(href) } catch { continue }
|
|
2420
2538
|
if (abs.protocol !== 'http:' && abs.protocol !== 'https:') continue
|
|
2421
|
-
|
|
2539
|
+
abs.hash = ''
|
|
2422
2540
|
if (!links.includes(abs.href)) links.push(abs.href)
|
|
2423
2541
|
}
|
|
2424
|
-
|
|
2542
|
+
// 内联与外链各自封顶:内联不花网络,只是 HTML 里的一段;合在一起算的话,一个塞满小块 <style> 的
|
|
2543
|
+
// 服务端渲染页会把外链名额吃光,真正定调的那几份样式表一份都不拉。
|
|
2544
|
+
const host = base ? base.host : ''
|
|
2545
|
+
const site = (h) => h.split('.').slice(-2).join('.') // 粗粒度「同一站点」:www.ex.com 与 static.ex.com 算一家
|
|
2546
|
+
const rank = (href) => { const h = new URL(href).host; return h === host ? 0 : site(h) === site(host) ? 1 : 2 }
|
|
2547
|
+
const ordered = links.map((href, i) => ({ href, i, r: rank(href) })).sort((a, b) => a.r - b.r || a.i - b.i).map((x) => x.href)
|
|
2548
|
+
return { inline, links: ordered.slice(0, max) }
|
|
2425
2549
|
}
|
|
2426
2550
|
|
|
2427
2551
|
// 读响应体,**流式**截断。旧写法 `(await res.text()).slice(0, max)` 的上限写了等于没写:
|
|
@@ -2429,14 +2553,18 @@ function parseHtmlStyleRefs(html, baseUrl, { max = EXTRACT_MAX_SHEETS, tick = _n
|
|
|
2429
2553
|
// 两道闸:① 有 Content-Length 且超限 ⇒ 一个字节都不读,直接拒 ② 无则边读边数,到线就停并 cancel。
|
|
2430
2554
|
async function _readCapped(res, maxBytes) {
|
|
2431
2555
|
const len = Number(res && res.headers && typeof res.headers.get === 'function' ? res.headers.get('content-length') : NaN)
|
|
2432
|
-
|
|
2556
|
+
const body = res && res.body
|
|
2557
|
+
const streamable = !!(body && typeof body.getReader === 'function')
|
|
2558
|
+
// 声明的长度超限:能边读边截(可读流,或代理那条已在传输层封顶 capped)⇒ 照常读前 maxBytes ——
|
|
2559
|
+
// 现代站点的 HTML 动辄超过 512KB(Next.js 把数据内联进页面),而要的样式表链接全在 <head> 里,
|
|
2560
|
+
// 一律拒收的话 linear.app、stripe.com 这类站一个都提炼不了。只有「既不能截、又声明超限」才拒。
|
|
2561
|
+
if (Number.isFinite(len) && len > maxBytes && !streamable && !(res && res.capped)) {
|
|
2433
2562
|
const e = new Error(`这个网址的内容太大了(超过 ${Math.round(maxBytes / 1024)} KB)—— 换一个更轻的页面`)
|
|
2434
2563
|
e.code = 'EXTRACT_GUARD'
|
|
2435
2564
|
throw e
|
|
2436
2565
|
}
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
if (!body || typeof body.getReader !== 'function') {
|
|
2566
|
+
// 注入的假 res(单测)、代理那条与个别 fetch 实现没有可读流 ⇒ 退回 text(),截断口径不变
|
|
2567
|
+
if (!streamable) {
|
|
2440
2568
|
const t = await res.text()
|
|
2441
2569
|
return t.length > maxBytes ? t.slice(0, maxBytes) : t
|
|
2442
2570
|
}
|
|
@@ -2507,12 +2635,33 @@ function assertPublicUrl(u) {
|
|
|
2507
2635
|
throw e
|
|
2508
2636
|
}
|
|
2509
2637
|
|
|
2638
|
+
// 域名的解析结果也要是公网地址:跟跨域名样式表之后,页面能指定任意域名,而一个公网域名完全可以
|
|
2639
|
+
// 解析到 192.168.x / 127.0.0.1(DNS 重绑定)。解析不了(墙内本机解析不出海外域名,走代理时很常见)
|
|
2640
|
+
// 或超时 ⇒ 放行,交给后面的连接去报错 —— 这道检查只拒「明确解析到内网」的。
|
|
2641
|
+
// ⚠ Clash 的 fake-ip 模式下本机解析回的是 198.18.x(基准测试段,不在内网判定里)⇒ 放行,检查等于失效;
|
|
2642
|
+
// 这是代理模式的固有边界,不是漏判。lookup 为空(单测注入了 fetch)⇒ 不解析。
|
|
2643
|
+
const EXTRACT_DNS_MS = 3000
|
|
2644
|
+
const _dnsLookup = (host) => require('node:dns').promises.lookup(host, { all: true, verbatim: true })
|
|
2645
|
+
async function _assertResolvesPublic(hostname, lookup) {
|
|
2646
|
+
const h = String(hostname || '').replace(/^\[|\]$/g, '')
|
|
2647
|
+
if (!lookup || !h || require('node:net').isIP(h)) return // IP 字面量 isPublicHostname 已经判过
|
|
2648
|
+
let timer
|
|
2649
|
+
const addrs = await Promise.race([
|
|
2650
|
+
Promise.resolve().then(() => lookup(h)).catch(() => null),
|
|
2651
|
+
new Promise((r) => { timer = setTimeout(() => r(null), EXTRACT_DNS_MS); if (timer.unref) timer.unref() }),
|
|
2652
|
+
]).finally(() => clearTimeout(timer))
|
|
2653
|
+
for (const a of Array.isArray(addrs) ? addrs : []) {
|
|
2654
|
+
if (a && a.address && !isPublicHostname(a.address)) { const e = new Error(SSRF_MSG); e.code = 'EXTRACT_GUARD'; throw e }
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2510
2658
|
// 手动跟跳。**每一跳都重过同一套校验** —— 交给 fetch 的 redirect:'follow' 就等于只查了第一跳,
|
|
2511
2659
|
// 而「公网地址 301 到 127.0.0.1」是绕过首跳校验最省事的一招。
|
|
2512
|
-
async function _fetchGuarded(doFetch, href, init, { maxHops = EXTRACT_MAX_HOPS } = {}) {
|
|
2660
|
+
async function _fetchGuarded(doFetch, href, init, { maxHops = EXTRACT_MAX_HOPS, lookup = null } = {}) {
|
|
2513
2661
|
let cur = new URL(href)
|
|
2514
2662
|
for (let hop = 0; ; hop++) {
|
|
2515
2663
|
assertPublicUrl(cur)
|
|
2664
|
+
await _assertResolvesPublic(cur.hostname, lookup)
|
|
2516
2665
|
const res = await doFetch(cur.href, { ...init, redirect: 'manual' })
|
|
2517
2666
|
const status = res && res.status
|
|
2518
2667
|
const loc = res && res.headers && typeof res.headers.get === 'function' ? res.headers.get('location') : null
|
|
@@ -2529,8 +2678,10 @@ async function _fetchGuarded(doFetch, href, init, { maxHops = EXTRACT_MAX_HOPS }
|
|
|
2529
2678
|
}
|
|
2530
2679
|
}
|
|
2531
2680
|
|
|
2532
|
-
async function extractFromUrl(ref, { fetchImpl, timeoutMs = EXTRACT_TIMEOUT_MS, maxSheets = EXTRACT_MAX_SHEETS, maxBytes = MAX_CSS_CHARS, wallMs = EXTRACT_WALL_MS } = {}) {
|
|
2533
|
-
|
|
2681
|
+
async function extractFromUrl(ref, { fetchImpl, lookup, timeoutMs = EXTRACT_TIMEOUT_MS, maxSheets = EXTRACT_MAX_SHEETS, maxBytes = MAX_CSS_CHARS, wallMs = EXTRACT_WALL_MS } = {}) {
|
|
2682
|
+
// 配了代理就走代理(见本段段头);注入了 fetch(单测)就用注入的,也不做真解析
|
|
2683
|
+
const doFetch = fetchImpl || require('./core/proxy').getProxyFetch() || globalThis.fetch
|
|
2684
|
+
const dns = lookup !== undefined ? lookup : (fetchImpl ? null : _dnsLookup)
|
|
2534
2685
|
if (typeof doFetch !== 'function') throw new Error('这台机器上取不到网页(运行环境没有 fetch)')
|
|
2535
2686
|
let u
|
|
2536
2687
|
try { u = new URL(String(ref)) } catch { throw new Error('这不是一个网址:' + String(ref)) }
|
|
@@ -2539,7 +2690,7 @@ async function extractFromUrl(ref, { fetchImpl, timeoutMs = EXTRACT_TIMEOUT_MS,
|
|
|
2539
2690
|
const ctl = new AbortController()
|
|
2540
2691
|
const timer = setTimeout(() => ctl.abort(), timeoutMs)
|
|
2541
2692
|
try {
|
|
2542
|
-
const first = await _fetchGuarded(doFetch, u.href, { signal: ctl.signal, headers: { Accept: 'text/html,text/css,*/*' } })
|
|
2693
|
+
const first = await _fetchGuarded(doFetch, u.href, { signal: ctl.signal, maxBytes, headers: { Accept: 'text/html,text/css,*/*' } }, { lookup: dns })
|
|
2543
2694
|
const res = first.res
|
|
2544
2695
|
if (!res || !res.ok) throw new Error(`打不开这个网址(HTTP ${res ? res.status : '?'})`)
|
|
2545
2696
|
const html = await _readCapped(res, maxBytes)
|
|
@@ -2553,10 +2704,13 @@ async function extractFromUrl(ref, { fetchImpl, timeoutMs = EXTRACT_TIMEOUT_MS,
|
|
|
2553
2704
|
for (const href of refs.links) {
|
|
2554
2705
|
tick()
|
|
2555
2706
|
try {
|
|
2556
|
-
const r2 = await _fetchGuarded(doFetch, href, { signal: ctl.signal, headers: { Accept: 'text/css,*/*' } })
|
|
2707
|
+
const r2 = await _fetchGuarded(doFetch, href, { signal: ctl.signal, maxBytes, headers: { Accept: 'text/css,*/*' } }, { lookup: dns })
|
|
2557
2708
|
if (r2.res && r2.res.ok) { texts.push(await _readCapped(r2.res, maxBytes)); fetched++ }
|
|
2558
2709
|
} catch { /* 单份样式表拉不到就少一份,不让整次提炼失败 */ }
|
|
2559
2710
|
}
|
|
2711
|
+
// 两种「一份都没有」要分开说:页面引用了样式表却一份都没取到(网络 / 代理 / 对端拒绝),
|
|
2712
|
+
// 与页面里压根没有样式表(要跑脚本才出内容)—— 前者让人去查网络,后者让人换页面,说反了两头误事。
|
|
2713
|
+
if (!texts.length && refs.links.length) throw new Error(`页面引用了 ${refs.links.length} 份样式表,一份都没取到 —— 多半是这台机器连不上它们所在的域名(检查一下代理),或把网址发到群里让它去看`)
|
|
2560
2714
|
if (!texts.length) throw new Error('这个页面里没找到样式表 —— 多半是要跑脚本才出内容的站;换一个静态些的页面,或把网址发到群里让它慢慢看')
|
|
2561
2715
|
const r = extractFormFromTexts(texts, { name: u.hostname, origin: u.hostname, tick })
|
|
2562
2716
|
r.evidence.source = 'url'
|
|
@@ -2566,12 +2720,12 @@ async function extractFromUrl(ref, { fetchImpl, timeoutMs = EXTRACT_TIMEOUT_MS,
|
|
|
2566
2720
|
} catch (e) {
|
|
2567
2721
|
if (e && (e.name === 'AbortError' || e.name === 'TimeoutError')) throw new Error(`抓这个网址超时了(${Math.round(timeoutMs / 1000)} 秒)—— 换一个更轻的页面,或把网址发到群里让它慢慢看`)
|
|
2568
2722
|
if (e && e.code === 'EXTRACT_GUARD') throw e // 围栏与墙钟的话已经是人话,别被下面的兜底改写
|
|
2569
|
-
if (e && /^(
|
|
2723
|
+
if (e && /^(打不开|这个页面里|页面引用了|这不是一个网址|只认)/.test(e.message || '')) throw e
|
|
2570
2724
|
// `fetch failed` 是 Node 对一切网络层失败的统一说法(DNS 解析不了、连不上、证书不对都是它),
|
|
2571
2725
|
// 原样抛出等于把英文内部报错甩给 operator —— 翻成「可能是哪几种情况」才有下一步可做。
|
|
2572
2726
|
const raw = (e && e.message) || String(e)
|
|
2573
2727
|
if (/fetch failed|ENOTFOUND|ECONNREFUSED|ECONNRESET|EAI_AGAIN|certificate|TLS/i.test(raw)) {
|
|
2574
|
-
throw new Error('连不上这个网址 ——
|
|
2728
|
+
throw new Error('连不上这个网址 —— 可能是域名解析不了、站点拒绝连接,或这台机器访问它需要代理(配置里没配,或代理没开)。把网址发到群里让它去看是另一条路')
|
|
2575
2729
|
}
|
|
2576
2730
|
throw new Error('抓不到这个网址:' + raw)
|
|
2577
2731
|
} finally { clearTimeout(timer) }
|
package/src/platform/slack.js
CHANGED
|
@@ -492,7 +492,7 @@ function createSlackApi(token, { log = () => {}, fetchImpl = null, sleepImpl = s
|
|
|
492
492
|
if (!j || typeof j !== 'object') throw new SlackError('invalid_response', method, { status: res.status })
|
|
493
493
|
if (!j.ok) throw new SlackError(j.error || 'unknown_error', method, j)
|
|
494
494
|
const warn = j.warning || (j.response_metadata && j.response_metadata.warnings)
|
|
495
|
-
if (warn) log(`⚠ slack ${method} 有 warning(可能是静默失败,见 https://github.com/sintlab/sintbot/blob/main/docs/SLACK.md「静默失败」): ${JSON.stringify(warn).slice(0, 200)}`)
|
|
495
|
+
if (warn) log(`⚠ slack ${method} 有 warning(可能是静默失败,见 https://github.com/sintlab/sintbot/blob/main/docs/zh-CN/SLACK.md「静默失败」): ${JSON.stringify(warn).slice(0, 200)}`)
|
|
496
496
|
return j
|
|
497
497
|
}
|
|
498
498
|
}
|