dsh-mishu 0.7.1 → 0.7.3
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 +31 -5
- package/lib/client.js +647 -384
- package/lib/core/ask.js +16 -0
- package/lib/core/board.js +15 -3
- package/lib/core/brief.js +16 -0
- package/lib/core/index.js +3 -0
- package/lib/core/watch/fetch.js +169 -0
- package/lib/core/watch/source.js +116 -0
- package/lib/core/watch/sync.js +171 -0
- package/lib/dsh/api.js +92 -2
- package/lib/dsh/runtime.js +20 -2
- package/lib/types/core/ask.d.ts +5 -0
- package/lib/types/core/board.d.ts +12 -1
- package/lib/types/core/brief.d.ts +5 -0
- package/lib/types/core/index.d.ts +3 -0
- package/lib/types/core/watch/fetch.d.ts +50 -0
- package/lib/types/core/watch/source.d.ts +68 -0
- package/lib/types/core/watch/sync.d.ts +51 -0
- package/lib/types/dsh/runtime.d.ts +6 -1
- package/package.json +1 -1
package/lib/core/ask.js
CHANGED
|
@@ -16,6 +16,7 @@ import { mailReportKey } from "./mail/sync.js";
|
|
|
16
16
|
import { isActive } from "./matters.js";
|
|
17
17
|
import { CATEGORY_LABELS, describeRepeat, occurrence, remindDay } from "./recur.js";
|
|
18
18
|
import { dayOf, formatMoment } from "./time.js";
|
|
19
|
+
import { watchById, watchReportKey } from "./watch/source.js";
|
|
19
20
|
/** The tools the model can use in that chat, so it can look further and act only with your OK. */
|
|
20
21
|
function toolsHint(lang, id) {
|
|
21
22
|
return lang === 'zh'
|
|
@@ -128,5 +129,20 @@ export function buildAsk(store, request, options) {
|
|
|
128
129
|
? `我想让小秘书只读地查看我的「${preset.label.zh}」收件箱,需要开通 IMAP 并拿到客户端授权码(或应用专用密码)。请一步步教我怎么做,包括在网页上点哪里、要注意什么。\n\n小秘书给的说明:${preset.howTo.zh}\nIMAP 服务器:${preset.host || '(自己填)'}:${preset.port}${lines('\n测试连接时报错:', request.error)}`
|
|
129
130
|
: `I want my secretary to read my ${preset.label.en} inbox (read-only), which needs IMAP turned on and an app password. Walk me through it step by step, including where to click and what to watch out for.\n\nThe secretary's note: ${preset.howTo.en}\nIMAP server: ${preset.host || '(enter your own)'}:${preset.port}${lines('\nThe connection test said: ', request.error)}`;
|
|
130
131
|
}
|
|
132
|
+
case 'watch-error': {
|
|
133
|
+
const source = watchById(store, request.watchId);
|
|
134
|
+
if (source === undefined)
|
|
135
|
+
throw notFound();
|
|
136
|
+
const report = store.get(watchReportKey(source.id));
|
|
137
|
+
const error = report?.error?.[lang] ?? (zh ? '(没有记录到错误)' : '(no error recorded)');
|
|
138
|
+
return zh
|
|
139
|
+
? `我让小秘书盯着「${source.label}」这个网址,但它读不到内容,帮我弄明白原因、怎么解决:\n\n网址:${source.url}\n多久看一次:${source.intervalMinutes} 分钟\n我关注的字:${source.keywords.length === 0 ? '(没设,有任何变化就告诉我)' : source.keywords.join('、')}\n报错:${error}\n\n小秘书是怎么读的:普通 GET,20 秒超时,能读 RSS/Atom、JSON 列表或普通网页。如果这个网站要登录才能看,或者防爬,告诉我该怎么办。`
|
|
140
|
+
: `I asked my secretary to keep an eye on ${source.url}, but it cannot read it. Help me understand why and what to do:\n\nAddress: ${source.url}\nCheck every: ${source.intervalMinutes} minutes\nWords I watch for: ${source.keywords.length === 0 ? '(none — tell me about any change)' : source.keywords.join(', ')}\nError: ${error}\n\nHow it reads: a plain GET with a 20-second timeout, working on RSS/Atom, a JSON list or a plain page. If the site needs a login or blocks robots, tell me what to do instead.`;
|
|
141
|
+
}
|
|
142
|
+
case 'watch-setup': {
|
|
143
|
+
return zh
|
|
144
|
+
? '我想让小秘书帮我盯着一些我自己关心的东西:某个公告页、某个商品的价格、某个职位的招聘页、某个订阅源。请告诉我:哪些网址它读得到、哪些读不到(要登录的、全靠 JS 渲染的、防爬的),RSS 和普通网页分别会怎么处理,关键词该怎么写才能不漏也不吵,检查间隔设多长合适。'
|
|
145
|
+
: 'I want my secretary to watch a few things I care about myself: a page of announcements, the price of something, a job posting, a feed. Tell me which addresses it can read and which it cannot (login walls, JS-only pages, bot blocking), how it treats a feed versus a plain page, how to write the words I watch for so nothing is missed and nothing is noisy, and how often to check.';
|
|
146
|
+
}
|
|
131
147
|
}
|
|
132
148
|
}
|
package/lib/core/board.js
CHANGED
|
@@ -13,6 +13,7 @@ import { decode } from "./i18n.js";
|
|
|
13
13
|
import { manualExplain, storedExplain } from "./explain.js";
|
|
14
14
|
import { isActive } from "./matters.js";
|
|
15
15
|
import { mailReportKey } from "./mail/sync.js";
|
|
16
|
+
import { watchReportKey } from "./watch/source.js";
|
|
16
17
|
import { CATEGORY_LABELS, describeRepeat, occurrence, remindDay } from "./recur.js";
|
|
17
18
|
import { addDays, dayOf, dayStart, daysBetween, formatMoment, relativeDay, weekday } from "./time.js";
|
|
18
19
|
const zhOr = (lang, zh, en) => (lang === 'zh' ? zh : en);
|
|
@@ -43,7 +44,7 @@ function matterCard(store, m, today, now, lang, column) {
|
|
|
43
44
|
}
|
|
44
45
|
else {
|
|
45
46
|
const label = CATEGORY_LABELS[m.kind];
|
|
46
|
-
const where = m.source === 'mail' ? zhOr(lang, '邮件', 'Mail') : 'GitHub';
|
|
47
|
+
const where = m.source === 'mail' ? zhOr(lang, '邮件', 'Mail') : m.source === 'watch' ? zhOr(lang, '关注', 'Watching') : 'GitHub';
|
|
47
48
|
meta = manual && label !== undefined ? label[lang] : `${where} · ${reasonLabel(m.reason, lang)}`;
|
|
48
49
|
}
|
|
49
50
|
const actions = column === 'handle'
|
|
@@ -61,7 +62,8 @@ function matterCard(store, m, today, now, lang, column) {
|
|
|
61
62
|
explain.why += zhOr(lang, `你说了"明天再说",${m.snoozeUntil} 会回到"要你处理"。`, ` You put it off; it comes back on ${m.snoozeUntil}.`);
|
|
62
63
|
}
|
|
63
64
|
// The open button says what it opens ("Turn off Actions", "Open the failing log").
|
|
64
|
-
|
|
65
|
+
const source = manual ? 'manual' : m.source === 'mail' ? 'mail' : m.source === 'watch' ? 'watch' : 'github';
|
|
66
|
+
return { id: m.id, target: 'matter', source, title, meta, note: note === '' ? null : note, url: explain?.link?.url ?? m.url, tone, actions, explain };
|
|
65
67
|
}
|
|
66
68
|
export function buildBoard(store, options) {
|
|
67
69
|
const { now, lang } = options;
|
|
@@ -121,6 +123,14 @@ export function buildBoard(store, options) {
|
|
|
121
123
|
return { id: account.id, label: account.label, state: 'error', text: zhOr(lang, `${account.label}:${report.error.zh}`, `${account.label}: ${report.error.en}`) };
|
|
122
124
|
return { id: account.id, label: account.label, state: 'ok', text: zhOr(lang, `${account.label} ${formatMoment(report.at, now)} 收信`, `${account.label} checked ${formatMoment(report.at, now)}`) };
|
|
123
125
|
});
|
|
126
|
+
const watch = (options.watches ?? []).map((source) => {
|
|
127
|
+
const report = store.get(watchReportKey(source.id));
|
|
128
|
+
if (report === undefined)
|
|
129
|
+
return { id: source.id, label: source.label, state: 'never', text: zhOr(lang, `${source.label} 还没看过`, `${source.label} not checked yet`) };
|
|
130
|
+
if (report.error !== undefined)
|
|
131
|
+
return { id: source.id, label: source.label, state: 'error', text: zhOr(lang, `${source.label}:${report.error.zh}`, `${source.label}: ${report.error.en}`) };
|
|
132
|
+
return { id: source.id, label: source.label, state: 'ok', text: zhOr(lang, `${source.label} ${formatMoment(report.at, now)} 看过`, `${source.label} checked ${formatMoment(report.at, now)}`) };
|
|
133
|
+
});
|
|
124
134
|
const github = !options.github.enabled
|
|
125
135
|
? { state: 'off', text: zhOr(lang, 'GitHub 未开启', 'GitHub off') }
|
|
126
136
|
: last === undefined
|
|
@@ -143,12 +153,14 @@ export function buildBoard(store, options) {
|
|
|
143
153
|
parts.push(zhOr(lang, 'GitHub 这次没同步成。', 'The last GitHub sync failed.'));
|
|
144
154
|
for (const box of mail.filter(b => b.state === 'error'))
|
|
145
155
|
parts.push(zhOr(lang, `邮箱「${box.label}」这次没收成。`, `Mail "${box.label}" could not be checked.`));
|
|
156
|
+
for (const box of watch.filter(b => b.state === 'error'))
|
|
157
|
+
parts.push(zhOr(lang, `关注「${box.label}」这次没读成。`, `Watch "${box.label}" could not be read.`));
|
|
146
158
|
return {
|
|
147
159
|
lang,
|
|
148
160
|
at: now,
|
|
149
161
|
summary: parts.join(lang === 'zh' ? '' : ' '),
|
|
150
162
|
metrics: { handle: handle.length, overdue, dueSoon, closedToday, shadow },
|
|
151
|
-
sources: { github, manual: { series: store.activeSeries().length }, mail },
|
|
163
|
+
sources: { github, manual: { series: store.activeSeries().length }, mail, watch },
|
|
152
164
|
markRead: options.github.markRead,
|
|
153
165
|
columns: {
|
|
154
166
|
handle: handle.map(m => matterCard(store, m, today, now, lang, 'handle')),
|
package/lib/core/brief.js
CHANGED
|
@@ -10,6 +10,7 @@ import { isActive } from "./matters.js";
|
|
|
10
10
|
import { mailReportKey } from "./mail/sync.js";
|
|
11
11
|
import { CATEGORY_LABELS } from "./recur.js";
|
|
12
12
|
import { dayOf, daysBetween, formatMoment, relativeDay, weekday } from "./time.js";
|
|
13
|
+
import { watchReportKey } from "./watch/source.js";
|
|
13
14
|
export const REASON_LABELS = {
|
|
14
15
|
merged: bi('已合并', 'merged'),
|
|
15
16
|
closed: bi('已关闭', 'closed'),
|
|
@@ -43,6 +44,10 @@ export const REASON_LABELS = {
|
|
|
43
44
|
'mail-cc': bi('只是抄送', 'Cc only'),
|
|
44
45
|
'read-in-mail': bi('你在邮箱里看过了', 'read in your mail'),
|
|
45
46
|
'account-removed': bi('邮箱账户已移除', 'mail account removed'),
|
|
47
|
+
'watch-new': bi('有新内容', 'new entries'),
|
|
48
|
+
'watch-changed': bi('内容有变化', 'changed'),
|
|
49
|
+
'watch-keyword': bi('提到你关注的字', 'mentioned your words'),
|
|
50
|
+
'watch-removed': bi('你删掉了这个关注', 'watch removed'),
|
|
46
51
|
};
|
|
47
52
|
export function reasonLabel(reason, lang) {
|
|
48
53
|
if (reason === null)
|
|
@@ -75,6 +80,8 @@ function sourceTag(m, lang) {
|
|
|
75
80
|
return 'GitHub';
|
|
76
81
|
if (m.source === 'mail')
|
|
77
82
|
return pick(bi('邮件', 'Mail'), lang);
|
|
83
|
+
if (m.source === 'watch')
|
|
84
|
+
return pick(bi('关注', 'Watching'), lang);
|
|
78
85
|
return m.source;
|
|
79
86
|
}
|
|
80
87
|
/** One matter as a few lines. */
|
|
@@ -181,6 +188,15 @@ export function renderBrief(store, options) {
|
|
|
181
188
|
else
|
|
182
189
|
out.push(zh ? `邮件「${account.label}」:${formatMoment(report.at, now)} 收信,${report.messages} 封未读。` : `Mail "${account.label}": checked ${formatMoment(report.at, now)}, ${report.messages} unread.`);
|
|
183
190
|
}
|
|
191
|
+
for (const source of options.watches ?? []) {
|
|
192
|
+
const report = store.get(watchReportKey(source.id));
|
|
193
|
+
if (report === undefined)
|
|
194
|
+
out.push(zh ? `关注「${source.label}」:还没看过。` : `Watching "${source.label}": not checked yet.`);
|
|
195
|
+
else if (report.error !== undefined)
|
|
196
|
+
out.push(zh ? `关注「${source.label}」:${formatMoment(report.at, now)} 没读成:${report.error.zh}` : `Watching "${source.label}": failed at ${formatMoment(report.at, now)}: ${report.error.en}`);
|
|
197
|
+
else
|
|
198
|
+
out.push(zh ? `关注「${source.label}」:${formatMoment(report.at, now)} 看过,${report.items} 条内容。` : `Watching "${source.label}": checked ${formatMoment(report.at, now)}, ${report.items} entries.`);
|
|
199
|
+
}
|
|
184
200
|
return out.join('\n');
|
|
185
201
|
}
|
|
186
202
|
/** Recently closed matters with reasons, so a wrong closure can be reopened. */
|
package/lib/core/index.js
CHANGED
|
@@ -29,6 +29,9 @@ export { DEFAULT_NOTIFY, deliver, inQuiet, localClock, notifySettings, notifyTic
|
|
|
29
29
|
export { restartPlan, scheduleRestart } from "./restart.js";
|
|
30
30
|
export { FileSecrets, KeychainOrFile, MemorySecrets, secretEnvName, secretFromEnv, systemSecrets } from "./secrets.js";
|
|
31
31
|
export { deleteMailAccount, effectiveSettings, mailAccounts, saveMailAccount, saveSettings } from "./settings.js";
|
|
32
|
+
export { deleteWatch, dueWatches, entryKey, parseKeywords, resolveWatch, saveWatch, watchById, watchReportKey, watchSources, WATCH_SOURCE, } from "./watch/source.js";
|
|
33
|
+
export { describeWatchError, httpFetch, parseFeed, parseJson, parsePage, plainText, readWatchBody } from "./watch/fetch.js";
|
|
34
|
+
export { describeKeywords, describeWatch, diffItems, judgeWatch, syncWatch } from "./watch/sync.js";
|
|
32
35
|
export const DEFAULT_HOME = '~/.mishu';
|
|
33
36
|
/** `~` and `~/…` expand to the home directory on every platform. */
|
|
34
37
|
export function expandHome(path) {
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a watched address: a feed, a JSON list, or just a page.
|
|
3
|
+
*
|
|
4
|
+
* Whatever comes back is turned into a handful of items — a title, a bit of
|
|
5
|
+
* text and a stable key — so the rest of the code never has to care whether
|
|
6
|
+
* it was RSS, Atom, JSON or plain HTML. No parser library: the shapes that
|
|
7
|
+
* matter here are small, and a wrong guess costs nothing (the page is read
|
|
8
|
+
* as text and still works).
|
|
9
|
+
*/
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import { bi } from "../i18n.js";
|
|
12
|
+
import { entryKey } from "./source.js";
|
|
13
|
+
/** The real fetch: a plain GET with a short timeout and no cookies. */
|
|
14
|
+
export const httpFetch = async (url) => {
|
|
15
|
+
const controller = new AbortController();
|
|
16
|
+
const timer = setTimeout(() => controller.abort(), 20_000);
|
|
17
|
+
try {
|
|
18
|
+
const response = await fetch(url, {
|
|
19
|
+
method: 'GET',
|
|
20
|
+
redirect: 'follow',
|
|
21
|
+
signal: controller.signal,
|
|
22
|
+
headers: {
|
|
23
|
+
'user-agent': 'dsh-mishu/1 (+https://github.com/AIcivilization/dsh-mishu)',
|
|
24
|
+
accept: 'application/rss+xml, application/atom+xml, application/json, text/html;q=0.9, */*;q=0.8',
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
const body = await response.text();
|
|
28
|
+
return { status: response.status, body, contentType: response.headers.get('content-type') ?? '' };
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
|
|
35
|
+
function unescape(text) {
|
|
36
|
+
return text
|
|
37
|
+
.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (whole, name) => {
|
|
38
|
+
if (name.startsWith('#')) {
|
|
39
|
+
const code = name[1]?.toLowerCase() === 'x' ? Number.parseInt(name.slice(2), 16) : Number.parseInt(name.slice(1), 10);
|
|
40
|
+
return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : whole;
|
|
41
|
+
}
|
|
42
|
+
return ENTITIES[name.toLowerCase()] ?? whole;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/** Markup out, whitespace collapsed. */
|
|
46
|
+
export function plainText(markup) {
|
|
47
|
+
return unescape(markup
|
|
48
|
+
.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, ' ')
|
|
49
|
+
.replace(/<[^>]+>/g, ' '))
|
|
50
|
+
.replace(/\s+/g, ' ')
|
|
51
|
+
.trim();
|
|
52
|
+
}
|
|
53
|
+
function tag(block, name) {
|
|
54
|
+
const match = new RegExp(`<${name}\\b[^>]*>([\\s\\S]*?)</${name}>`, 'i').exec(block);
|
|
55
|
+
if (match === null)
|
|
56
|
+
return undefined;
|
|
57
|
+
const inner = match[1];
|
|
58
|
+
const cdata = /^\s*<!\[CDATA\[([\s\S]*?)\]\]>\s*$/.exec(inner);
|
|
59
|
+
return plainText(cdata === null ? inner : cdata[1]);
|
|
60
|
+
}
|
|
61
|
+
function attr(block, name) {
|
|
62
|
+
const match = new RegExp(`\\b${name}\\s*=\\s*["']([^"']*)["']`, 'i').exec(block);
|
|
63
|
+
return match === null ? undefined : unescape(match[1]);
|
|
64
|
+
}
|
|
65
|
+
/** RSS `<item>` and Atom `<entry>` blocks, whichever the document has. */
|
|
66
|
+
export function parseFeed(body, now, base) {
|
|
67
|
+
const blocks = [...body.matchAll(/<(item|entry)\b[\s\S]*?<\/\1>/gi)].map(m => m[0]);
|
|
68
|
+
if (blocks.length === 0)
|
|
69
|
+
return undefined;
|
|
70
|
+
return blocks.map((block) => {
|
|
71
|
+
const title = tag(block, 'title') ?? '';
|
|
72
|
+
const link = tag(block, 'link') ?? (() => {
|
|
73
|
+
const href = attr(block, 'href');
|
|
74
|
+
return href === undefined ? undefined : href;
|
|
75
|
+
})();
|
|
76
|
+
const bodyText = tag(block, 'description') ?? tag(block, 'summary') ?? tag(block, 'content') ?? '';
|
|
77
|
+
const guid = tag(block, 'guid') ?? tag(block, 'id');
|
|
78
|
+
const when = tag(block, 'pubDate') ?? tag(block, 'updated') ?? tag(block, 'published') ?? tag(block, 'dc:date');
|
|
79
|
+
const at = when === undefined ? now : Date.parse(when);
|
|
80
|
+
const href = link === undefined ? null : absolute(link, base);
|
|
81
|
+
return {
|
|
82
|
+
key: entryKey({ id: guid ?? href ?? undefined, link: href ?? undefined, title }),
|
|
83
|
+
title: title === '' ? (href ?? '?') : title,
|
|
84
|
+
text: bodyText,
|
|
85
|
+
url: href,
|
|
86
|
+
at: Number.isFinite(at) ? at : now,
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
function absolute(link, base) {
|
|
91
|
+
try {
|
|
92
|
+
return new URL(link, base).toString();
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return link;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* A JSON body: an array of objects, or an object with one array inside. Each
|
|
100
|
+
* element's title-like and text-like fields become an item.
|
|
101
|
+
*/
|
|
102
|
+
export function parseJson(body, now, base) {
|
|
103
|
+
let value;
|
|
104
|
+
try {
|
|
105
|
+
value = JSON.parse(body);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
const list = Array.isArray(value)
|
|
111
|
+
? value
|
|
112
|
+
: Object.values(value).find(Array.isArray);
|
|
113
|
+
if (list === undefined || list.length === 0)
|
|
114
|
+
return undefined;
|
|
115
|
+
const str = (item, names) => {
|
|
116
|
+
for (const name of names) {
|
|
117
|
+
const found = item[name];
|
|
118
|
+
if (typeof found === 'string' && found.trim() !== '')
|
|
119
|
+
return found;
|
|
120
|
+
if (typeof found === 'number')
|
|
121
|
+
return String(found);
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
};
|
|
125
|
+
return list.slice(0, 60).map((raw) => {
|
|
126
|
+
if (typeof raw !== 'object' || raw === null)
|
|
127
|
+
return { key: entryKey({ title: String(raw) }), title: String(raw), text: '', url: null, at: now };
|
|
128
|
+
const item = raw;
|
|
129
|
+
const title = str(item, ['title', 'name', 'subject', 'headline', 'version', 'label']) ?? JSON.stringify(item).slice(0, 120);
|
|
130
|
+
const text = str(item, ['description', 'body', 'content', 'summary', 'text', 'excerpt', 'details']) ?? '';
|
|
131
|
+
const link = str(item, ['url', 'link', 'html_url', 'permalink', 'href']);
|
|
132
|
+
const when = str(item, ['date', 'published', 'updated', 'pubDate', 'created_at', 'time', 'timestamp']);
|
|
133
|
+
const at = when === undefined ? now : Date.parse(when);
|
|
134
|
+
const href = link === undefined ? null : absolute(link, base);
|
|
135
|
+
return {
|
|
136
|
+
key: entryKey({ id: str(item, ['id', 'guid', 'uuid', 'key']) ?? href ?? undefined, link: href ?? undefined, title }),
|
|
137
|
+
title, text, url: href, at: Number.isFinite(at) ? at : now,
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
/** A page with no feed: its title and text, as one item keyed by a hash of the text. */
|
|
142
|
+
export function parsePage(body, now, base) {
|
|
143
|
+
const title = plainText(/<title\b[^>]*>([\s\S]*?)<\/title>/i.exec(body)?.[1] ?? '') || new URL(base).hostname;
|
|
144
|
+
const text = plainText(/<body\b[^>]*>([\s\S]*?)<\/body>/i.exec(body)?.[1] ?? body).slice(0, 20_000);
|
|
145
|
+
const key = createHash('sha256').update(text).digest('hex').slice(0, 16);
|
|
146
|
+
return [{ key, title, text, url: base, at: now }];
|
|
147
|
+
}
|
|
148
|
+
/** Whatever the address returned, as items. */
|
|
149
|
+
export function readWatchBody(options) {
|
|
150
|
+
const { body, contentType, url, now } = options;
|
|
151
|
+
const looksJson = /json/i.test(contentType) || /^\s*[[{]/.test(body);
|
|
152
|
+
const looksFeed = /xml|rss|atom/i.test(contentType) || /<(rss|feed)\b/i.test(body);
|
|
153
|
+
if (looksFeed || (!looksJson && /<(item|entry)\b/i.test(body)))
|
|
154
|
+
return parseFeed(body, now, url) ?? parsePage(body, now, url);
|
|
155
|
+
if (looksJson)
|
|
156
|
+
return parseJson(body, now, url) ?? parsePage(body, now, url);
|
|
157
|
+
return parsePage(body, now, url);
|
|
158
|
+
}
|
|
159
|
+
/** The sentence shown when a watch cannot be read. */
|
|
160
|
+
export function describeWatchError(error, source) {
|
|
161
|
+
const raw = error.message ?? String(error);
|
|
162
|
+
if (/abort/i.test(raw))
|
|
163
|
+
return bi(`${source.label} 20 秒没有回应,可能是网站太慢或打不开。`, `${source.label} did not answer within 20 seconds; the site may be slow or down.`);
|
|
164
|
+
if (/ENOTFOUND|EAI_AGAIN|getaddrinfo/i.test(raw))
|
|
165
|
+
return bi(`${source.label} 的网址找不到服务器:检查一下网址有没有写错。`, `The server for ${source.label} was not found: check the address.`);
|
|
166
|
+
if (/certificate|self.signed|SSL|TLS/i.test(raw))
|
|
167
|
+
return bi(`${source.label} 的证书有问题,读不了。`, `${source.label} has a certificate problem and could not be read.`);
|
|
168
|
+
return bi(`读 ${source.label} 失败:${raw}`, `Could not read ${source.label}: ${raw}`);
|
|
169
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Things you watch yourself: a page, a feed or an API you care about.
|
|
3
|
+
*
|
|
4
|
+
* GitHub and mail are sources the secretary knows how to read. Everything
|
|
5
|
+
* else a person cares about — a game's patch notes, a job posting, a price,
|
|
6
|
+
* a status page — is a URL. You add it here with the words that matter to
|
|
7
|
+
* you, and the secretary looks at it now and then: when it changes, or when
|
|
8
|
+
* one of your words shows up, it becomes a card like anything else.
|
|
9
|
+
*
|
|
10
|
+
* Only where and what to look for is kept here. What was seen last lives in
|
|
11
|
+
* the notebook's key-value table, so an entry never grows without bound.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import { bi, MishuError } from "../i18n.js";
|
|
15
|
+
export const WATCH_SOURCE = 'watch';
|
|
16
|
+
const SOURCES_KEY = 'watch.sources';
|
|
17
|
+
const NEXT_ID_KEY = 'watch.nextId';
|
|
18
|
+
export function watchSources(store) {
|
|
19
|
+
return store.get(SOURCES_KEY) ?? [];
|
|
20
|
+
}
|
|
21
|
+
export function watchById(store, id) {
|
|
22
|
+
return watchSources(store).find(s => s.id === id);
|
|
23
|
+
}
|
|
24
|
+
export function watchReportKey(id) {
|
|
25
|
+
return `watch.lastSync.${id}`;
|
|
26
|
+
}
|
|
27
|
+
/** Where one watch keeps what it has already seen. */
|
|
28
|
+
export function watchStateKey(id) {
|
|
29
|
+
return `watch.state.${id}`;
|
|
30
|
+
}
|
|
31
|
+
export function watchSeenKey(id) {
|
|
32
|
+
return `watch.seen.${id}`;
|
|
33
|
+
}
|
|
34
|
+
/** A stable id for a feed entry: its guid, else its link, else its title. */
|
|
35
|
+
export function entryKey(entry) {
|
|
36
|
+
const basis = entry.id ?? entry.link ?? entry.title ?? '';
|
|
37
|
+
return createHash('sha256').update(basis).digest('hex').slice(0, 16);
|
|
38
|
+
}
|
|
39
|
+
/** Split what you typed into words: commas, 、, spaces or new lines all work. */
|
|
40
|
+
export function parseKeywords(value) {
|
|
41
|
+
const list = Array.isArray(value) ? value : String(value ?? '').split(/[,,、;\n]+/);
|
|
42
|
+
const words = list.map(w => w.trim().toLowerCase()).filter(w => w !== '');
|
|
43
|
+
return [...new Set(words)].slice(0, 20);
|
|
44
|
+
}
|
|
45
|
+
/** Check and complete what the settings page sent; throws a sentence a person can act on. */
|
|
46
|
+
export function resolveWatch(input, id) {
|
|
47
|
+
const raw = String(input.url ?? '').trim();
|
|
48
|
+
if (raw === '')
|
|
49
|
+
throw new MishuError(bi('还没填网址', 'the address is empty'));
|
|
50
|
+
let url;
|
|
51
|
+
try {
|
|
52
|
+
url = new URL(raw);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
throw new MishuError(bi(`网址看不懂:${raw}。要完整的一串,比如 https://example.com/news`, `that is not a web address: ${raw}. It should look like https://example.com/news`));
|
|
56
|
+
}
|
|
57
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
58
|
+
throw new MishuError(bi('只支持 http 和 https 的网址', 'only http and https addresses work'));
|
|
59
|
+
}
|
|
60
|
+
const label = String(input.label ?? '').trim() || url.hostname;
|
|
61
|
+
if (label.length > 60)
|
|
62
|
+
throw new MishuError(bi('名字太长了,60 个字以内', 'the name is too long; keep it under 60 characters'));
|
|
63
|
+
const intervalMinutes = Number(input.intervalMinutes ?? 60);
|
|
64
|
+
if (!Number.isInteger(intervalMinutes) || intervalMinutes < 15 || intervalMinutes > 1440) {
|
|
65
|
+
throw new MishuError(bi('多久看一次要是 15 到 1440 分钟', 'how often to look must be 15 to 1440 minutes'));
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
id,
|
|
69
|
+
label,
|
|
70
|
+
url: url.toString(),
|
|
71
|
+
keywords: parseKeywords(input.keywords),
|
|
72
|
+
enabled: input.enabled !== false,
|
|
73
|
+
intervalMinutes,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/** Add (no id) or update one watch. */
|
|
77
|
+
export function saveWatch(store, input, now) {
|
|
78
|
+
const sources = watchSources(store);
|
|
79
|
+
const existing = input.id === undefined ? undefined : sources.find(s => s.id === input.id);
|
|
80
|
+
if (input.id !== undefined && existing === undefined)
|
|
81
|
+
throw new MishuError(bi(`没有 #${input.id} 这个关注`, `there is no watch #${input.id}`));
|
|
82
|
+
const id = existing?.id ?? (store.get(NEXT_ID_KEY) ?? 1);
|
|
83
|
+
const source = resolveWatch({ ...existing, ...input }, id);
|
|
84
|
+
store.set(SOURCES_KEY, existing === undefined ? [...sources, source] : sources.map(s => (s.id === id ? source : s)));
|
|
85
|
+
if (existing === undefined)
|
|
86
|
+
store.set(NEXT_ID_KEY, id + 1);
|
|
87
|
+
store.log(now, existing === undefined ? 'watch-add' : 'watch-update', { id, url: source.url, keywords: source.keywords.length });
|
|
88
|
+
return source;
|
|
89
|
+
}
|
|
90
|
+
/** Remove one watch and close whatever it still has open. */
|
|
91
|
+
export function deleteWatch(store, id, now) {
|
|
92
|
+
const sources = watchSources(store);
|
|
93
|
+
if (!sources.some(s => s.id === id))
|
|
94
|
+
return false;
|
|
95
|
+
store.transaction(() => {
|
|
96
|
+
store.set(SOURCES_KEY, sources.filter(s => s.id !== id));
|
|
97
|
+
store.set(watchStateKey(id), null);
|
|
98
|
+
store.set(watchSeenKey(id), null);
|
|
99
|
+
for (const matter of store.mattersOfSource(WATCH_SOURCE)) {
|
|
100
|
+
if (matter.status === 'open' && (matter.key === `watch:${id}` || matter.key.startsWith(`watch:${id}:`))) {
|
|
101
|
+
store.updateMatter(matter.id, { status: 'done', reason: 'watch-removed', closedBy: 'source', closedAt: now }, now);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
store.log(now, 'watch-delete', { id });
|
|
105
|
+
});
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
/** The watches whose next check is due. */
|
|
109
|
+
export function dueWatches(store, now) {
|
|
110
|
+
return watchSources(store).filter((source) => {
|
|
111
|
+
if (!source.enabled)
|
|
112
|
+
return false;
|
|
113
|
+
const report = store.get(watchReportKey(source.id));
|
|
114
|
+
return report === undefined || now - report.at >= source.intervalMinutes * 60_000;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One watch, one check: read the address, compare with what was seen last
|
|
3
|
+
* time, and turn anything new into a card.
|
|
4
|
+
*
|
|
5
|
+
* A card per watch, not per entry: "补丁公告 · 3 条新内容" is what you want to
|
|
6
|
+
* see, not three cards that say the same thing. Marking it done closes it;
|
|
7
|
+
* the next change opens it again with what changed.
|
|
8
|
+
*
|
|
9
|
+
* The first check only writes down what is there now — otherwise adding a
|
|
10
|
+
* feed would hand you every entry it ever published. Words you asked to watch
|
|
11
|
+
* for are the exception: those are shown right away, because you asked.
|
|
12
|
+
*/
|
|
13
|
+
import { applyVerdict } from "../fold.js";
|
|
14
|
+
import { bi, MishuError } from "../i18n.js";
|
|
15
|
+
import { shortDay } from "../time.js";
|
|
16
|
+
import { describeWatchError, readWatchBody } from "./fetch.js";
|
|
17
|
+
import { WATCH_SOURCE, watchReportKey, watchSeenKey, watchStateKey } from "./source.js";
|
|
18
|
+
/** How many entry keys one watch remembers. A feed longer than this is fine; the tail ages out. */
|
|
19
|
+
const SEEN_LIMIT = 500;
|
|
20
|
+
function readState(store, id) {
|
|
21
|
+
return store.get(watchStateKey(id)) ?? { seen: [], primed: false };
|
|
22
|
+
}
|
|
23
|
+
/** Which items are new since last time, and whether the whole page changed. */
|
|
24
|
+
export function diffItems(items, state) {
|
|
25
|
+
const seen = new Set(state.seen);
|
|
26
|
+
const fresh = items.filter(item => !seen.has(item.key));
|
|
27
|
+
// A single-item read is a page: its key is a hash of the text, so "new" means "the text changed".
|
|
28
|
+
const digest = items.length === 1 ? items[0].key : items.map(i => i.key).sort().join(',');
|
|
29
|
+
return { fresh, changed: state.digest !== undefined && state.digest !== digest, digest };
|
|
30
|
+
}
|
|
31
|
+
function matches(item, keywords) {
|
|
32
|
+
if (keywords.length === 0)
|
|
33
|
+
return undefined;
|
|
34
|
+
const haystack = `${item.title}\n${item.text}`.toLowerCase();
|
|
35
|
+
return keywords.find(word => haystack.includes(word));
|
|
36
|
+
}
|
|
37
|
+
function quote(items, lang) {
|
|
38
|
+
const shown = items.slice(0, 3).map(i => `「${i.title.slice(0, 80)}」`);
|
|
39
|
+
const more = items.length - shown.length;
|
|
40
|
+
if (lang === 'zh')
|
|
41
|
+
return more > 0 ? `${shown.join('、')} 等 ${items.length} 条` : shown.join('、');
|
|
42
|
+
return more > 0 ? `${shown.join(', ')} and ${more} more` : shown.join(', ');
|
|
43
|
+
}
|
|
44
|
+
export function judgeWatch(options) {
|
|
45
|
+
const { source, items, fresh, changed, first, now } = options;
|
|
46
|
+
const hit = (list) => list
|
|
47
|
+
.map(item => ({ item, word: matches(item, source.keywords) }))
|
|
48
|
+
.filter((x) => x.word !== undefined);
|
|
49
|
+
const freshHits = hit(fresh);
|
|
50
|
+
const allHits = hit(items);
|
|
51
|
+
const keywordMode = source.keywords.length > 0;
|
|
52
|
+
let reason;
|
|
53
|
+
let relevant;
|
|
54
|
+
let word;
|
|
55
|
+
if (keywordMode) {
|
|
56
|
+
// A word you watch for is worth a card even on the very first check.
|
|
57
|
+
relevant = (first ? allHits : freshHits).map(x => x.item);
|
|
58
|
+
if (relevant.length === 0)
|
|
59
|
+
return undefined;
|
|
60
|
+
word = (first ? allHits : freshHits)[0].word;
|
|
61
|
+
reason = 'watch-keyword';
|
|
62
|
+
}
|
|
63
|
+
else if (first) {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
else if (fresh.length > 0) {
|
|
67
|
+
relevant = fresh;
|
|
68
|
+
reason = items.length > 1 ? 'watch-new' : 'watch-changed';
|
|
69
|
+
}
|
|
70
|
+
else if (changed) {
|
|
71
|
+
relevant = items.slice(0, 1);
|
|
72
|
+
reason = 'watch-changed';
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
const when = shortDay(now);
|
|
78
|
+
const what = bi(`你关注「${source.label}」(${new URL(source.url).hostname})`, `The thing you watch: "${source.label}" (${new URL(source.url).hostname})`);
|
|
79
|
+
const why = keywordMode && word !== undefined
|
|
80
|
+
? bi(`${when} 检查时,这里有内容提到你关注的字「${word}」:${quote(relevant, 'zh')}。`, `On ${when} it mentioned "${word}", one of your words: ${quote(relevant, 'en')}.`)
|
|
81
|
+
: reason === 'watch-new'
|
|
82
|
+
? bi(`${when} 检查时出现了 ${fresh.length} 条新内容:${quote(relevant, 'zh')}。`, `On ${when} there were ${fresh.length} new entries: ${quote(relevant, 'en')}.`)
|
|
83
|
+
: bi(`${when} 检查时,${source.label} 的内容变了:${quote(relevant, 'zh')}。`, `On ${when} "${source.label}" changed: ${quote(relevant, 'en')}.`);
|
|
84
|
+
const link = relevant.find(i => i.url !== null)?.url ?? source.url;
|
|
85
|
+
return {
|
|
86
|
+
matterKey: `watch:${source.id}`,
|
|
87
|
+
kind: 'watch',
|
|
88
|
+
title: source.label,
|
|
89
|
+
url: link,
|
|
90
|
+
status: 'open',
|
|
91
|
+
reason,
|
|
92
|
+
note: bi(keywordMode ? `提到「${word}」` : reason === 'watch-new' ? `${fresh.length} 条新内容` : '内容有变化', keywordMode ? `mentions "${word}"` : reason === 'watch-new' ? `${fresh.length} new entries` : 'changed'),
|
|
93
|
+
explain: {
|
|
94
|
+
what,
|
|
95
|
+
why,
|
|
96
|
+
fix: bi('打开看一眼;处理完点"办完了"。不想再关注了,在设置里删掉这一条。', 'Open it and have a look; mark it done afterwards. To stop watching, delete it in Settings.'),
|
|
97
|
+
link: { label: bi('打开看看', 'Open it'), url: link },
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
export async function syncWatch(options) {
|
|
102
|
+
const { store, source, fetcher, now } = options;
|
|
103
|
+
const report = { at: now, id: source.id, items: 0, opened: 0, closed: 0 };
|
|
104
|
+
let body;
|
|
105
|
+
try {
|
|
106
|
+
body = await fetcher(source.url);
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
report.error = describeWatchError(error, source);
|
|
110
|
+
store.set(watchReportKey(source.id), report);
|
|
111
|
+
return report;
|
|
112
|
+
}
|
|
113
|
+
if (body.status >= 400) {
|
|
114
|
+
report.error = body.status === 404
|
|
115
|
+
? bi(`${source.label} 打不开了(404):这个网址可能已经变了。`, `${source.label} is gone (404): the address may have moved.`)
|
|
116
|
+
: bi(`${source.label} 返回 ${body.status},读不到内容。`, `${source.label} answered ${body.status}, so there was nothing to read.`);
|
|
117
|
+
store.set(watchReportKey(source.id), report);
|
|
118
|
+
return report;
|
|
119
|
+
}
|
|
120
|
+
if (body.body.trim() === '') {
|
|
121
|
+
report.error = bi(`${source.label} 这次读到的是空页面。`, `${source.label} came back empty.`);
|
|
122
|
+
store.set(watchReportKey(source.id), report);
|
|
123
|
+
return report;
|
|
124
|
+
}
|
|
125
|
+
const items = readWatchBody({ body: body.body, contentType: body.contentType, url: source.url, now });
|
|
126
|
+
if (items.length === 0) {
|
|
127
|
+
report.error = bi(`${source.label} 这次读到的是空页面。`, `${source.label} came back empty.`);
|
|
128
|
+
store.set(watchReportKey(source.id), report);
|
|
129
|
+
return report;
|
|
130
|
+
}
|
|
131
|
+
report.items = items.length;
|
|
132
|
+
const state = readState(store, source.id);
|
|
133
|
+
const { fresh, changed, digest } = diffItems(items, state);
|
|
134
|
+
const verdict = judgeWatch({ source, items, fresh, changed, first: !state.primed, now });
|
|
135
|
+
store.transaction(() => {
|
|
136
|
+
if (verdict !== undefined) {
|
|
137
|
+
const counts = { opened: 0, closed: 0 };
|
|
138
|
+
applyVerdict(store, WATCH_SOURCE, { ...verdict, activityAt: Math.max(...items.map(i => i.at), now) }, now, counts);
|
|
139
|
+
report.opened = counts.opened;
|
|
140
|
+
report.closed = counts.closed;
|
|
141
|
+
}
|
|
142
|
+
const seen = [...state.seen, ...fresh.map(i => i.key)];
|
|
143
|
+
const trimmed = seen.length > SEEN_LIMIT ? seen.slice(seen.length - SEEN_LIMIT) : seen;
|
|
144
|
+
store.set(watchStateKey(source.id), { seen: trimmed, digest, primed: true });
|
|
145
|
+
store.set(watchSeenKey(source.id), trimmed.length);
|
|
146
|
+
});
|
|
147
|
+
store.set(watchReportKey(source.id), report);
|
|
148
|
+
return report;
|
|
149
|
+
}
|
|
150
|
+
/** Everything about one watch the settings page shows. */
|
|
151
|
+
export function describeWatch(store, source, lang) {
|
|
152
|
+
const report = store.get(watchReportKey(source.id));
|
|
153
|
+
if (report === undefined)
|
|
154
|
+
return lang === 'zh' ? '还没看过' : 'not checked yet';
|
|
155
|
+
if (report.error !== undefined)
|
|
156
|
+
return report.error[lang];
|
|
157
|
+
return lang === 'zh'
|
|
158
|
+
? `${shortDay(report.at)} 看过,${report.items} 条内容`
|
|
159
|
+
: `checked ${shortDay(report.at)}, ${report.items} entries`;
|
|
160
|
+
}
|
|
161
|
+
/** The words, for showing back to you. */
|
|
162
|
+
export function describeKeywords(source, lang) {
|
|
163
|
+
if (source.keywords.length === 0)
|
|
164
|
+
return lang === 'zh' ? '有任何变化就告诉我' : 'tell me about any change';
|
|
165
|
+
return lang === 'zh' ? `提到 ${source.keywords.join('、')} 时告诉我` : `tell me when it mentions ${source.keywords.join(', ')}`;
|
|
166
|
+
}
|
|
167
|
+
/** Guard for the settings page: an address that cannot be a watch at all. */
|
|
168
|
+
export function assertWatchable(source) {
|
|
169
|
+
if (!/^https?:\/\//.test(source.url))
|
|
170
|
+
throw new MishuError(bi('只支持 http 和 https 的网址', 'only http and https addresses work'));
|
|
171
|
+
}
|