rssany 0.1.0
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/.env.example +52 -0
- package/README.md +196 -0
- package/bin/rssany.js +6 -0
- package/config.examples.json +11 -0
- package/dist/index.js +4037 -0
- package/dist/index.js.map +1 -0
- package/package.json +98 -0
- package/plugins/sources/email.rssany.js +96 -0
- package/plugins/sources/rss.rssany.js +83 -0
- package/plugins/templates/site.rssany.js +26 -0
- package/scripts/reset.mjs +136 -0
- package/sources.example.json +562 -0
- package/statics/401.html +56 -0
- package/statics/404.html +12 -0
- package/statics/README.md +7 -0
- package/statics/image.png +0 -0
- package/webui/build/200.html +51 -0
- package/webui/build/_app/env.js +1 -0
- package/webui/build/_app/immutable/assets/0.BUAXpTm6.css +1 -0
- package/webui/build/_app/immutable/assets/10.I1OuCLrU.css +1 -0
- package/webui/build/_app/immutable/assets/11.CrO9xaki.css +1 -0
- package/webui/build/_app/immutable/assets/12.BEi6fInA.css +1 -0
- package/webui/build/_app/immutable/assets/14.Ctlgn1LZ.css +1 -0
- package/webui/build/_app/immutable/assets/2.eJ80XOGm.css +1 -0
- package/webui/build/_app/immutable/assets/4.B8-jYAVj.css +1 -0
- package/webui/build/_app/immutable/assets/5.ClehBQ0g.css +1 -0
- package/webui/build/_app/immutable/assets/6.Drn-0DON.css +1 -0
- package/webui/build/_app/immutable/assets/7.ms2diq_q.css +1 -0
- package/webui/build/_app/immutable/assets/8.DKymkjjs.css +1 -0
- package/webui/build/_app/immutable/assets/9.BZheTlzZ.css +1 -0
- package/webui/build/_app/immutable/assets/SourcesList.BhtYlRsQ.css +1 -0
- package/webui/build/_app/immutable/chunks/BUApaBEI.js +1 -0
- package/webui/build/_app/immutable/chunks/BUngiKFg.js +1 -0
- package/webui/build/_app/immutable/chunks/Bfc47y5P.js +1 -0
- package/webui/build/_app/immutable/chunks/Bt0fzibd.js +1 -0
- package/webui/build/_app/immutable/chunks/BxHqDcpw.js +1 -0
- package/webui/build/_app/immutable/chunks/ByQRbEUX.js +1 -0
- package/webui/build/_app/immutable/chunks/C12mHcUp.js +6 -0
- package/webui/build/_app/immutable/chunks/C1kQ4pHy.js +1 -0
- package/webui/build/_app/immutable/chunks/C74gbb4Q.js +1 -0
- package/webui/build/_app/immutable/chunks/CAtemnMo.js +1 -0
- package/webui/build/_app/immutable/chunks/CBY2biv-.js +1 -0
- package/webui/build/_app/immutable/chunks/CVjCNJia.js +1 -0
- package/webui/build/_app/immutable/chunks/Cg3zih_x.js +1 -0
- package/webui/build/_app/immutable/chunks/CjQQ9_Q2.js +2 -0
- package/webui/build/_app/immutable/chunks/CkS2JMkE.js +1 -0
- package/webui/build/_app/immutable/chunks/CtHRh_pJ.js +1 -0
- package/webui/build/_app/immutable/chunks/D-6mYMI1.js +1 -0
- package/webui/build/_app/immutable/chunks/D1Gs8-g3.js +1 -0
- package/webui/build/_app/immutable/chunks/D9dRVKgL.js +1 -0
- package/webui/build/_app/immutable/chunks/DCEY1XiC.js +1 -0
- package/webui/build/_app/immutable/chunks/DI-t-G_K.js +2 -0
- package/webui/build/_app/immutable/chunks/DTUxjyWL.js +1 -0
- package/webui/build/_app/immutable/chunks/DWJZOHke.js +1 -0
- package/webui/build/_app/immutable/chunks/Dgs6d7X5.js +1 -0
- package/webui/build/_app/immutable/chunks/DjpPK99f.js +71 -0
- package/webui/build/_app/immutable/chunks/DjzVVxpy.js +1 -0
- package/webui/build/_app/immutable/chunks/LQVMBmDN.js +1 -0
- package/webui/build/_app/immutable/chunks/Qw0Qgx6J.js +1 -0
- package/webui/build/_app/immutable/chunks/V2-VOe88.js +1 -0
- package/webui/build/_app/immutable/chunks/bohabpgg.js +1 -0
- package/webui/build/_app/immutable/chunks/c-YfbAB_.js +8 -0
- package/webui/build/_app/immutable/chunks/hp4PFHFv.js +1 -0
- package/webui/build/_app/immutable/chunks/tpTQfoNn.js +1 -0
- package/webui/build/_app/immutable/entry/app.4I2fqDIL.js +2 -0
- package/webui/build/_app/immutable/entry/start.CrgdT2Qb.js +1 -0
- package/webui/build/_app/immutable/nodes/0.gA9sQtoM.js +11 -0
- package/webui/build/_app/immutable/nodes/1.Bybh7btp.js +1 -0
- package/webui/build/_app/immutable/nodes/10.DEkJCZ6X.js +1 -0
- package/webui/build/_app/immutable/nodes/11.CDNNJqlQ.js +1 -0
- package/webui/build/_app/immutable/nodes/12.D9g8GCjm.js +24 -0
- package/webui/build/_app/immutable/nodes/13.DRpZV72T.js +1 -0
- package/webui/build/_app/immutable/nodes/14.DVeJW6bd.js +1 -0
- package/webui/build/_app/immutable/nodes/15.BtYZF6FM.js +1 -0
- package/webui/build/_app/immutable/nodes/16.Ba_qJjp6.js +1 -0
- package/webui/build/_app/immutable/nodes/2.DIZ4IPNm.js +1 -0
- package/webui/build/_app/immutable/nodes/3.BFSNf0FK.js +1 -0
- package/webui/build/_app/immutable/nodes/4.BSsIjejE.js +2 -0
- package/webui/build/_app/immutable/nodes/5.COxRT9Oe.js +1 -0
- package/webui/build/_app/immutable/nodes/6.CBgQ4YzB.js +1 -0
- package/webui/build/_app/immutable/nodes/7.BbzWOL0V.js +6 -0
- package/webui/build/_app/immutable/nodes/8.C8120200.js +1 -0
- package/webui/build/_app/immutable/nodes/9.BH_BGQQ4.js +1 -0
- package/webui/build/_app/version.json +1 -0
package/package.json
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "rssany",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Universal RSS/Atom/JSON Feed pipeline — fetches, extracts, parses and converts any web content into consumable feeds with plugin support",
|
|
5
|
+
"author": "Joo",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"rssany": "./bin/rssany.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"bin",
|
|
17
|
+
"plugins",
|
|
18
|
+
"statics",
|
|
19
|
+
"webui/build",
|
|
20
|
+
".env.example",
|
|
21
|
+
"README.md",
|
|
22
|
+
"sources.example.json",
|
|
23
|
+
"config.examples.json",
|
|
24
|
+
"scripts/reset.mjs"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=20 <24"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "vite build",
|
|
31
|
+
"dev": "cross-env NODE_ENV=development NODE_OPTIONS=--no-deprecation tsx app/index.ts",
|
|
32
|
+
"dev:api": "cross-env NODE_ENV=development NODE_OPTIONS=--no-deprecation tsx app/index.ts",
|
|
33
|
+
"dev:all": "concurrently -k -n api,web -c blue,magenta \"pnpm dev\" \"pnpm webui:watch\"",
|
|
34
|
+
"start": "node dist/index.js",
|
|
35
|
+
"serve:route": "node scripts/serve-route.mjs",
|
|
36
|
+
"serve:app": "npx tsx app/index.ts",
|
|
37
|
+
"test": "vitest",
|
|
38
|
+
"test:run": "vitest run",
|
|
39
|
+
"lint": "eslint .",
|
|
40
|
+
"lint:fix": "eslint . --fix",
|
|
41
|
+
"typecheck": "tsc --noEmit",
|
|
42
|
+
"reset": "node scripts/reset.mjs",
|
|
43
|
+
"webui:install": "cd webui && npm install",
|
|
44
|
+
"webui:dev": "cd webui && pnpm run dev",
|
|
45
|
+
"webui:build": "cd webui && npm run build",
|
|
46
|
+
"webui:watch": "cd webui && npm run build:watch",
|
|
47
|
+
"build:all": "npm run build && npm run webui:build",
|
|
48
|
+
"prepublishOnly": "npm run build:all",
|
|
49
|
+
"docker:build": "bash scripts/docker-build.sh",
|
|
50
|
+
"docker:build:tag": "bash scripts/docker-build.sh"
|
|
51
|
+
},
|
|
52
|
+
"keywords": [
|
|
53
|
+
"rss",
|
|
54
|
+
"atom",
|
|
55
|
+
"json-feed",
|
|
56
|
+
"subscription",
|
|
57
|
+
"pipeline"
|
|
58
|
+
],
|
|
59
|
+
"license": "MIT",
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@eslint/js": "^9.15.0",
|
|
62
|
+
"@types/jsdom": "^21.1.7",
|
|
63
|
+
"@types/mailparser": "^3.4.6",
|
|
64
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
65
|
+
"@types/node": "^25.2.0",
|
|
66
|
+
"@types/node-cron": "^3.0.11",
|
|
67
|
+
"@types/nodemailer": "^7.0.11",
|
|
68
|
+
"concurrently": "^9.2.1",
|
|
69
|
+
"cross-env": "^7.0.3",
|
|
70
|
+
"eslint": "^9.15.0",
|
|
71
|
+
"globals": "^15.12.0",
|
|
72
|
+
"tsx": "^4.19.0",
|
|
73
|
+
"typescript": "~5.6.0",
|
|
74
|
+
"typescript-eslint": "^8.15.0",
|
|
75
|
+
"vite": "^6.0.0",
|
|
76
|
+
"vitest": "^2.1.0"
|
|
77
|
+
},
|
|
78
|
+
"dependencies": {
|
|
79
|
+
"@hono/node-server": "^1.13.0",
|
|
80
|
+
"@mozilla/readability": "^0.6.0",
|
|
81
|
+
"better-sqlite3": "^12.6.2",
|
|
82
|
+
"cron-parser": "^5.0.0",
|
|
83
|
+
"dotenv": "^16.4.7",
|
|
84
|
+
"hono": "^4.6.0",
|
|
85
|
+
"https-proxy-agent": "^7.0.6",
|
|
86
|
+
"imapflow": "^1.2.10",
|
|
87
|
+
"jsdom": "^25.0.0",
|
|
88
|
+
"mailparser": "^3.9.3",
|
|
89
|
+
"marked": "^17.0.3",
|
|
90
|
+
"node-cron": "^3.0.3",
|
|
91
|
+
"node-html-parser": "^7.0.2",
|
|
92
|
+
"nodemailer": "^8.0.2",
|
|
93
|
+
"openai": "^4.76.1",
|
|
94
|
+
"puppeteer-core": "^24.36.0",
|
|
95
|
+
"rss-parser": "^3.13.0",
|
|
96
|
+
"zod": "^4.3.6"
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// 内置 IMAP 邮件插件:匹配 imap://、imaps:// 协议 URL
|
|
2
|
+
|
|
3
|
+
import { ImapFlow } from "imapflow";
|
|
4
|
+
import { logger } from "../../app/core/logger/index.js";
|
|
5
|
+
import { simpleParser } from "mailparser";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
|
|
8
|
+
function parseImapUrl(sourceId) {
|
|
9
|
+
const url = new URL(sourceId);
|
|
10
|
+
const host = url.hostname;
|
|
11
|
+
const port = url.port ? parseInt(url.port, 10) : 993;
|
|
12
|
+
const secure = url.protocol === "imaps:" || port === 993;
|
|
13
|
+
const user = decodeURIComponent(url.username);
|
|
14
|
+
const pass = decodeURIComponent(url.password);
|
|
15
|
+
const folder = decodeURIComponent(url.pathname.slice(1)) || "INBOX";
|
|
16
|
+
const limit = Math.max(1, parseInt(url.searchParams.get("limit") ?? "30", 10));
|
|
17
|
+
return { host, port, secure, user, pass, folder, limit };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function makeGuid(messageId, uid, host) {
|
|
21
|
+
const raw = messageId ?? `${uid}@${host}`;
|
|
22
|
+
return createHash("sha256").update(raw).digest("hex");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default {
|
|
26
|
+
id: "__email__",
|
|
27
|
+
pattern: /^imaps?:\/\//,
|
|
28
|
+
priority: 0,
|
|
29
|
+
refreshInterval: "30min",
|
|
30
|
+
async fetchItems(sourceId, _ctx) {
|
|
31
|
+
const { host, port, secure, user, pass, folder, limit } = parseImapUrl(sourceId);
|
|
32
|
+
const client = new ImapFlow({
|
|
33
|
+
host,
|
|
34
|
+
port,
|
|
35
|
+
secure,
|
|
36
|
+
auth: { user, pass },
|
|
37
|
+
logger: false,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
client.on("error", (err) => {
|
|
41
|
+
logger.error("source", "IMAP 连接异常", { err: err?.message, host, folder });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const items = [];
|
|
45
|
+
let connected = false;
|
|
46
|
+
try {
|
|
47
|
+
await client.connect();
|
|
48
|
+
connected = true;
|
|
49
|
+
const lock = await client.getMailboxLock(folder);
|
|
50
|
+
try {
|
|
51
|
+
const mailbox = client.mailbox;
|
|
52
|
+
if (mailbox === false) return [];
|
|
53
|
+
const total = mailbox.exists ?? 0;
|
|
54
|
+
if (total === 0) return [];
|
|
55
|
+
const start = Math.max(1, total - limit + 1);
|
|
56
|
+
for await (const msg of client.fetch(`${start}:*`, { source: true, envelope: true })) {
|
|
57
|
+
try {
|
|
58
|
+
if (msg.source === undefined || msg.envelope === undefined) continue;
|
|
59
|
+
const parsed = await simpleParser(msg.source);
|
|
60
|
+
const envelope = msg.envelope;
|
|
61
|
+
const guid = makeGuid(envelope.messageId, msg.uid, host);
|
|
62
|
+
const title = parsed.subject ?? envelope.subject ?? "(无主题)";
|
|
63
|
+
const fromAddr = envelope.from?.[0];
|
|
64
|
+
const authorRaw = fromAddr?.name || fromAddr?.address || undefined;
|
|
65
|
+
const author = authorRaw ? [authorRaw] : undefined;
|
|
66
|
+
const pubDate = parsed.date ?? envelope.date ?? new Date();
|
|
67
|
+
const link = `imap://${host}/${encodeURIComponent(folder)}#${msg.uid}`;
|
|
68
|
+
const htmlBody = typeof parsed.html === "string" ? parsed.html : undefined;
|
|
69
|
+
const textBody = typeof parsed.text === "string" ? parsed.text : undefined;
|
|
70
|
+
const content = htmlBody ?? (textBody ? `<pre>${textBody}</pre>` : undefined);
|
|
71
|
+
const summary = textBody?.slice(0, 300) || undefined;
|
|
72
|
+
items.push({ guid, title, link, pubDate, author, summary, content });
|
|
73
|
+
} catch (err) {
|
|
74
|
+
logger.warn("source", "解析单封邮件失败", { err: err?.message });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
} finally {
|
|
78
|
+
lock.release();
|
|
79
|
+
}
|
|
80
|
+
} catch (err) {
|
|
81
|
+
logger.warn("source", "拉取 IMAP 邮件失败", { err: err?.message, host, folder });
|
|
82
|
+
return [];
|
|
83
|
+
} finally {
|
|
84
|
+
if (connected && client.usable) {
|
|
85
|
+
try {
|
|
86
|
+
await client.logout();
|
|
87
|
+
} catch (err) {
|
|
88
|
+
logger.warn("source", "IMAP 退出连接失败", { err: err?.message, host, folder });
|
|
89
|
+
}
|
|
90
|
+
} else {
|
|
91
|
+
client.close();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return items.sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime());
|
|
95
|
+
},
|
|
96
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// 内置 RSS/Atom/JSON Feed 插件:匹配 *rss*、*atom*、*.xml 等标准 Feed URL
|
|
2
|
+
|
|
3
|
+
import Parser from "rss-parser";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
const UA = "RssAny/1.0 (+https://github.com/joohw/rssany)";
|
|
7
|
+
const parser = new Parser({
|
|
8
|
+
timeout: 15_000,
|
|
9
|
+
headers: {
|
|
10
|
+
"User-Agent": UA,
|
|
11
|
+
Accept: "application/rss+xml,application/atom+xml,application/json,application/xml,text/xml,*/*",
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
function looksLikeFeed(url) {
|
|
16
|
+
const lower = url.toLowerCase();
|
|
17
|
+
return (
|
|
18
|
+
lower.includes("/feed") ||
|
|
19
|
+
lower.includes("/rss") ||
|
|
20
|
+
lower.includes("/atom") ||
|
|
21
|
+
lower.endsWith(".xml") ||
|
|
22
|
+
lower.endsWith(".rss") ||
|
|
23
|
+
lower.endsWith(".atom") ||
|
|
24
|
+
lower.includes("format=rss") ||
|
|
25
|
+
lower.includes("format=atom") ||
|
|
26
|
+
lower.includes("output=rss")
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function fetchFeed(url, proxy) {
|
|
31
|
+
const proxyToUse = proxy ?? process.env.HTTP_PROXY ?? process.env.HTTPS_PROXY;
|
|
32
|
+
if (proxyToUse) {
|
|
33
|
+
const { HttpsProxyAgent } = await import("https-proxy-agent");
|
|
34
|
+
const agent = new HttpsProxyAgent(proxyToUse);
|
|
35
|
+
const parserWithProxy = new Parser({
|
|
36
|
+
timeout: 15_000,
|
|
37
|
+
headers: {
|
|
38
|
+
"User-Agent": UA,
|
|
39
|
+
Accept: "application/rss+xml,application/atom+xml,application/json,application/xml,text/xml,*/*",
|
|
40
|
+
},
|
|
41
|
+
requestOptions: { agent },
|
|
42
|
+
});
|
|
43
|
+
return parserWithProxy.parseURL(url);
|
|
44
|
+
}
|
|
45
|
+
return parser.parseURL(url);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export default {
|
|
49
|
+
id: "__rss__",
|
|
50
|
+
pattern: /^https?:\/\//,
|
|
51
|
+
match: looksLikeFeed,
|
|
52
|
+
priority: 20,
|
|
53
|
+
refreshInterval: "1h",
|
|
54
|
+
async fetchItems(sourceId, ctx) {
|
|
55
|
+
const feed = await fetchFeed(sourceId, ctx.proxy);
|
|
56
|
+
return (feed.items ?? []).map((item) => {
|
|
57
|
+
const link = item.link ?? item.guid ?? sourceId;
|
|
58
|
+
const guid = item.guid ?? createHash("sha256").update(link).digest("hex");
|
|
59
|
+
const pubDate =
|
|
60
|
+
item.pubDate != null
|
|
61
|
+
? new Date(item.pubDate)
|
|
62
|
+
: item.isoDate != null
|
|
63
|
+
? new Date(item.isoDate)
|
|
64
|
+
: new Date();
|
|
65
|
+
const authorRaw =
|
|
66
|
+
typeof item.creator === "string" ? item.creator : typeof item.author === "string" ? item.author : undefined;
|
|
67
|
+
const author = authorRaw ? [authorRaw] : undefined;
|
|
68
|
+
const summary =
|
|
69
|
+
typeof item.summary === "string" ? item.summary : typeof item.contentSnippet === "string" ? item.contentSnippet : undefined;
|
|
70
|
+
const content =
|
|
71
|
+
typeof item.content === "string" ? item.content : typeof item["content:encoded"] === "string" ? item["content:encoded"] : undefined;
|
|
72
|
+
return {
|
|
73
|
+
guid,
|
|
74
|
+
title: item.title ?? "",
|
|
75
|
+
link,
|
|
76
|
+
pubDate,
|
|
77
|
+
author,
|
|
78
|
+
summary,
|
|
79
|
+
content,
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site 插件模板(管理页「添加插件」会复制到 `.rssany/plugins/sources/{id}.rssany.ts`)
|
|
3
|
+
* 修改 `id` 后请与文件名保持一致。
|
|
4
|
+
*
|
|
5
|
+
* 接口说明:app/scraper/sources/web/site.ts
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export default {
|
|
9
|
+
id: "__PLUGIN_ID__",
|
|
10
|
+
listUrlPattern: "https://example.com/{segment}",
|
|
11
|
+
refreshInterval: "1day",
|
|
12
|
+
|
|
13
|
+
/** sourceId 与订阅里 ref 一致;ctx 含 fetchHtml、extractItem 等 */
|
|
14
|
+
async fetchItems(sourceId, ctx) {
|
|
15
|
+
const { html, finalUrl } = await ctx.fetchHtml(sourceId, {
|
|
16
|
+
waitMs: 2000,
|
|
17
|
+
purify: true,
|
|
18
|
+
});
|
|
19
|
+
void html;
|
|
20
|
+
void finalUrl;
|
|
21
|
+
// TODO: 解析列表页 HTML,产出 { title, link, summary?, pubDate? } 等 FeedItem
|
|
22
|
+
return [];
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
// enrichItem: async (item, ctx) => ctx.extractItem(item),
|
|
26
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 停止占用 HTTP 服务端口的进程,并删除用户数据目录(与 app 中 PORT / RSSANY_USER_DIR 约定一致)。
|
|
4
|
+
* 用法:pnpm reset 或 PORT=3000 pnpm reset
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { existsSync, rmSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { config } from "dotenv";
|
|
12
|
+
|
|
13
|
+
config({ path: join(process.cwd(), ".env") });
|
|
14
|
+
|
|
15
|
+
const DEFAULT_PORT = 18473;
|
|
16
|
+
const port = Number(process.env.PORT) || DEFAULT_PORT;
|
|
17
|
+
const userDirRaw = process.env.RSSANY_USER_DIR?.trim();
|
|
18
|
+
const userDir =
|
|
19
|
+
userDirRaw && userDirRaw.length > 0 ? userDirRaw : join(homedir(), ".rssany");
|
|
20
|
+
|
|
21
|
+
function pidsListeningWin32(p) {
|
|
22
|
+
const cmd = `Get-NetTCPConnection -LocalPort ${p} -State Listen -ErrorAction SilentlyContinue | ForEach-Object { $_.OwningProcess } | Sort-Object -Unique`;
|
|
23
|
+
try {
|
|
24
|
+
const out = execFileSync("powershell.exe", ["-NoProfile", "-Command", cmd], {
|
|
25
|
+
encoding: "utf8",
|
|
26
|
+
windowsHide: true,
|
|
27
|
+
});
|
|
28
|
+
return [
|
|
29
|
+
...new Set(
|
|
30
|
+
out
|
|
31
|
+
.trim()
|
|
32
|
+
.split(/\r?\n/)
|
|
33
|
+
.map((s) => s.trim())
|
|
34
|
+
.filter(Boolean)
|
|
35
|
+
.map(Number)
|
|
36
|
+
.filter((n) => Number.isFinite(n) && n > 0),
|
|
37
|
+
),
|
|
38
|
+
];
|
|
39
|
+
} catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function pidsListeningNetstatWin32(p) {
|
|
45
|
+
const pids = new Set();
|
|
46
|
+
try {
|
|
47
|
+
const out = execFileSync("netstat", ["-ano"], { encoding: "utf8", windowsHide: true });
|
|
48
|
+
const needle = `:${p}`;
|
|
49
|
+
for (const line of out.split(/\r?\n/)) {
|
|
50
|
+
if (!line.includes("LISTENING") || !line.includes(needle)) continue;
|
|
51
|
+
const m = line.trim().match(/LISTENING\s+(\d+)\s*$/);
|
|
52
|
+
if (m) pids.add(Number(m[1]));
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// ignore
|
|
56
|
+
}
|
|
57
|
+
return [...pids];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function pidsListeningUnix(p) {
|
|
61
|
+
const argsList = [
|
|
62
|
+
["-nP", `-iTCP:${p}`, "-sTCP:LISTEN", "-t"],
|
|
63
|
+
["-ti", `:${p}`],
|
|
64
|
+
];
|
|
65
|
+
for (const args of argsList) {
|
|
66
|
+
try {
|
|
67
|
+
const out = execFileSync("lsof", args, { encoding: "utf8" });
|
|
68
|
+
const pids = out
|
|
69
|
+
.trim()
|
|
70
|
+
.split(/\n/)
|
|
71
|
+
.map((s) => Number(s.trim()))
|
|
72
|
+
.filter((n) => Number.isFinite(n) && n > 0);
|
|
73
|
+
if (pids.length) return [...new Set(pids)];
|
|
74
|
+
} catch {
|
|
75
|
+
// try next
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function killPidsWin32(pids) {
|
|
82
|
+
for (const pid of pids) {
|
|
83
|
+
if (pid === process.pid) continue;
|
|
84
|
+
try {
|
|
85
|
+
execFileSync("taskkill", ["/F", "/PID", String(pid)], { stdio: "inherit", windowsHide: true });
|
|
86
|
+
console.log(`已结束进程 PID ${pid}`);
|
|
87
|
+
} catch {
|
|
88
|
+
console.warn(`无法结束进程 PID ${pid}(可能已退出)`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function killPidsUnix(pids) {
|
|
94
|
+
for (const pid of pids) {
|
|
95
|
+
if (pid === process.pid) continue;
|
|
96
|
+
try {
|
|
97
|
+
process.kill(pid, "SIGTERM");
|
|
98
|
+
console.log(`已发送 SIGTERM 至 PID ${pid}`);
|
|
99
|
+
} catch {
|
|
100
|
+
try {
|
|
101
|
+
process.kill(pid, "SIGKILL");
|
|
102
|
+
console.log(`已发送 SIGKILL 至 PID ${pid}`);
|
|
103
|
+
} catch {
|
|
104
|
+
console.warn(`无法结束进程 PID ${pid}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function main() {
|
|
111
|
+
console.log(`端口: ${port}(来自 PORT 或默认 ${DEFAULT_PORT})`);
|
|
112
|
+
console.log(`用户数据目录: ${userDir}`);
|
|
113
|
+
|
|
114
|
+
let pids =
|
|
115
|
+
process.platform === "win32" ? pidsListeningWin32(port) : pidsListeningUnix(port);
|
|
116
|
+
if (process.platform === "win32" && pids.length === 0) {
|
|
117
|
+
pids = pidsListeningNetstatWin32(port);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (pids.length === 0) {
|
|
121
|
+
console.log("未发现占用该端口的监听进程。");
|
|
122
|
+
} else {
|
|
123
|
+
console.log(`将结束占用端口的进程: ${pids.join(", ")}`);
|
|
124
|
+
if (process.platform === "win32") killPidsWin32(pids);
|
|
125
|
+
else killPidsUnix(pids);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!existsSync(userDir)) {
|
|
129
|
+
console.log("用户数据目录不存在,跳过删除。");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
rmSync(userDir, { recursive: true, force: true });
|
|
133
|
+
console.log("已删除用户数据目录。");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
main();
|