@chance722/dsh-inbox 0.2.5 → 0.2.6
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/README.md +3 -2
- package/README.zh.md +3 -2
- package/cordis.patch.yml +21 -0
- package/lib/client.js +0 -23
- package/lib/index.js +121 -32
- package/lib/types/client/i18n.d.ts +0 -11
- package/lib/types/client/messages.d.ts +0 -12
- package/lib/types/host/link-title.d.ts +25 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,9 +19,9 @@ It works for you alone: everything lands on your machine, and the model only see
|
|
|
19
19
|
| Feature | What it does |
|
|
20
20
|
|---|---|
|
|
21
21
|
| **Two ways in** | `/inbox <text or link>` in the composer (attach images to carry them along), or paste / drop / pick a file in the panel |
|
|
22
|
-
| **Automatic classification** | Links are filed by platform and media type (
|
|
22
|
+
| **Automatic classification** | Links are filed by platform and media type (Bilibili/YouTube videos, WeChat/掘金/Zhihu articles — 60-odd sites), text by credential shape, images get a 疑似证件 tag when they have card proportions; what the rules cannot decide goes to the model, with a daily cap |
|
|
23
23
|
| **Panel** | Filter by watch-later / category / tag, search across title, text, link and note, switch between two list densities; the detail pane edits name, category, note and tags, flags watch-later, deletes and restores; it follows dsh's dark and light themes **and its language** (switch dsh to English under 设置 → 常规 and the whole panel, dock, cards and manual come with you) |
|
|
24
|
-
| **Everything has a name** | A link is named by the headline of the page it points at (no model tokens spent), a photo or file by the name it arrived with, and the name you type always wins. The row shows the name; the glyph on the left says what kind of thing it is (a key for a credential) |
|
|
24
|
+
| **Everything has a name** | A link is named by the headline of the page it points at (no model tokens spent), a photo or file by the name it arrived with, and the name you type always wins. A page that will not be read — an anti-bot page, a dead link — leaves the address itself as the name rather than a guess. The row shows the name; the glyph on the left says what kind of thing it is (a key for a credential) |
|
|
25
25
|
| **Who judged the category** | A coloured badge next to the category: 规则判定 (grey, the local rules) / 模型判定 (violet, the capped model pass) / 手动判定 (blue, your own choice — nothing overwrites it later) |
|
|
26
26
|
| **Ask in conversation** | Ask for 收件箱 / 仓库 / inbox and the assistant searches by words, category, tag, watch-later flag or kind (ten at a time plus a count of the rest, with thumbnails right in the results), then opens one by id (text up to 1000 characters, link, note, tags, attachment facts). To look at one yourself: expand 「N 次工具调用」 above the answer and press 打开 ↗ — the 仓库 tab opens on that record |
|
|
27
27
|
| **Looking at a picture** | Image bytes stay out of the conversation by default; when you ask "look at this picture and tell me what it is", the assistant sends that one image to itself — explicitly, per call |
|
|
@@ -161,6 +161,7 @@ With a remote configured and `/inbox` as the directory:
|
|
|
161
161
|
- **Pictures**: classification does send an image to the model (a phone photo of an ID card has the same proportions as any other photo); the conversation gets an `[attachment:id]` marker by default. **The one exception**: when you explicitly ask the assistant to look at a picture, that single image is sent to it — per call, never by default.
|
|
162
162
|
- **The model cannot see your vault** unless you ask it to look, and classification requests are redacted first.
|
|
163
163
|
- **Your remote needs access control**: only credential bodies are ciphertext up there — text, links, notes and attachment bytes are in the clear, and the master password and key never sync.
|
|
164
|
+
- **What the install changes outside the panel**: reading a pasted link's headline is this plugin's only outbound request — one GET, only for links captured on this machine, never for links that arrived through sync. The profile makes it with a browser-shaped identity (`… AppleWebKit/537.36 (KHTML, like Gecko) dsh-inbox Safari/537.36`), shipped in this package's `cordis.patch.yml` as an override of `web-fetch-http.userAgent`, because sites gate on the shape of that string. That identity is the whole profile's, the model's own web tools included; **your own `cordis.patch.yml` overrides it**. Measurements and reasoning: [docs/help/link-title-fetch.md](docs/help/link-title-fetch.md).
|
|
164
165
|
|
|
165
166
|
## Development
|
|
166
167
|
|
package/README.zh.md
CHANGED
|
@@ -19,9 +19,9 @@
|
|
|
19
19
|
| 功能 | 说明 |
|
|
20
20
|
|---|---|
|
|
21
21
|
| **两个入口** | 输入框里 `/inbox <文字或链接>`(图片附在输入框上一起收),或面板里粘贴 / 拖拽 / 选择文件 |
|
|
22
|
-
| **自动分类** | 链接按平台与类型分(B
|
|
22
|
+
| **自动分类** | 链接按平台与类型分(B 站/YouTube 视频,公众号/掘金/知乎文章…共 60 多个站点)、文本按密钥形状分、图片按比例加「疑似证件」标签;规则判不出的交给模型兜底,有每日上限 |
|
|
23
23
|
| **面板** | 待看 / 类目 / 标签筛选 + 跨标题正文链接备注的搜索;两种列表密度;详情里能改名称、类目、备注、标签,能标待看,能删除与恢复;**跟着 dsh 的深色/浅色主题走,也跟着它的语言走**(在「设置 → 常规」里把 dsh 切成 English,面板、右侧 dock、对话卡片与使用手册一起变) |
|
|
24
|
-
| **每条都有名字** | 链接自动抓页面标题(不花模型 token
|
|
24
|
+
| **每条都有名字** | 链接自动抓页面标题(不花模型 token)、图片文件用原文件名;你自己起的名字永远优先。读不到的页面——对方是反爬页、或者链接已经死了——名字位就留链接本身,不猜一个。列表里显示名字本身,类型交给左侧图标(密钥就是一把钥匙) |
|
|
25
25
|
| **类目是谁判的** | 类目旁带颜色标签:规则判定(灰)/ 模型判定(紫)/ 手动判定(蓝)。你改过的类目,后续任何流程都不会覆盖 |
|
|
26
26
|
| **对话里取回** | 直接问「我的收件箱 / 仓库 / inbox 里有哪些还没看的链接」就行,助手按关键词/类目/标签/待看/类型查(最多 10 条 + 还剩几条,带图的结果直接显示缩略图),按 id 打开一条(正文最多 1000 字、链接、备注、标签、附件信息)。想直接看某一条:展开回答上方的「N 次工具调用」,记录后面的「打开 ↗」会在右侧「仓库」里停到那一条 |
|
|
27
27
|
| **看图** | 默认图片字节不进对话;你说「帮我看这张图是什么」时,助手才会把那张图发给自己看(一次性、显式要) |
|
|
@@ -160,6 +160,7 @@ rm -r ~/.dsh/profiles/<你装的 profile>
|
|
|
160
160
|
- **图片**:分类时会把图片发给模型判断(手机拍的证件照比例与普通照片无异);对话里默认只回 `[attachment:id]` 标记,字节不进对话。**唯一的例外**:你明确让助手「看这张图」时,它会把那一张发给自己看——每次都要显式要求,默认永不发。
|
|
161
161
|
- **模型看不到你的仓库**,除非你让它查(它调工具时才读得到),且分类请求先过脱敏。
|
|
162
162
|
- **云端要有访问控制**:同步上去的内容里只有账密正文是密文,其余(文本、链接、备注、附件字节)是明文;主密码与密钥从不同步。
|
|
163
|
+
- **装它会改到面板之外的一处**:抓链接标题是本插件唯一的对外请求——一次 GET,只在本机捕获的链接上发,同步拉进来的链接不抓。抓取身份是**浏览器形状**(`… AppleWebKit/537.36 (KHTML, like Gecko) dsh-inbox Safari/537.36`),由本包的 `cordis.patch.yml` 覆盖 `web-fetch-http.userAgent` 写成,因为站点按这个字符串的**形状**认客户端。这层身份是整个 profile 的,模型的 web 工具也一起用;**你自己的 `cordis.patch.yml` 覆盖得掉**。实测与理由见 [docs/help/link-title-fetch.md](docs/help/link-title-fetch.md)。
|
|
163
164
|
|
|
164
165
|
## 开发
|
|
165
166
|
|
package/cordis.patch.yml
CHANGED
|
@@ -9,3 +9,24 @@
|
|
|
9
9
|
- insert:
|
|
10
10
|
- id: dsh-inbox
|
|
11
11
|
name: '@chance722/dsh-inbox'
|
|
12
|
+
|
|
13
|
+
# The identity this profile fetches pages with, restated here because our own
|
|
14
|
+
# link-title fetch needs a browser-shaped one: sites gate on the *shape* of this
|
|
15
|
+
# string, and bilibili answers the `Mozilla/5.0 (compatible; ...)` form with an
|
|
16
|
+
# anti-bot page that carries a real-looking `<title>` (measured 2026-09-21:
|
|
17
|
+
# 20/29 refused for the compatible form, 0/15 for the one below). Details,
|
|
18
|
+
# measurements and the refusal heuristics live in docs/help/link-title-fetch.md.
|
|
19
|
+
#
|
|
20
|
+
# It still says who is asking — `dsh-inbox` sits where a browser puts
|
|
21
|
+
# `Chrome/140.0.0.0` — with no version token, because one that goes stale on every
|
|
22
|
+
# release is worse than none. A parenthetical `(+url)` or a suffix after a browser
|
|
23
|
+
# token lands in the refused bucket exactly like `(compatible; ...)` does, so
|
|
24
|
+
# there is none of either.
|
|
25
|
+
#
|
|
26
|
+
# Scope: this is the profile's fetch identity, so the model's own web tools use it
|
|
27
|
+
# too. The user's own cordis.patch.yml is applied after this layer and wins,
|
|
28
|
+
# which is how anyone picks a different identity without touching the package.
|
|
29
|
+
- id: web-fetch-http
|
|
30
|
+
name: '@deepseek-ai/dsh-web-fetch-http'
|
|
31
|
+
config:
|
|
32
|
+
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) dsh-inbox Safari/537.36'
|
package/lib/client.js
CHANGED
|
@@ -715,12 +715,6 @@ window.__ModuleLoader__.load({
|
|
|
715
715
|
"source.rule.hint": "\u672C\u5730\u89C4\u5219\u6309\u94FE\u63A5\u3001\u6587\u672C\u3001\u56FE\u7247\u7684\u5F62\u72B6\u5224\u7684\uFF0C\u6CA1\u6709\u8054\u7F51",
|
|
716
716
|
"source.model.hint": "\u89C4\u5219\u5224\u4E0D\u51FA\u6765\u624D\u4EA4\u7ED9\u6A21\u578B\u5224\u7684\uFF1B\u4F60\u8BF4\u7684\u8BDD\u6C38\u8FDC\u4F18\u5148\uFF0C\u968F\u65F6\u53EF\u4EE5\u6539",
|
|
717
717
|
"source.user.hint": "\u4F60\u81EA\u5DF1\u9009\u7684\u7C7B\u76EE\uFF0C\u89C4\u5219\u548C\u6A21\u578B\u90FD\u4E0D\u4F1A\u8986\u76D6\u5B83",
|
|
718
|
-
// Why a link has no fetched title (these reach the panel as `linkTitleError`).
|
|
719
|
-
"link.noTitle": "\u90A3\u4E2A\u9875\u9762\u91CC\u6CA1\u6709\u6807\u9898\uFF08\u6709\u4E9B\u7AD9\u70B9\u5BF9\u975E\u6D4F\u89C8\u5668\u7684\u8BF7\u6C42\u53EA\u56DE\u7A7A\u58F3\u9875\uFF0C\u6BD4\u5982\u5FAE\u4FE1\uFF09",
|
|
720
|
-
"link.http": "\u5BF9\u65B9\u8FD4\u56DE HTTP {status}",
|
|
721
|
-
"link.notHtml": "\u90A3\u4E2A\u5730\u5740\u4E0D\u662F\u7F51\u9875",
|
|
722
|
-
"link.unreachable": "\u8BF7\u6C42\u6CA1\u6210\u529F\uFF08\u7F51\u7EDC\u4E0D\u901A\u6216\u5BF9\u65B9\u62D2\u7EDD\uFF09",
|
|
723
|
-
"link.unknown": "\u539F\u56E0\u4E0D\u660E",
|
|
724
718
|
// Chrome above the list.
|
|
725
719
|
"app.settings": "\u8BBE\u7F6E",
|
|
726
720
|
"app.manual": "\u4F7F\u7528\u624B\u518C",
|
|
@@ -777,7 +771,6 @@ window.__ModuleLoader__.load({
|
|
|
777
771
|
"detail.removeTag": "\u4ECE\u8FD9\u6761\u8BB0\u5F55\u4E0A\u79FB\u9664\u300C{tag}\u300D",
|
|
778
772
|
"detail.updated": " \xB7 \u66F4\u65B0 {when}",
|
|
779
773
|
"detail.sealedNote": "\u8FD9\u6761\u8D26\u5BC6\u7684\u6B63\u6587\u662F\u5BC6\u6587\uFF0C\u73B0\u5728\u89E3\u4E0D\u5F00\u3002\u5230\u300C\u8BBE\u7F6E \u2192 \u8D26\u5BC6\u52A0\u5BC6\u300D\u89E3\u9501\uFF08\u6216\u5148\u8BBE\u4E00\u4E2A\u4E3B\u5BC6\u7801\uFF09\u5C31\u80FD\u770B\u5230\u3002",
|
|
780
|
-
"detail.titleMissed": "\u6CA1\u6293\u5230\u9875\u9762\u6807\u9898\uFF1A{reason}\u3002\u53EF\u4EE5\u81EA\u5DF1\u8D77\u4E2A\u540D\u5B57\u3002",
|
|
781
774
|
"detail.play": "\u64AD\u653E",
|
|
782
775
|
"detail.zoom": "\u653E\u5927\u67E5\u770B",
|
|
783
776
|
"detail.playInBrowser": "\u5728\u6D4F\u89C8\u5668\u91CC\u64AD\u653E",
|
|
@@ -999,11 +992,6 @@ window.__ModuleLoader__.load({
|
|
|
999
992
|
"source.rule.hint": "the local rules judged this from the shape of the link, text or image \u2014 no network was used",
|
|
1000
993
|
"source.model.hint": "the rules could not decide, so the model did; what you say always wins and can be changed any time",
|
|
1001
994
|
"source.user.hint": "the category you chose yourself \u2014 neither the rules nor the model will overwrite it",
|
|
1002
|
-
"link.noTitle": "that page has no title (some sites answer non-browser requests with an empty shell \u2014 WeChat does)",
|
|
1003
|
-
"link.http": "the other side answered HTTP {status}",
|
|
1004
|
-
"link.notHtml": "that address is not a web page",
|
|
1005
|
-
"link.unreachable": "the request did not go through (no network, or refused)",
|
|
1006
|
-
"link.unknown": "no clear reason",
|
|
1007
995
|
"app.settings": "Settings",
|
|
1008
996
|
"app.manual": "Manual",
|
|
1009
997
|
"app.counts": " \xB7 {total} total \xB7 {watch} to read \xB7 {deleted} in the bin",
|
|
@@ -1056,7 +1044,6 @@ window.__ModuleLoader__.load({
|
|
|
1056
1044
|
"detail.removeTag": "Remove \u201C{tag}\u201D from this record",
|
|
1057
1045
|
"detail.updated": " \xB7 updated {when}",
|
|
1058
1046
|
"detail.sealedNote": "This credential\u2019s body is ciphertext and cannot be read right now. Unlock it under Settings \u2192 Credential encryption (or set a master password first).",
|
|
1059
|
-
"detail.titleMissed": "No page title was fetched: {reason}. You can give it a name yourself.",
|
|
1060
1047
|
"detail.play": "Play",
|
|
1061
1048
|
"detail.zoom": "Zoom in",
|
|
1062
1049
|
"detail.playInBrowser": "Play in the browser",
|
|
@@ -2025,15 +2012,6 @@ window.__ModuleLoader__.load({
|
|
|
2025
2012
|
var ACCENT_COLOR = "#6e9ef7";
|
|
2026
2013
|
var MODEL_COLOR = "#a78bfa";
|
|
2027
2014
|
var CONTROL_HEIGHT = "calc(1.6em + 12px)";
|
|
2028
|
-
function titleFailureText(code) {
|
|
2029
|
-
if (code === "no-title") {
|
|
2030
|
-
return t("link.noTitle");
|
|
2031
|
-
}
|
|
2032
|
-
if (code.startsWith("http:")) return t("link.http", { status: code.slice("http:".length) });
|
|
2033
|
-
if (code.startsWith("not-html:")) return t("link.notHtml");
|
|
2034
|
-
if (code.startsWith("network:")) return t("link.unreachable");
|
|
2035
|
-
return t("link.unknown");
|
|
2036
|
-
}
|
|
2037
2015
|
var paneRowStyle = { flex: "none" };
|
|
2038
2016
|
var selectStyle = {
|
|
2039
2017
|
...actionStyle,
|
|
@@ -4323,7 +4301,6 @@ window.__ModuleLoader__.load({
|
|
|
4323
4301
|
style: { ...paneRowStyle, ...inputStyle, width: "100%", boxSizing: "border-box" }
|
|
4324
4302
|
}
|
|
4325
4303
|
),
|
|
4326
|
-
detail.title === void 0 && detail.linkTitle === void 0 && detail.linkTitleError !== void 0 && detail.kind === "link" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { style: { ...paneRowStyle, margin: 0, fontSize: 12, opacity: 0.6 }, children: t("detail.titleMissed", { reason: titleFailureText(detail.linkTitleError) }) }),
|
|
4327
4304
|
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
|
|
4328
4305
|
SelectBox,
|
|
4329
4306
|
{
|
package/lib/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { defineTool as defineTool2 } from "@deepseek-ai/dsh-tools";
|
|
|
3
3
|
|
|
4
4
|
// src/shared/constants.ts
|
|
5
5
|
var PACKAGE_NAME = "@chance722/dsh-inbox";
|
|
6
|
-
var VERSION = true ? "0.2.
|
|
6
|
+
var VERSION = true ? "0.2.6" : "0.0.0-dev";
|
|
7
7
|
var DEFAULT_USER_AGENT = "dsh-inbox";
|
|
8
8
|
|
|
9
9
|
// src/shared/vocabulary.ts
|
|
@@ -208,6 +208,16 @@ var ENTITIES = {
|
|
|
208
208
|
apos: "'",
|
|
209
209
|
nbsp: " "
|
|
210
210
|
};
|
|
211
|
+
var REFUSAL_MARKER = /(risk-captcha|_BiliGreyResult|cf-chl|challenge-platform|geetest)/i;
|
|
212
|
+
var REFUSAL_TITLE = /(验证码|人机验证|安全验证|环境异常|访问异常|请完成验证|正在验证|captcha|just a moment|attention required|access denied|forbidden)/i;
|
|
213
|
+
var REFUSAL_TEXT_FLOOR = 200;
|
|
214
|
+
function visibleTextLength(html) {
|
|
215
|
+
return html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<!--[\s\S]*?-->/g, "").replace(/<[^>]+>/g, "").replace(/\s+/g, "").length;
|
|
216
|
+
}
|
|
217
|
+
function looksLikeRefusal(html, title) {
|
|
218
|
+
if (visibleTextLength(html) >= REFUSAL_TEXT_FLOOR) return false;
|
|
219
|
+
return REFUSAL_MARKER.test(html) || REFUSAL_TITLE.test(title);
|
|
220
|
+
}
|
|
211
221
|
function decodeEntities(text) {
|
|
212
222
|
return text.replace(/&(#[xX]?[0-9a-fA-F]+|[a-zA-Z]+);/g, (whole, body) => {
|
|
213
223
|
if (body.startsWith("#")) {
|
|
@@ -277,6 +287,11 @@ async function fetchLinkTitle(vault, id, web, log = () => {
|
|
|
277
287
|
log(`${host}\uFF1A\u9875\u9762\u91CC\u6CA1\u6709 <title>\uFF08${String(result.body.content.length)} \u5B57\u7B26\uFF09`);
|
|
278
288
|
return void 0;
|
|
279
289
|
}
|
|
290
|
+
if (looksLikeRefusal(result.body.content, title)) {
|
|
291
|
+
await miss(vault, id, "refused-page");
|
|
292
|
+
log(`${host}\uFF1A\u6293\u5230\u7684\u662F\u62D2\u7EDD\u9875\uFF0C\u4E0D\u5F53\u540D\u5B57`);
|
|
293
|
+
return void 0;
|
|
294
|
+
}
|
|
280
295
|
const current = vault.get(id);
|
|
281
296
|
if (current === void 0 || current.title !== void 0 || current.linkTitle !== void 0) {
|
|
282
297
|
log(`${host}\uFF1A\u6293\u5230\u4E86\u6807\u9898\uFF0C\u4F46\u8BB0\u5F55\u5DF2\u7ECF\u6709\u540D\u5B57\u4E86`);
|
|
@@ -295,19 +310,71 @@ async function miss(vault, id, reason) {
|
|
|
295
310
|
|
|
296
311
|
// src/host/classify/rules.ts
|
|
297
312
|
var PLATFORMS = [
|
|
298
|
-
|
|
299
|
-
["
|
|
300
|
-
["
|
|
301
|
-
["
|
|
302
|
-
["
|
|
303
|
-
["
|
|
304
|
-
["
|
|
305
|
-
["
|
|
313
|
+
// 视频 / 音频
|
|
314
|
+
["bilibili.com", "bilibili", "media"],
|
|
315
|
+
["b23.tv", "bilibili", "media"],
|
|
316
|
+
["youtube.com", "youtube", "media"],
|
|
317
|
+
["youtu.be", "youtube", "media"],
|
|
318
|
+
["vimeo.com", "vimeo", "media"],
|
|
319
|
+
["youku.com", "youku", "media"],
|
|
320
|
+
["v.qq.com", "tencentvideo", "media"],
|
|
321
|
+
["iqiyi.com", "iqiyi", "media"],
|
|
322
|
+
["mgtv.com", "mgtv", "media"],
|
|
323
|
+
["douyin.com", "douyin", "media"],
|
|
324
|
+
["iesdouyin.com", "douyin", "media"],
|
|
325
|
+
["kuaishou.com", "kuaishou", "media"],
|
|
326
|
+
["ixigua.com", "xigua", "media"],
|
|
327
|
+
["tiktok.com", "tiktok", "media"],
|
|
328
|
+
["twitch.tv", "twitch", "media"],
|
|
329
|
+
["dailymotion.com", "dailymotion", "media"],
|
|
330
|
+
["music.163.com", "netease-music", "media"],
|
|
331
|
+
["y.qq.com", "qq-music", "media"],
|
|
332
|
+
["spotify.com", "spotify", "media"],
|
|
333
|
+
["soundcloud.com", "soundcloud", "media"],
|
|
334
|
+
["ximalaya.com", "ximalaya", "media"],
|
|
335
|
+
// 文章 / 帖子
|
|
336
|
+
["mp.weixin.qq.com", "wechat", "article"],
|
|
337
|
+
["weixin.qq.com", "wechat", "article"],
|
|
338
|
+
["zhihu.com", "zhihu", "article"],
|
|
339
|
+
["juejin.cn", "juejin", "article"],
|
|
340
|
+
["csdn.net", "csdn", "article"],
|
|
341
|
+
["cnblogs.com", "cnblogs", "article"],
|
|
342
|
+
["jianshu.com", "jianshu", "article"],
|
|
343
|
+
["segmentfault.com", "segmentfault", "article"],
|
|
344
|
+
["v2ex.com", "v2ex", "article"],
|
|
345
|
+
["sspai.com", "sspai", "article"],
|
|
346
|
+
["36kr.com", "36kr", "article"],
|
|
347
|
+
["infoq.cn", "infoq", "article"],
|
|
348
|
+
["toutiao.com", "toutiao", "article"],
|
|
349
|
+
["weibo.com", "weibo", "article"],
|
|
350
|
+
["weibo.cn", "weibo", "article"],
|
|
351
|
+
["douban.com", "douban", "article"],
|
|
352
|
+
["xiaohongshu.com", "xiaohongshu", "article"],
|
|
353
|
+
["xhslink.com", "xiaohongshu", "article"],
|
|
354
|
+
["maimai.cn", "maimai", "article"],
|
|
355
|
+
["yuque.com", "yuque", "article"],
|
|
356
|
+
["medium.com", "medium", "article"],
|
|
357
|
+
["substack.com", "substack", "article"],
|
|
358
|
+
["dev.to", "devto", "article"],
|
|
359
|
+
["news.ycombinator.com", "hackernews", "article"],
|
|
360
|
+
["reddit.com", "reddit", "article"],
|
|
361
|
+
["stackoverflow.com", "stackoverflow", "article"],
|
|
362
|
+
["arxiv.org", "arxiv", "article"],
|
|
363
|
+
["developer.mozilla.org", "mdn", "article"],
|
|
364
|
+
["x.com", "twitter", "article"],
|
|
365
|
+
["twitter.com", "twitter", "article"],
|
|
366
|
+
["instagram.com", "instagram", "article"],
|
|
367
|
+
["threads.net", "threads", "article"],
|
|
368
|
+
["bsky.app", "bluesky", "article"],
|
|
369
|
+
["t.me", "telegram", "article"],
|
|
370
|
+
["linkedin.com", "linkedin", "article"],
|
|
371
|
+
// 代码 / 包:认得出站点,说不准"文章还是视频",交给模型
|
|
306
372
|
["github.com", "github"],
|
|
307
|
-
["
|
|
308
|
-
["
|
|
309
|
-
["
|
|
310
|
-
["
|
|
373
|
+
["gitlab.com", "gitlab"],
|
|
374
|
+
["gitee.com", "gitee"],
|
|
375
|
+
["npmjs.com", "npm"],
|
|
376
|
+
["pypi.org", "pypi"],
|
|
377
|
+
["huggingface.co", "huggingface"]
|
|
311
378
|
];
|
|
312
379
|
var SECRET_PATTERNS = [
|
|
313
380
|
[/\b(secret|access)[-_]?(id|key)\b\s*[:=]/i, "\u51FA\u73B0 secretId / secretKey \u8D4B\u503C"],
|
|
@@ -333,28 +400,28 @@ function hostOf(url) {
|
|
|
333
400
|
return void 0;
|
|
334
401
|
}
|
|
335
402
|
}
|
|
336
|
-
function
|
|
403
|
+
function ruleFor(url) {
|
|
337
404
|
const host = hostOf(url);
|
|
338
405
|
if (host === void 0) return void 0;
|
|
339
|
-
for (const
|
|
340
|
-
|
|
406
|
+
for (const rule of PLATFORMS) {
|
|
407
|
+
const [suffix] = rule;
|
|
408
|
+
if (host === suffix || host.endsWith(`.${suffix}`)) return rule;
|
|
341
409
|
}
|
|
342
410
|
return void 0;
|
|
343
411
|
}
|
|
344
|
-
function
|
|
345
|
-
|
|
346
|
-
return !path.startsWith("/read/");
|
|
347
|
-
}
|
|
348
|
-
return /\/video\/|\/watch\b|\/audio\/|\/podcast/.test(path);
|
|
412
|
+
function platformOf(url) {
|
|
413
|
+
return ruleFor(url)?.[1];
|
|
349
414
|
}
|
|
350
|
-
function
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
return /\/article\/|\/post\/|\/blog\/|\/read
|
|
415
|
+
function pathSaysMedia(path) {
|
|
416
|
+
return /\/video\/|\/watch\b|\/audio\/|\/podcast|\/shorts\/|\/v_show\/|\/playlist\b/.test(path);
|
|
417
|
+
}
|
|
418
|
+
function pathSaysArticle(path) {
|
|
419
|
+
return /\/article\/|\/articles\/|\/post\/|\/posts\/|\/blog\/|\/read\/|\/story\/|\/item\b/.test(path);
|
|
355
420
|
}
|
|
356
421
|
function classifyLink(url) {
|
|
357
|
-
const
|
|
422
|
+
const rule = ruleFor(url);
|
|
423
|
+
const platform = rule?.[1];
|
|
424
|
+
const habit = rule?.[2];
|
|
358
425
|
const parsed = (() => {
|
|
359
426
|
try {
|
|
360
427
|
return new URL(url);
|
|
@@ -365,15 +432,22 @@ function classifyLink(url) {
|
|
|
365
432
|
if (parsed === void 0) {
|
|
366
433
|
return { category: "other", confidence: "unsure", reason: "\u94FE\u63A5\u89E3\u6790\u4E0D\u4E86" };
|
|
367
434
|
}
|
|
368
|
-
const host = parsed.hostname.toLowerCase().replace(/^www\./, "");
|
|
369
435
|
const path = parsed.pathname;
|
|
370
436
|
const base = platform === void 0 ? {} : { platform };
|
|
371
|
-
if (
|
|
437
|
+
if (pathSaysMedia(path)) {
|
|
372
438
|
return { ...base, category: "media", confidence: "decided", reason: "\u94FE\u63A5\u6307\u5411\u89C6\u9891/\u97F3\u9891\u9875" };
|
|
373
439
|
}
|
|
374
|
-
if (
|
|
440
|
+
if (pathSaysArticle(path)) {
|
|
375
441
|
return { ...base, category: "article", confidence: "decided", reason: "\u94FE\u63A5\u6307\u5411\u6587\u7AE0\u9875" };
|
|
376
442
|
}
|
|
443
|
+
if (habit !== void 0) {
|
|
444
|
+
return {
|
|
445
|
+
...base,
|
|
446
|
+
category: habit,
|
|
447
|
+
confidence: "decided",
|
|
448
|
+
reason: habit === "media" ? `${String(platform)} \u4E0A\u7684\u89C6\u9891/\u97F3\u9891\u9875` : `${String(platform)} \u4E0A\u7684\u6587\u7AE0\u9875`
|
|
449
|
+
};
|
|
450
|
+
}
|
|
377
451
|
if (platform !== void 0) {
|
|
378
452
|
return { ...base, category: "other", confidence: "unsure", reason: `\u8BA4\u5F97\u51FA\u5E73\u53F0\u662F ${platform}\uFF0C\u4F46\u8BF4\u4E0D\u51C6\u662F\u6587\u7AE0\u8FD8\u662F\u89C6\u9891` };
|
|
379
453
|
}
|
|
@@ -459,7 +533,17 @@ function mergedNote(current, incoming) {
|
|
|
459
533
|
async function absorb(vault, existing, incoming) {
|
|
460
534
|
const patched = await vault.patch(existing.id, {
|
|
461
535
|
note: mergedNote(existing, incoming.note),
|
|
462
|
-
...existing.title === void 0 && incoming.title !== void 0 ? { title: incoming.title } : {}
|
|
536
|
+
...existing.title === void 0 && incoming.title !== void 0 ? { title: incoming.title } : {},
|
|
537
|
+
/*
|
|
538
|
+
A repeat is the cheap chance to fill in what the rules learned since.
|
|
539
|
+
|
|
540
|
+
`platform` is derived from the host, and the table grows (掘金 was added
|
|
541
|
+
after a record for it already existed), so an old record can sit there
|
|
542
|
+
with no platform while a fresh paste of the same URL knows it. This only
|
|
543
|
+
ever *fills*: a record that already names a platform keeps it, because
|
|
544
|
+
that value may be what the user corrected by hand.
|
|
545
|
+
*/
|
|
546
|
+
...existing.platform === void 0 && incoming.platform !== void 0 ? { platform: incoming.platform } : {}
|
|
463
547
|
});
|
|
464
548
|
const restored = existing.deletedAt !== void 0;
|
|
465
549
|
const item = restored ? await vault.restore(existing.id) : patched;
|
|
@@ -493,7 +577,12 @@ async function captureText(vault, raw, source, note) {
|
|
|
493
577
|
const existing = vault.list({ includeDeleted: true, kinds: [sniffed.kind] }).find(
|
|
494
578
|
(item) => sniffed.kind === "link" ? item.url !== void 0 && normalizeLink(item.url) === normalizeLink(raw) : sealed === void 0 ? item.text === raw : item.secretDigest === sealed.secretDigest
|
|
495
579
|
);
|
|
496
|
-
if (existing !== void 0)
|
|
580
|
+
if (existing !== void 0) {
|
|
581
|
+
return absorb(vault, existing, {
|
|
582
|
+
note,
|
|
583
|
+
...platform === void 0 ? {} : { platform }
|
|
584
|
+
});
|
|
585
|
+
}
|
|
497
586
|
return { item: await vault.create(candidate), merged: false, restored: false, verdict };
|
|
498
587
|
}
|
|
499
588
|
async function captureImage(vault, attachment, source, note) {
|
|
@@ -86,17 +86,6 @@ export declare function categoryLabel(category: Category): string;
|
|
|
86
86
|
export declare function sourceLabel(source: CategorySource): string;
|
|
87
87
|
/** One sentence explaining what that badge implies. */
|
|
88
88
|
export declare function sourceHint(source: CategorySource): string;
|
|
89
|
-
/**
|
|
90
|
-
* Why a link never got a name.
|
|
91
|
-
*
|
|
92
|
-
* The host stores a machine-readable code (`http:403`, `no-title`, …) — never a
|
|
93
|
-
* sentence, and deliberately not in the host's language — so the panel can say
|
|
94
|
-
* it in the reader's.
|
|
95
|
-
*
|
|
96
|
-
* @param code - the stored `linkTitleError`, or undefined when there is none.
|
|
97
|
-
* @returns a sentence, or undefined when there is nothing to explain.
|
|
98
|
-
*/
|
|
99
|
-
export declare function titleMissReason(code: string | undefined): string | undefined;
|
|
100
89
|
/**
|
|
101
90
|
* Hook the panel's copy up to dsh's language.
|
|
102
91
|
*
|
|
@@ -41,11 +41,6 @@ export declare const zh: {
|
|
|
41
41
|
'source.rule.hint': string;
|
|
42
42
|
'source.model.hint': string;
|
|
43
43
|
'source.user.hint': string;
|
|
44
|
-
'link.noTitle': string;
|
|
45
|
-
'link.http': string;
|
|
46
|
-
'link.notHtml': string;
|
|
47
|
-
'link.unreachable': string;
|
|
48
|
-
'link.unknown': string;
|
|
49
44
|
'app.settings': string;
|
|
50
45
|
'app.manual': string;
|
|
51
46
|
'app.counts': string;
|
|
@@ -98,7 +93,6 @@ export declare const zh: {
|
|
|
98
93
|
'detail.removeTag': string;
|
|
99
94
|
'detail.updated': string;
|
|
100
95
|
'detail.sealedNote': string;
|
|
101
|
-
'detail.titleMissed': string;
|
|
102
96
|
'detail.play': string;
|
|
103
97
|
'detail.zoom': string;
|
|
104
98
|
'detail.playInBrowser': string;
|
|
@@ -310,11 +304,6 @@ export declare const MESSAGES: {
|
|
|
310
304
|
'source.rule.hint': string;
|
|
311
305
|
'source.model.hint': string;
|
|
312
306
|
'source.user.hint': string;
|
|
313
|
-
'link.noTitle': string;
|
|
314
|
-
'link.http': string;
|
|
315
|
-
'link.notHtml': string;
|
|
316
|
-
'link.unreachable': string;
|
|
317
|
-
'link.unknown': string;
|
|
318
307
|
'app.settings': string;
|
|
319
308
|
'app.manual': string;
|
|
320
309
|
'app.counts': string;
|
|
@@ -367,7 +356,6 @@ export declare const MESSAGES: {
|
|
|
367
356
|
'detail.removeTag': string;
|
|
368
357
|
'detail.updated': string;
|
|
369
358
|
'detail.sealedNote': string;
|
|
370
|
-
'detail.titleMissed': string;
|
|
371
359
|
'detail.play': string;
|
|
372
360
|
'detail.zoom': string;
|
|
373
361
|
'detail.playInBrowser': string;
|
|
@@ -8,12 +8,15 @@
|
|
|
8
8
|
* private ones, follows only same-origin redirects, and caps both time and size.
|
|
9
9
|
* It runs after the record is stored, never in front of the paste.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
11
|
+
* Four rules keep it honest:
|
|
12
12
|
* - it never writes `title`. That field is the user's own word, and the
|
|
13
13
|
* credentials rule depends on it staying that way (`AGENTS.md` 3); the fetched
|
|
14
14
|
* headline lives in `linkTitle` instead;
|
|
15
15
|
* - it writes nothing when the record already has a name, and nothing when the
|
|
16
16
|
* user renamed it while the fetch was in flight;
|
|
17
|
+
* - it does not take a name from a page that is not the page: a site can answer
|
|
18
|
+
* with an anti-bot page that carries a *real-looking* `<title>` (bilibili's is
|
|
19
|
+
* 「验证码_哔哩哔哩」), and that string names the refusal, not the link;
|
|
17
20
|
* - it never turns a failure into a problem: a dead link, a slow host or a page
|
|
18
21
|
* that is not HTML simply leaves the URL as the name, which is where this
|
|
19
22
|
* started.
|
|
@@ -34,6 +37,27 @@ export interface WebFetchSeam {
|
|
|
34
37
|
readonly url: string;
|
|
35
38
|
}, signal?: AbortSignal): Promise<WebFetchResult>;
|
|
36
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* True when this document is a refusal wearing a page's clothes.
|
|
42
|
+
*
|
|
43
|
+
* The checks before this one — a 2xx status, an HTML body, a non-empty
|
|
44
|
+
* `<title>` — all pass on bilibili's anti-bot page, which is what made a record
|
|
45
|
+
* show 「验证码」 as its name (2026-09-21). What separates that page from a real
|
|
46
|
+
* one is that it carries **no text at all**: the challenge shell is 1360 bytes
|
|
47
|
+
* of scripts and empty containers, while the page it stands in for has
|
|
48
|
+
* thousands of characters. Only then do the markers and the shape of the title
|
|
49
|
+
* decide, because the markers alone are not evidence — real bilibili pages
|
|
50
|
+
* carry the same strings.
|
|
51
|
+
*
|
|
52
|
+
* Both halves are deliberately biased towards refusing: a link whose name we
|
|
53
|
+
* skip shows its own address, which is honest and one keystroke away from being
|
|
54
|
+
* named by hand, while a refusal kept as a name is a wrong name that stays.
|
|
55
|
+
*
|
|
56
|
+
* @param html - the decoded document.
|
|
57
|
+
* @param title - the headline pulled out of it.
|
|
58
|
+
* @returns true when the page refuses to be read.
|
|
59
|
+
*/
|
|
60
|
+
export declare function looksLikeRefusal(html: string, title: string): boolean;
|
|
37
61
|
/**
|
|
38
62
|
* Pull the `<title>` out of an HTML document.
|
|
39
63
|
*
|