@chance722/dsh-inbox 0.2.4 → 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 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 (a Bilibili video, a WeChat article…), 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 |
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
 
@@ -173,6 +174,14 @@ pnpm test # vitest
173
174
 
174
175
  Client-side changes need `pnpm build` and a page reload (dsh's client-hmr reloads it for you); host-side changes need a restart. Details in [docs/help/dev-setup.md](docs/help/dev-setup.md); milestones and acceptance records in the [development bus](docs/feature/dev-bus.md).
175
176
 
177
+ To switch the profile you actually run between the **published package** and **this checkout** (default profile `web`; set `DSH_PROFILE` for another):
178
+
179
+ ```powershell
180
+ pnpm dev:status # which one is in use right now
181
+ pnpm dev:npm # point it at the published version
182
+ pnpm dev:local # point it back here (builds first, then links)
183
+ ```
184
+
176
185
  ## License
177
186
 
178
187
  MIT
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
 
@@ -172,6 +173,14 @@ pnpm test # vitest
172
173
 
173
174
  改客户端代码:`pnpm build` 后刷新页面即可(dsh 的 client-hmr 会自己重载);改宿主代码要重启服务。细节见 [docs/help/dev-setup.md](docs/help/dev-setup.md),阶段与验收记录在[开发总线](docs/feature/dev-bus.md)。
174
175
 
176
+ 日常想在**线上发布版**和**本地改动**之间切换(默认 profile 是 `web`,换 profile 加 `DSH_PROFILE`):
177
+
178
+ ```powershell
179
+ pnpm dev:status # 现在用的是哪一个:本仓库 / 线上包
180
+ pnpm dev:npm # 切到 npm 上发布的版本(体验发布版)
181
+ pnpm dev:local # 切回当前仓库(会先 pnpm build 再链接)
182
+ ```
183
+
175
184
  ## 许可
176
185
 
177
186
  MIT
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",
@@ -868,11 +861,14 @@ window.__ModuleLoader__.load({
868
861
  "sync.pullFailed": "\u62C9\u53D6\u5931\u8D25\uFF1A{reason}",
869
862
  // The refresh button's tooltip: counts first, reasons only when something was
870
863
  // skipped. The toast carries the two numbers alone.
871
- "sync.detailPushedIdle": "\u63A8\u9001 0 \u6761\uFF08{count} \u6761\u5DF2\u662F\u6700\u65B0\uFF09",
864
+ // No number here on purpose: the push considers every row it has, tombstones
865
+ // included, so "26 条已是最新" next to a header saying "共 13 条" is a
866
+ // mismatch the reader has to resolve for themselves (2026-09-21).
867
+ "sync.detailPushedIdle": "\u63A8\u9001 0 \u6761\uFF08\u6CA1\u6709\u65B0\u6539\u52A8\uFF09",
872
868
  "sync.detailPushed": "\u63A8\u9001 {records} \u6761 / {attachments} \u4E2A\u9644\u4EF6",
873
869
  "sync.detailPulled": "\u62C9\u53D6 {count} \u6761 \xB7 \u4E91\u7AEF {records} \u6761\u8BB0\u5F55 / {files} \u4E2A\u9644\u4EF6",
874
870
  "sync.detailAdded": "\uFF08\u5176\u4E2D {count} \u6761\u662F\u65B0\u8BB0\u5F55\uFF09",
875
- "sync.detailSkip": "\uFF08\u8DF3\u8FC7 {skipped}\uFF1A\u81EA\u5DF1\u7684 {sync}\u3001\u66F4\u65E7 {older}\uFF09",
871
+ "sync.detailDeleted": "\uFF08\u5176\u4E2D {count} \u6761\u662F\u5220\u9664\uFF0C\u4F1A\u8FDB\u56DE\u6536\u7AD9\uFF09",
876
872
  "sync.pullForeignSync": " \xB7 \u26A0\uFE0F \u53E6\u6709 {records} \u6761\u8BB0\u5F55\u5728\u522B\u7684\u540C\u6B65\u76EE\u5F55\uFF1A{roots}",
877
873
  "sync.detailForeign": " \xB7 \u26A0\uFE0F \u522B\u7684\u540C\u6B65\u76EE\u5F55\uFF1A{roots}\uFF08{records} \u6761\u8BB0\u5F55\uFF1B\u672C\u673A {ours}\uFF09",
878
874
  // The toast: two numbers, nothing else. Everything else about a sync is
@@ -996,11 +992,6 @@ window.__ModuleLoader__.load({
996
992
  "source.rule.hint": "the local rules judged this from the shape of the link, text or image \u2014 no network was used",
997
993
  "source.model.hint": "the rules could not decide, so the model did; what you say always wins and can be changed any time",
998
994
  "source.user.hint": "the category you chose yourself \u2014 neither the rules nor the model will overwrite it",
999
- "link.noTitle": "that page has no title (some sites answer non-browser requests with an empty shell \u2014 WeChat does)",
1000
- "link.http": "the other side answered HTTP {status}",
1001
- "link.notHtml": "that address is not a web page",
1002
- "link.unreachable": "the request did not go through (no network, or refused)",
1003
- "link.unknown": "no clear reason",
1004
995
  "app.settings": "Settings",
1005
996
  "app.manual": "Manual",
1006
997
  "app.counts": " \xB7 {total} total \xB7 {watch} to read \xB7 {deleted} in the bin",
@@ -1053,7 +1044,6 @@ window.__ModuleLoader__.load({
1053
1044
  "detail.removeTag": "Remove \u201C{tag}\u201D from this record",
1054
1045
  "detail.updated": " \xB7 updated {when}",
1055
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).",
1056
- "detail.titleMissed": "No page title was fetched: {reason}. You can give it a name yourself.",
1057
1047
  "detail.play": "Play",
1058
1048
  "detail.zoom": "Zoom in",
1059
1049
  "detail.playInBrowser": "Play in the browser",
@@ -1137,11 +1127,11 @@ window.__ModuleLoader__.load({
1137
1127
  "sync.pushPartial": "{head} \xB7 partly failed: {reason}",
1138
1128
  "sync.pullNoAddress": "No address configured yet",
1139
1129
  "sync.pullFailed": "Pull failed: {reason}",
1140
- "sync.detailPushedIdle": "pushed 0 ({count} already up to date)",
1130
+ "sync.detailPushedIdle": "pushed 0 (nothing new)",
1141
1131
  "sync.detailPushed": "pushed {records} / {attachments} attachments",
1142
1132
  "sync.detailPulled": "pulled {count} \xB7 the cloud holds {records} records / {files} attachments",
1143
1133
  "sync.detailAdded": " ({count} of them new)",
1144
- "sync.detailSkip": " ({skipped} skipped: {sync} ours, {older} older)",
1134
+ "sync.detailDeleted": " ({count} of them deletions \u2014 they land in the recycle bin)",
1145
1135
  "sync.pullForeignSync": " \xB7 \u26A0\uFE0F {records} records live in another sync directory: {roots}",
1146
1136
  "sync.detailForeign": " \xB7 \u26A0\uFE0F another sync directory: {roots} ({records} records; ours is {ours})",
1147
1137
  "sync.shortPushed": "pushed {count}",
@@ -2022,15 +2012,6 @@ window.__ModuleLoader__.load({
2022
2012
  var ACCENT_COLOR = "#6e9ef7";
2023
2013
  var MODEL_COLOR = "#a78bfa";
2024
2014
  var CONTROL_HEIGHT = "calc(1.6em + 12px)";
2025
- function titleFailureText(code) {
2026
- if (code === "no-title") {
2027
- return t("link.noTitle");
2028
- }
2029
- if (code.startsWith("http:")) return t("link.http", { status: code.slice("http:".length) });
2030
- if (code.startsWith("not-html:")) return t("link.notHtml");
2031
- if (code.startsWith("network:")) return t("link.unreachable");
2032
- return t("link.unknown");
2033
- }
2034
2015
  var paneRowStyle = { flex: "none" };
2035
2016
  var selectStyle = {
2036
2017
  ...actionStyle,
@@ -3316,12 +3297,8 @@ window.__ModuleLoader__.load({
3316
3297
  files: result.remoteAttachments ?? 0
3317
3298
  });
3318
3299
  const gained = (result.added ?? 0) === 0 ? "" : t("sync.detailAdded", { count: result.added ?? 0 });
3319
- const skipped = result.skipped === 0 ? "" : t("sync.detailSkip", {
3320
- skipped: result.skipped,
3321
- sync: result.skippedSync ?? 0,
3322
- older: result.skippedOlder ?? 0
3323
- });
3324
- return `${head}${gained}${skipped}${warningsOf(result, true)}`;
3300
+ const deleted = (result.deletions ?? 0) === 0 ? "" : t("sync.detailDeleted", { count: result.deletions ?? 0 });
3301
+ return `${head}${gained}${deleted}${warningsOf(result, true)}`;
3325
3302
  }
3326
3303
  function EncryptionSettings({ call }) {
3327
3304
  const [status, setStatus] = import_react8.default.useState();
@@ -4324,7 +4301,6 @@ window.__ModuleLoader__.load({
4324
4301
  style: { ...paneRowStyle, ...inputStyle, width: "100%", boxSizing: "border-box" }
4325
4302
  }
4326
4303
  ),
4327
- 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) }) }),
4328
4304
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
4329
4305
  SelectBox,
4330
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.4" : "0.0.0-dev";
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
- ["bilibili.com", "bilibili"],
299
- ["b23.tv", "bilibili"],
300
- ["mp.weixin.qq.com", "wechat"],
301
- ["weixin.qq.com", "wechat"],
302
- ["zhihu.com", "zhihu"],
303
- ["xiaohongshu.com", "xiaohongshu"],
304
- ["xhslink.com", "xiaohongshu"],
305
- ["maimai.cn", "maimai"],
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
- ["youtube.com", "youtube"],
308
- ["youtu.be", "youtube"],
309
- ["x.com", "twitter"],
310
- ["twitter.com", "twitter"]
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 platformOf(url) {
403
+ function ruleFor(url) {
337
404
  const host = hostOf(url);
338
405
  if (host === void 0) return void 0;
339
- for (const [suffix, platform] of PLATFORMS) {
340
- if (host === suffix || host.endsWith(`.${suffix}`)) return platform;
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 isMedia(host, path) {
345
- if (/bilibili\.com$|b23\.tv$|youtube\.com$|youtu\.be$/.test(host)) {
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 isArticle(host, path) {
351
- if (/mp\.weixin\.qq\.com$|weixin\.qq\.com$/.test(host)) return true;
352
- if (/zhihu\.com$/.test(host)) return true;
353
- if (/xiaohongshu\.com$|xhslink\.com$/.test(host)) return true;
354
- return /\/article\/|\/post\/|\/blog\/|\/read\//.test(path);
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 platform = platformOf(url);
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 (isMedia(host, path)) {
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 (isArticle(host, path)) {
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) return absorb(vault, existing, { note });
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) {
@@ -1404,13 +1493,14 @@ async function mergeOnce(vault, tree, prefix, admit) {
1404
1493
  const failures = [];
1405
1494
  let merged = 0;
1406
1495
  let added = 0;
1496
+ let deletions = 0;
1407
1497
  let kept = 0;
1408
1498
  let attachments = 0;
1409
1499
  let objects;
1410
1500
  try {
1411
1501
  objects = await tree.list(`${prefix}/items/`);
1412
1502
  } catch (error) {
1413
- return { merged: 0, added: 0, kept: 0, attachments: 0, failures: [`\u5217\u8FDC\u7AEF\u540C\u6B65\u76EE\u5F55\u5931\u8D25\uFF1A${reasonOf(error)}`] };
1503
+ return { merged: 0, added: 0, deletions: 0, kept: 0, attachments: 0, failures: [`\u5217\u8FDC\u7AEF\u540C\u6B65\u76EE\u5F55\u5931\u8D25\uFF1A${reasonOf(error)}`] };
1414
1504
  }
1415
1505
  const wanted = /* @__PURE__ */ new Map();
1416
1506
  const bytesAt = /* @__PURE__ */ new Map();
@@ -1441,6 +1531,7 @@ async function mergeOnce(vault, tree, prefix, admit) {
1441
1531
  await vault.import(remote);
1442
1532
  merged += 1;
1443
1533
  if (local === void 0) added += 1;
1534
+ if (remote.deletedAt !== void 0) deletions += 1;
1444
1535
  for (const attachmentId of remote.attachmentIds) {
1445
1536
  if (vault.getAttachment(attachmentId) === void 0) {
1446
1537
  wanted.set(attachmentId, remote.id);
@@ -1475,7 +1566,7 @@ async function mergeOnce(vault, tree, prefix, admit) {
1475
1566
  failures.push(`\u9644\u4EF6 ${attachmentId}\uFF1A${reasonOf(error)}`);
1476
1567
  }
1477
1568
  }
1478
- return { merged, added, kept, attachments, failures };
1569
+ return { merged, added, deletions, kept, attachments, failures };
1479
1570
  }
1480
1571
  function extensionOfName(path) {
1481
1572
  return /\.([A-Za-z0-9]{1,8})$/.exec(nameOf2(path))?.[1]?.toLowerCase() ?? "bin";
@@ -1488,7 +1579,7 @@ async function mergeRemote(ctx, vault, attachments, extraRoots = []) {
1488
1579
  const prefix = syncRoot(settings);
1489
1580
  try {
1490
1581
  const tree = settings.protocol === "s3" ? await s3Tree(ctx, settings) : await webdavTree(ctx, settings);
1491
- if (tree === void 0) return { merged: 0, added: 0, kept: 0, attachments: 0, failures: [] };
1582
+ if (tree === void 0) return { merged: 0, added: 0, deletions: 0, kept: 0, attachments: 0, failures: [] };
1492
1583
  const admit = admitWith(attachments);
1493
1584
  const outcome = await mergeOnce(vault, tree, prefix, admit);
1494
1585
  for (const root of extraRoots) {
@@ -1496,13 +1587,14 @@ async function mergeRemote(ctx, vault, attachments, extraRoots = []) {
1496
1587
  const extra = await mergeOnce(vault, tree, root, admit);
1497
1588
  outcome.merged += extra.merged;
1498
1589
  outcome.added += extra.added;
1590
+ outcome.deletions += extra.deletions;
1499
1591
  outcome.kept += extra.kept;
1500
1592
  outcome.attachments += extra.attachments;
1501
1593
  outcome.failures.push(...extra.failures);
1502
1594
  }
1503
1595
  return outcome;
1504
1596
  } catch (error) {
1505
- return { merged: 0, added: 0, kept: 0, attachments: 0, failures: [reasonOf(error)] };
1597
+ return { merged: 0, added: 0, deletions: 0, kept: 0, attachments: 0, failures: [reasonOf(error)] };
1506
1598
  }
1507
1599
  }
1508
1600
  function admitWith(store) {
@@ -1609,6 +1701,7 @@ async function withMerge(ctx, vault, attachments, pulled) {
1609
1701
  ...pulled,
1610
1702
  merged: outcome.merged,
1611
1703
  added: outcome.added,
1704
+ deletions: outcome.deletions,
1612
1705
  kept: outcome.kept,
1613
1706
  attachments: outcome.attachments,
1614
1707
  failed: pulled.failed + outcome.failures.length,
@@ -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;
@@ -186,7 +180,7 @@ export declare const zh: {
186
180
  'sync.detailPushed': string;
187
181
  'sync.detailPulled': string;
188
182
  'sync.detailAdded': string;
189
- 'sync.detailSkip': string;
183
+ 'sync.detailDeleted': string;
190
184
  'sync.pullForeignSync': string;
191
185
  'sync.detailForeign': string;
192
186
  'sync.shortPushed': 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;
@@ -455,7 +443,7 @@ export declare const MESSAGES: {
455
443
  'sync.detailPushed': string;
456
444
  'sync.detailPulled': string;
457
445
  'sync.detailAdded': string;
458
- 'sync.detailSkip': string;
446
+ 'sync.detailDeleted': string;
459
447
  'sync.pullForeignSync': string;
460
448
  'sync.detailForeign': string;
461
449
  'sync.shortPushed': 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
- * Three rules keep it honest:
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
  *
@@ -42,6 +42,15 @@ export interface SyncTree {
42
42
  export interface MergeOutcome {
43
43
  /** Records that were new here, or overwritten because the remote was newer. */
44
44
  merged: number;
45
+ /**
46
+ * How many of {@link merged} arrived as deletions.
47
+ *
48
+ * A deleted record travels as an ordinary record with deletedAt set, so a
49
+ * merge that brings deletions looks exactly like one that brings updates until
50
+ * somebody opens the recycle bin (measured 2026-09-21: 19 merged — 6 new
51
+ * records and 13 tombstones, and the bin went from empty to thirteen).
52
+ */
53
+ deletions: number;
45
54
  /**
46
55
  * How many of {@link merged} were ids this vault did not have at all.
47
56
  *
@@ -175,6 +175,8 @@ export interface PullResult {
175
175
  merged?: number;
176
176
  /** How many of {@link merged} were ids this vault did not have at all. */
177
177
  added?: number;
178
+ /** How many of {@link merged} were deletions (they land in the recycle bin). */
179
+ deletions?: number;
178
180
  /** Attachment objects the merge had to fetch and admit locally. */
179
181
  attachments?: number;
180
182
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chance722/dsh-inbox",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "private": false,
5
5
  "description": "Personal paste inbox for DeepSeek Harness (dsh): capture, classify, browse and retrieve.",
6
6
  "keywords": [
@@ -64,7 +64,10 @@
64
64
  "build": "node scripts/build.mjs && tsc -p tsconfig.build.json",
65
65
  "typecheck": "tsc --noEmit",
66
66
  "test": "vitest run",
67
- "prepublishOnly": "npm run build"
67
+ "prepublishOnly": "npm run build",
68
+ "dev:status": "node scripts/dev.mjs status",
69
+ "dev:local": "node scripts/dev.mjs local",
70
+ "dev:npm": "node scripts/dev.mjs npm"
68
71
  },
69
72
  "dependencies": {
70
73
  "lucide-react": "^1.47.0",