dsh-mishu 0.7.2 → 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.
@@ -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
+ }
package/lib/dsh/api.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * travel inward: they go straight to the keychain, and no endpoint ever
10
10
  * returns one.
11
11
  */
12
- import { addDays, addSeries, bi, deleteFeishu, deliver, FEISHU_WEBHOOK, notifySettings, NOTIFY_REPORT_KEY, saveFeishu, saveNotifySettings, testMessage, buildAsk, buildBoard, buildDetail, dayOf, deleteGithubToken, deleteMailAccount, describeMailError, MishuError, describeSeries, detectLang, errorText, GitHubClient, MAIL_PRESETS, mailReportKey, NEW_TOKEN_URL, normalizePassword, providerMismatch, resolveSeries, saveGithubToken, saveMailAccount, saveSettings, secretAccount, secretEnvName, setStatusByUser, snoozeByUser, stopSeries, tickManual, } from "../core/index.js";
12
+ import { describeWatchError, addDays, addSeries, bi, deleteFeishu, deliver, FEISHU_WEBHOOK, notifySettings, NOTIFY_REPORT_KEY, saveFeishu, saveNotifySettings, testMessage, buildAsk, buildBoard, buildDetail, dayOf, deleteGithubToken, deleteMailAccount, describeMailError, MishuError, describeSeries, detectLang, errorText, GitHubClient, MAIL_PRESETS, mailReportKey, NEW_TOKEN_URL, normalizePassword, providerMismatch, resolveSeries, saveGithubToken, saveMailAccount, saveSettings, secretAccount, secretEnvName, setStatusByUser, snoozeByUser, stopSeries, tickManual, deleteWatch, describeKeywords, describeWatch, parseKeywords, readWatchBody, resolveWatch, saveWatch, watchById, watchReportKey, watchSources, } from "../core/index.js";
13
13
  import { resolveAccount } from "../core/mail/account.js";
14
14
  export const API_BASE = '/api/dsh-mishu';
15
15
  class ApiError extends Error {
@@ -62,6 +62,38 @@ function accountInput(value) {
62
62
  return out;
63
63
  }
64
64
  const password = (body) => (typeof body.password === 'string' && body.password !== '' ? body.password : undefined);
65
+ function watchInput(value) {
66
+ const w = (value ?? {});
67
+ const text = (v) => (typeof v === 'string' ? v : undefined);
68
+ const out = {};
69
+ if (Number.isInteger(Number(w.id)) && Number(w.id) > 0)
70
+ out.id = Number(w.id);
71
+ if (text(w.label) !== undefined)
72
+ out.label = text(w.label);
73
+ if (text(w.url) !== undefined)
74
+ out.url = text(w.url);
75
+ if (typeof w.keywords === 'string' || Array.isArray(w.keywords))
76
+ out.keywords = w.keywords;
77
+ if (typeof w.enabled === 'boolean')
78
+ out.enabled = w.enabled;
79
+ if (w.intervalMinutes !== undefined && w.intervalMinutes !== null && w.intervalMinutes !== '')
80
+ out.intervalMinutes = Number(w.intervalMinutes);
81
+ return out;
82
+ }
83
+ /** What the settings page shows for one watch. */
84
+ function watchView(store, source, lang) {
85
+ return {
86
+ id: source.id,
87
+ label: source.label,
88
+ url: source.url,
89
+ keywords: source.keywords.join(' '),
90
+ enabled: source.enabled,
91
+ intervalMinutes: source.intervalMinutes,
92
+ status: describeWatch(store, source, lang),
93
+ summary: describeKeywords(source, lang),
94
+ report: store.get(watchReportKey(source.id)) ?? null,
95
+ };
96
+ }
65
97
  export function apiHandlers(runtime, clock = Date.now) {
66
98
  const langOf = (body) => runtime.fixedLang() ?? detectLang(typeof body.lang === 'string' ? body.lang : undefined);
67
99
  const need = (lang) => {
@@ -77,6 +109,7 @@ export function apiHandlers(runtime, clock = Date.now) {
77
109
  return buildBoard(store, {
78
110
  now: at, lang, github: { enabled: settings.githubEnabled, markRead: settings.markRead },
79
111
  mailAccounts: runtime.accounts(),
112
+ watches: runtime.watches(),
80
113
  });
81
114
  };
82
115
  /** Everything the settings page shows. No secret is ever included. */
@@ -108,6 +141,7 @@ export function apiHandlers(runtime, clock = Date.now) {
108
141
  ...secrets.available && secrets.file !== undefined ? { file: { path: secrets.file.path, why: secrets.file.why[lang] } } : {},
109
142
  },
110
143
  accounts,
144
+ watches: watchSources(store).map(source => watchView(store, source, lang)),
111
145
  notify: {
112
146
  settings: notifySettings(store),
113
147
  channels: await Promise.all(['desktop', 'feishu'].map(async (id) => {
@@ -194,7 +228,7 @@ export function apiHandlers(runtime, clock = Date.now) {
194
228
  '/ask': async (body) => {
195
229
  const lang = langOf(body);
196
230
  const ask = (body.ask ?? {});
197
- const kinds = ['matter', 'series', 'summary', 'github-error', 'mail-error', 'mail-setup'];
231
+ const kinds = ['matter', 'series', 'summary', 'github-error', 'mail-error', 'mail-setup', 'watch-error', 'watch-setup'];
198
232
  if (typeof ask.kind !== 'string' || !kinds.includes(ask.kind))
199
233
  throw new ApiError(lang === 'zh' ? '不认识要问什么' : 'Unknown question');
200
234
  const request = {
@@ -202,6 +236,7 @@ export function apiHandlers(runtime, clock = Date.now) {
202
236
  ...ask.id === undefined ? {} : { id: Number(ask.id) },
203
237
  ...ask.accountId === undefined ? {} : { accountId: Number(ask.accountId) },
204
238
  ...ask.preset === undefined ? {} : { preset: String(ask.preset) },
239
+ ...ask.watchId === undefined ? {} : { watchId: Number(ask.watchId) },
205
240
  ...typeof ask.error === 'string' && ask.error !== '' ? { error: ask.error.slice(0, 1000) } : {},
206
241
  };
207
242
  const prompt = buildAsk(need(lang), request, { now: clock(), lang, markRead: runtime.settings().markRead, accounts: runtime.accounts() });
@@ -310,6 +345,61 @@ export function apiHandlers(runtime, clock = Date.now) {
310
345
  }
311
346
  return { view: await settingsView(lang) };
312
347
  },
348
+ // ── things you watch yourself ─────────────────────────
349
+ '/watch/save': async (body) => {
350
+ const lang = langOf(body);
351
+ const source = saveWatch(need(lang), watchInput(body.watch), clock());
352
+ return { id: source.id, view: await settingsView(lang) };
353
+ },
354
+ /**
355
+ * Read it once without saving: how many entries came back, and what the
356
+ * first one is — enough to tell a feed from a login wall.
357
+ */
358
+ '/watch/test': async (body) => {
359
+ const lang = langOf(body);
360
+ const input = watchInput(body.watch);
361
+ const source = resolveWatch(input, input.id ?? 0);
362
+ let page;
363
+ try {
364
+ page = await runtime.watchFetch(source.url);
365
+ }
366
+ catch (error) {
367
+ throw new MishuError(describeWatchError(error, source));
368
+ }
369
+ if (page.status >= 400)
370
+ throw new ApiError(lang === 'zh' ? `这个网址返回 ${page.status}` : `The address answered ${page.status}`);
371
+ const items = readWatchBody({ body: page.body, contentType: page.contentType, url: source.url, now: clock() });
372
+ if (items.length === 0)
373
+ throw new ApiError(lang === 'zh' ? '读到了,但里面没有内容' : 'It loaded, but there was nothing in it');
374
+ const first = items[0];
375
+ const words = parseKeywords(input.keywords);
376
+ const haystack = `${first.title}\n${first.text}`.toLowerCase();
377
+ const hit = words.find(word => haystack.includes(word));
378
+ return {
379
+ items: items.length,
380
+ title: first.title.slice(0, 120),
381
+ excerpt: first.text.slice(0, 200),
382
+ ...words.length === 0 ? {} : { keywordHit: hit !== undefined, ...hit === undefined ? {} : { keyword: hit } },
383
+ };
384
+ },
385
+ '/watch/delete': async (body) => {
386
+ const lang = langOf(body);
387
+ if (!deleteWatch(need(lang), Number(body.id), clock()))
388
+ throw new ApiError(lang === 'zh' ? `没有 #${body.id} 这个关注` : `There is no watch #${body.id}`);
389
+ return { view: await settingsView(lang) };
390
+ },
391
+ /** Look at it right now, whatever the interval says. */
392
+ '/watch/sync': async (body) => {
393
+ const lang = langOf(body);
394
+ const store = need(lang);
395
+ const source = watchById(store, Number(body.id));
396
+ if (source === undefined)
397
+ throw new ApiError(lang === 'zh' ? `没有 #${body.id} 这个关注` : `There is no watch #${body.id}`);
398
+ const report = await runtime.syncWatchSource(source);
399
+ if (report?.error !== undefined)
400
+ throw new ApiError(report.error[lang]);
401
+ return { report: report ?? null, board: board(store, lang), view: await settingsView(lang) };
402
+ },
313
403
  /** Whether the page's "restart DSH" button can work here, and if not, what to do instead. */
314
404
  '/restart/info': async (body) => {
315
405
  const plan = runtime.restarter.plan();
@@ -4,12 +4,14 @@
4
4
  * what you set on the settings page on top), the keychain, and one sync of
5
5
  * every source at a time.
6
6
  */
7
- import { dayOf, detectLang, effectiveSettings, GitHubClient, ImapFetcher, mailAccounts, mailReportKey, renderBrief, buildBoard, DesktopChannel, FeishuChannel, FileSecrets, GithubDeviceLogin, KeychainOrFile, notifySettings, notifyTick, mishuPaths, resolveGithubToken, secretAccount, secretFromEnv, syncGithub, syncMail, systemSecrets, tickManual, bi, restartPlan, scheduleRestart, } from "../core/index.js";
7
+ import { dayOf, detectLang, effectiveSettings, GitHubClient, ImapFetcher, mailAccounts, mailReportKey, renderBrief, buildBoard, DesktopChannel, FeishuChannel, FileSecrets, GithubDeviceLogin, KeychainOrFile, notifySettings, notifyTick, mishuPaths, resolveGithubToken, secretAccount, secretFromEnv, syncGithub, syncMail, systemSecrets, tickManual, bi, restartPlan, scheduleRestart, dueWatches, httpFetch, syncWatch, watchReportKey, watchSources, } from "../core/index.js";
8
8
  export class Runtime {
9
9
  store;
10
10
  config;
11
11
  secrets;
12
12
  fetcher;
13
+ /** How watched addresses are read; tests swap it for a fake. */
14
+ watchFetch = httpFetch;
13
15
  logger;
14
16
  syncing;
15
17
  /** Where reminders can go; tests swap them for recorders. */
@@ -44,6 +46,9 @@ export class Runtime {
44
46
  accounts() {
45
47
  return this.store === undefined ? [] : mailAccounts(this.store);
46
48
  }
49
+ watches() {
50
+ return this.store === undefined ? [] : watchSources(this.store);
51
+ }
47
52
  brief(lang = this.lang()) {
48
53
  const store = this.store;
49
54
  if (store === undefined)
@@ -55,6 +60,7 @@ export class Runtime {
55
60
  now, lang, upcomingDays: this.config.upcomingDays,
56
61
  github: { enabled: settings.githubEnabled, markRead: settings.markRead },
57
62
  mailAccounts: this.accounts(),
63
+ watches: this.watches(),
58
64
  });
59
65
  }
60
66
  /** The password for an account: keychain first, then the documented environment variable. */
@@ -109,6 +115,15 @@ export class Runtime {
109
115
  this.logger.info(`dsh-mishu: mail #${account.id} ${report.error === undefined ? `ok (${report.messages} unread, +${report.opened} open, ${report.closed} closed)` : 'failed'}`);
110
116
  return report;
111
117
  }
118
+ /** One watch; the result is also kept as its last report. */
119
+ async syncWatchSource(source) {
120
+ const store = this.store;
121
+ if (store === undefined)
122
+ return undefined;
123
+ const report = await syncWatch({ store, source, fetcher: this.watchFetch, now: Date.now() });
124
+ this.logger.info(`dsh-mishu: watch #${source.id} ${report.error === undefined ? `ok (${report.items} items, +${report.opened} open, ${report.closed} closed)` : 'failed'}`);
125
+ return report;
126
+ }
112
127
  /** The channels that are turned on and work on this computer. */
113
128
  async activeChannels() {
114
129
  const store = this.store;
@@ -125,7 +140,7 @@ export class Runtime {
125
140
  /** The board as the brief describes it. */
126
141
  board(now, lang = this.lang()) {
127
142
  const settings = this.settings();
128
- return buildBoard(this.store, { now, lang, github: { enabled: settings.githubEnabled, markRead: settings.markRead }, mailAccounts: this.accounts() });
143
+ return buildBoard(this.store, { now, lang, github: { enabled: settings.githubEnabled, markRead: settings.markRead }, mailAccounts: this.accounts(), watches: this.watches() });
129
144
  }
130
145
  /** Send what is due now: urgent items, the daily brief. One at a time. */
131
146
  notify(now = Date.now()) {
@@ -151,6 +166,9 @@ export class Runtime {
151
166
  for (const account of this.accounts().filter(a => a.enabled)) {
152
167
  await this.syncMailAccount(account).catch(error => this.logger.warn(`dsh-mishu: mail #${account.id} sync failed (${error.message})`));
153
168
  }
169
+ for (const source of dueWatches(store, Date.now())) {
170
+ await this.syncWatchSource(source).catch(error => this.logger.warn(`dsh-mishu: watch #${source.id} failed (${error.message})`));
171
+ }
154
172
  // What the sync turned up may be urgent.
155
173
  await this.notify();
156
174
  })().finally(() => { this.syncing = undefined; });