ffxiv_ptfinder 1.0.1

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.
Files changed (54) hide show
  1. package/Makefile +16 -0
  2. package/README.md +83 -0
  3. package/data/description_tags_ja.json +11 -0
  4. package/data/filter.json +21 -0
  5. package/data/jobs_ja.json +26 -0
  6. package/dist/cli.js +35 -0
  7. package/dist/cliOptions.js +68 -0
  8. package/dist/config.js +16 -0
  9. package/dist/debugCli.js +101 -0
  10. package/dist/descriptionRequirements.js +34 -0
  11. package/dist/descriptionTags.js +17 -0
  12. package/dist/discordWebhook.js +49 -0
  13. package/dist/envOptions.js +52 -0
  14. package/dist/extractListings.js +146 -0
  15. package/dist/fetchHtml.js +44 -0
  16. package/dist/jobMaps.js +13 -0
  17. package/dist/jobs.js +17 -0
  18. package/dist/lambdaHandler.js +31 -0
  19. package/dist/listingsPipeline.js +47 -0
  20. package/dist/lodestone.js +8 -0
  21. package/dist/lodestoneAchievements.js +16 -0
  22. package/dist/lodestoneEnrichment.js +74 -0
  23. package/dist/logger.js +45 -0
  24. package/dist/notifyCli.js +31 -0
  25. package/dist/partyText.js +133 -0
  26. package/dist/runApp.js +95 -0
  27. package/dist/searchFilter.js +184 -0
  28. package/dist/types.js +2 -0
  29. package/duties_all.json +3322 -0
  30. package/fetch-content.sh +44 -0
  31. package/package.json +24 -0
  32. package/samconfig.toml +13 -0
  33. package/src/cliOptions.ts +79 -0
  34. package/src/config.ts +16 -0
  35. package/src/debugCli.ts +124 -0
  36. package/src/descriptionRequirements.ts +36 -0
  37. package/src/descriptionTags.ts +20 -0
  38. package/src/discordWebhook.ts +49 -0
  39. package/src/envOptions.ts +72 -0
  40. package/src/extractListings.ts +121 -0
  41. package/src/fetchHtml.ts +41 -0
  42. package/src/jobMaps.ts +16 -0
  43. package/src/jobs.ts +20 -0
  44. package/src/lambdaHandler.ts +39 -0
  45. package/src/listingsPipeline.ts +50 -0
  46. package/src/lodestoneEnrichment.ts +96 -0
  47. package/src/logger.ts +60 -0
  48. package/src/notifyCli.ts +32 -0
  49. package/src/partyText.ts +161 -0
  50. package/src/runApp.ts +119 -0
  51. package/src/searchFilter.ts +259 -0
  52. package/src/types.ts +23 -0
  53. package/template.yaml +50 -0
  54. package/tsconfig.json +15 -0
package/Makefile ADDED
@@ -0,0 +1,16 @@
1
+ .PHONY: build-PtfinderFunction
2
+
3
+ # SAM build entrypoint for the function.
4
+ build-PtfinderFunction:
5
+ @echo "==> Installing dependencies"
6
+ npm ci
7
+ @echo "==> Building TypeScript"
8
+ npm run -s build
9
+ @echo "==> Pruning devDependencies"
10
+ npm prune --omit=dev
11
+ @echo "==> Copying artifacts"
12
+ cp -R dist $(ARTIFACTS_DIR)/
13
+ cp -R data $(ARTIFACTS_DIR)/
14
+ cp package.json package-lock.json $(ARTIFACTS_DIR)/
15
+ cp -R node_modules $(ARTIFACTS_DIR)/
16
+
package/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # ffxiv_ptfinder
2
+
3
+ FFXIV のパーティ募集(`https://xivpf.com/listings`)を定期取得し、条件に一致した募集を Discord Webhook に送信するツールです。
4
+ AWS SAM + EventBridge(30分間隔)での実行を前提にしています。
5
+
6
+ ## ざっくり仕様
7
+
8
+ - 取得元: `https://xivpf.com/listings`(Cookie `lang=ja` を付与)
9
+ - 対象DC: `Elemental` / `Mana` / `Meteor` / `Gaia`
10
+ - 送信: 1募集 = 1メッセージ(Discord のコードブロック)
11
+ - 送信件数上限: `FFXIV_PTFINDER_LIMIT`(デフォルト 5)
12
+ - 募集者から Lodestone を検索し、先頭ヒットのURLを本文に含めます
13
+ - Lodestone のアチーブページ(カテゴリ4)から「絶/零式」達成状況を判定して本文に含めます
14
+ - 判定: 対象アチーブの項目に日付(`time.entry__activity__time`)が存在するか
15
+
16
+ ## フィルタ(filter.json)
17
+
18
+ 検索条件は `data/filter.json` で管理します(複数行で編集しやすい形式)。
19
+ SAMデプロイ時もこのファイルが同梱され、Lambda 実行時に読み込まれます。
20
+
21
+ 例: `data/filter.json`
22
+
23
+ ```json
24
+ {
25
+ "dutyTitle": { "terms": ["絶もうひとつの未来"], "mode": "and" },
26
+ "requirements": { "terms": ["練習"], "mode": "and" },
27
+ "party": {
28
+ "recruiting": {
29
+ "healer": ["白", "占"],
30
+ "withinRoleMode": "or",
31
+ "acrossRolesMode": "and"
32
+ }
33
+ },
34
+ "description": { "terms": ["最初から", "P1"], "mode": "or" }
35
+ }
36
+ ```
37
+
38
+ 主なキー(抜粋):
39
+
40
+ - `dutyTitle`: コンテンツ名(部分一致, and/or)
41
+ - `requirements`: 要件(`description` から `[Practice]` 等を抽出した日本語ラベル、部分一致, and/or)
42
+ - `description`: 募集文(タグ除去後の本文、部分一致, and/or)
43
+ - `party.recruiting`: 募集中ジョブ(表示に使う略称で指定。例: `白`/`占`/`侍`)
44
+ - `withinRoleMode`: 同一ロール内の OR/AND
45
+ - `acrossRolesMode`: 複数ロール指定時の OR/AND
46
+
47
+ ## 環境変数(Lambda)
48
+
49
+ - `DISCORD_WEBHOOK_URL`(必須): Discord Webhook URL
50
+ - `FFXIV_PTFINDER_LIMIT`(任意): 送信上限(デフォルト 5)
51
+ - `FFXIV_PTFINDER_FILTER_FILE`(任意): フィルタファイルパス(デフォルト `data/filter.json`)
52
+
53
+ ## ローカル実行
54
+
55
+ ### デバッグ(Webhook送信なし、JSON出力)
56
+
57
+ `yarn start` で `https://xivpf.com/listings` を取得し、フィルタ(`data/filter.json`)を反映した募集一覧を JSON で標準出力に出します。
58
+
59
+ ### 通知(Webhook送信)
60
+
61
+ ```sh
62
+ DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/..." yarn notify
63
+ ```
64
+
65
+ ## SAM デプロイ
66
+
67
+ ### ビルド
68
+
69
+ ```sh
70
+ SAM_CLI_HOME=.samcli sam build
71
+ ```
72
+
73
+ ### 初回デプロイ
74
+
75
+ ```sh
76
+ SAM_CLI_HOME=.samcli sam deploy --guided
77
+ ```
78
+
79
+ `samconfig.toml` に設定が保存されるので、2回目以降は次で更新できます。
80
+
81
+ ```sh
82
+ SAM_CLI_HOME=.samcli sam deploy
83
+ ```
@@ -0,0 +1,11 @@
1
+ {
2
+ "version": 1,
3
+ "tags": [
4
+ { "token": "[Practice]", "label": "練習" },
5
+ { "token": "[Duty Completion]", "label": "コンプリート目的" },
6
+ { "token": "[Loot]", "label": "周回" },
7
+ { "token": "[One Player per Job]", "label": "ジョブ重複なし" },
8
+ { "token": "[Duty Complete]", "label": "コンプリート済み" }
9
+ ]
10
+ }
11
+
@@ -0,0 +1,21 @@
1
+ {
2
+ "dutyTitle": {
3
+ "terms": ["絶もうひとつの未来"],
4
+ "mode": "or"
5
+ },
6
+ "requirements": {
7
+ "terms": ["練習"],
8
+ "mode": "and"
9
+ },
10
+ "party": {
11
+ "recruiting": {
12
+ "healer": ["白", "占"],
13
+ "withinRoleMode": "or",
14
+ "acrossRolesMode": "and"
15
+ }
16
+ },
17
+ "description": {
18
+ "terms": ["P2"],
19
+ "mode": "or"
20
+ }
21
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "version": 1,
3
+ "jobs": {
4
+ "DRK": { "short": "暗", "role": "tank" },
5
+ "PLD": { "short": "ナ", "role": "tank" },
6
+ "WAR": { "short": "戦", "role": "tank" },
7
+ "GNB": { "short": "ガ", "role": "tank" },
8
+ "AST": { "short": "占", "role": "healer" },
9
+ "SGE": { "short": "賢", "role": "healer" },
10
+ "SCH": { "short": "学", "role": "healer" },
11
+ "WHM": { "short": "白", "role": "healer" },
12
+ "VPR": { "short": "ヴ", "role": "dps" },
13
+ "DRG": { "short": "竜", "role": "dps" },
14
+ "MNK": { "short": "モ", "role": "dps" },
15
+ "NIN": { "short": "忍", "role": "dps" },
16
+ "SAM": { "short": "侍", "role": "dps" },
17
+ "RPR": { "short": "リ", "role": "dps" },
18
+ "MCH": { "short": "機", "role": "dps" },
19
+ "DNC": { "short": "踊", "role": "dps" },
20
+ "BRD": { "short": "詩", "role": "dps" },
21
+ "BLM": { "short": "黒", "role": "dps" },
22
+ "SMN": { "short": "召", "role": "dps" },
23
+ "RDM": { "short": "赤", "role": "dps" },
24
+ "PCT": { "short": "ピ", "role": "dps" }
25
+ }
26
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const cliOptions_1 = require("./cliOptions");
4
+ const envOptions_1 = require("./envOptions");
5
+ const runApp_1 = require("./runApp");
6
+ /**
7
+ * パイプ出力時(例: `| head`)の EPIPE を無視するハンドラを登録します。
8
+ */
9
+ function installStdoutEpipeHandler() {
10
+ process.stdout.on("error", (err) => {
11
+ if (err.code === "EPIPE")
12
+ process.exit(0);
13
+ throw err;
14
+ });
15
+ }
16
+ /**
17
+ * CLI エントリポイント。
18
+ *
19
+ * Lambda と同じ `runApp` を呼び出して実行します。
20
+ */
21
+ async function main() {
22
+ installStdoutEpipeHandler();
23
+ const cliOptions = (0, cliOptions_1.parseCliArgs)(process.argv.slice(2));
24
+ const envOptions = (0, envOptions_1.readEnvOptions)();
25
+ const options = (0, envOptions_1.resolveOptions)(cliOptions, envOptions);
26
+ await (0, runApp_1.runApp)(options);
27
+ }
28
+ /**
29
+ * トップレベルのエラーハンドラ(スタック/メッセージ出力 + 非0終了コード)。
30
+ */
31
+ main().catch((err) => {
32
+ const message = err instanceof Error ? err.stack ?? err.message : String(err);
33
+ process.stderr.write(message + "\n");
34
+ process.exitCode = 1;
35
+ });
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.printUsageAndExit = printUsageAndExit;
4
+ exports.getUsageText = getUsageText;
5
+ exports.parseCliArgs = parseCliArgs;
6
+ const config_1 = require("./config");
7
+ /**
8
+ * Usage を stderr に出力して終了します。
9
+ */
10
+ function printUsageAndExit(exitCode = 0) {
11
+ process.stderr.write(getUsageText() + "\n");
12
+ process.exit(exitCode);
13
+ }
14
+ /**
15
+ * Usage 文字列を生成します(テストしやすいように純粋関数にしています)。
16
+ */
17
+ function getUsageText() {
18
+ return [
19
+ "Usage:",
20
+ ` yarn start -- [--limit <n>] [--discord]`,
21
+ ` defaults: --limit ${config_1.DEFAULT_WEBHOOK_LIMIT}`,
22
+ "",
23
+ "Environment variables (Lambda-friendly):",
24
+ ` ${config_1.ENV.LIMIT}=<n> (default: ${config_1.DEFAULT_WEBHOOK_LIMIT})`,
25
+ ` ${config_1.ENV.DISCORD_WEBHOOK_URL}=<discord_webhook_url> (required)`,
26
+ ` ${config_1.ENV.FILTER_FILE}=<path> (default: data/filter.json)`,
27
+ "Examples:",
28
+ ` ${config_1.ENV.DISCORD_WEBHOOK_URL}=\"https://discord.com/api/webhooks/...\" yarn notify`,
29
+ ` ${config_1.ENV.LIMIT}=5 ${config_1.ENV.DISCORD_WEBHOOK_URL}=\"https://discord.com/api/webhooks/...\" yarn notify`,
30
+ ` yarn start -- --discord --limit 5`
31
+ ].join("\n");
32
+ }
33
+ /**
34
+ * CLI 引数(argv)をオプションにパースします。
35
+ * ここでは argv の解析のみ行い、環境変数とのマージやデフォルト適用は別モジュールで行います。
36
+ */
37
+ function parseCliArgs(argv) {
38
+ const options = {};
39
+ for (let index = 0; index < argv.length; index++) {
40
+ const arg = argv[index];
41
+ if (arg === "--help" || arg === "-h") {
42
+ printUsageAndExit(0);
43
+ }
44
+ if (arg === "--query" || arg === "-q") {
45
+ options.query = argv[++index];
46
+ continue;
47
+ }
48
+ if (arg === "--discord") {
49
+ options.output = "discord";
50
+ continue;
51
+ }
52
+ if (arg === "--limit" || arg === "-l") {
53
+ const raw = argv[++index];
54
+ const parsed = Number(raw);
55
+ if (!Number.isFinite(parsed) || parsed <= 0) {
56
+ process.stderr.write(`Invalid --limit: ${raw}\n`);
57
+ process.exit(2);
58
+ }
59
+ options.limit = Math.floor(parsed);
60
+ continue;
61
+ }
62
+ if (!options.query) {
63
+ options.query = arg;
64
+ continue;
65
+ }
66
+ }
67
+ return options;
68
+ }
package/dist/config.js ADDED
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ /**
3
+ * CLI 全体で共有するデフォルト値・設定。
4
+ */
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.ENV = exports.ALLOWED_DATA_CENTRES = exports.DEFAULT_FILTER_FILE = exports.DEFAULT_WEBHOOK_LIMIT = exports.DEFAULT_COOKIE = exports.DEFAULT_LISTINGS_URL = void 0;
7
+ exports.DEFAULT_LISTINGS_URL = "https://xivpf.com/listings";
8
+ exports.DEFAULT_COOKIE = "lang=ja";
9
+ exports.DEFAULT_WEBHOOK_LIMIT = 5;
10
+ exports.DEFAULT_FILTER_FILE = "data/filter.json";
11
+ exports.ALLOWED_DATA_CENTRES = new Set(["Elemental", "Mana", "Meteor", "Gaia"]);
12
+ exports.ENV = {
13
+ LIMIT: "FFXIV_PTFINDER_LIMIT",
14
+ DISCORD_WEBHOOK_URL: "DISCORD_WEBHOOK_URL",
15
+ FILTER_FILE: "FFXIV_PTFINDER_FILTER_FILE"
16
+ };
@@ -0,0 +1,101 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const config_1 = require("./config");
4
+ const cliOptions_1 = require("./cliOptions");
5
+ const envOptions_1 = require("./envOptions");
6
+ const jobs_1 = require("./jobs");
7
+ const listingsPipeline_1 = require("./listingsPipeline");
8
+ const discordWebhook_1 = require("./discordWebhook");
9
+ const partyText_1 = require("./partyText");
10
+ const jobMaps_1 = require("./jobMaps");
11
+ const lodestoneEnrichment_1 = require("./lodestoneEnrichment");
12
+ const searchFilter_1 = require("./searchFilter");
13
+ /**
14
+ * パイプ出力時(例: `| head`)の EPIPE を無視するハンドラを登録します。
15
+ */
16
+ function installStdoutEpipeHandler() {
17
+ process.stdout.on("error", (err) => {
18
+ if (err.code === "EPIPE")
19
+ process.exit(0);
20
+ throw err;
21
+ });
22
+ }
23
+ /**
24
+ * デバッグ用エントリポイント。
25
+ *
26
+ * - Webhook 送信はせず、募集一覧を JSON で標準出力に吐き出します。
27
+ * - 入力は固定で `https://xivpf.com/listings` を使用します。
28
+ */
29
+ async function main() {
30
+ installStdoutEpipeHandler();
31
+ const cli = (0, cliOptions_1.parseCliArgs)(process.argv.slice(2));
32
+ const env = (0, envOptions_1.readEnvOptions)();
33
+ const filterFile = env.filterFile ?? config_1.DEFAULT_FILTER_FILE;
34
+ const searchFilter = await (0, searchFilter_1.loadListingSearchFilterFromFile)(filterFile);
35
+ const preFiltered = await (0, listingsPipeline_1.buildListings)({
36
+ input: config_1.DEFAULT_LISTINGS_URL,
37
+ searchFilter
38
+ });
39
+ const limit = cli.limit ?? env.limit;
40
+ const jobsJa = await (0, jobs_1.loadJobsJa)();
41
+ const { codeToShort, codeToRole } = (0, jobMaps_1.buildJobMaps)(jobsJa.jobs);
42
+ const listings = await applyFullFilter({
43
+ listings: preFiltered,
44
+ searchFilter,
45
+ limit,
46
+ codeToShort,
47
+ codeToRole,
48
+ enrichLodestone: cli.output === "discord" || Boolean(searchFilter?.achievements)
49
+ });
50
+ if (cli.output === "discord") {
51
+ process.stdout.write(formatDiscordPreview(listings, codeToShort, codeToRole) + "\n");
52
+ return;
53
+ }
54
+ process.stdout.write(JSON.stringify(listings, null, 2) + "\n");
55
+ }
56
+ /**
57
+ * `filter.json` の内容をできるだけ実運用(Lambda)に近い形で評価します。
58
+ *
59
+ * - party/formattedText はここで評価します(`buildListings` の事前フィルタには含まれないため)
60
+ * - achievements 指定がある場合は、Lodestoneアクセスを行い達成状況を補完します
61
+ */
62
+ async function applyFullFilter(params) {
63
+ const hasSearchFilter = Boolean(params.searchFilter);
64
+ if (!hasSearchFilter)
65
+ return params.limit ? params.listings.slice(0, params.limit) : params.listings;
66
+ const lodestoneCache = params.enrichLodestone ? (0, lodestoneEnrichment_1.createLodestoneEnrichmentCache)() : undefined;
67
+ const matched = [];
68
+ for (const listing of params.listings) {
69
+ const enriched = params.enrichLodestone && lodestoneCache
70
+ ? await (0, lodestoneEnrichment_1.enrichListingWithLodestone)(listing, lodestoneCache)
71
+ : listing;
72
+ const formattedText = (0, partyText_1.formatListingText)(enriched, params.codeToShort, params.codeToRole);
73
+ const ok = await (0, searchFilter_1.matchListing)({
74
+ listing: enriched,
75
+ filter: params.searchFilter,
76
+ formattedText
77
+ });
78
+ if (!ok)
79
+ continue;
80
+ matched.push(enriched);
81
+ if (params.limit && matched.length >= params.limit)
82
+ break;
83
+ }
84
+ return matched;
85
+ }
86
+ /**
87
+ * Webhook送信の本文と同じ形式(コードブロック)で標準出力に出します。
88
+ *
89
+ * - 1募集=1メッセージ想定なので、募集ごとにコードブロックを出します
90
+ * - ヒット0件の場合は、Webhookで送る文言と同じ文言を出します
91
+ */
92
+ function formatDiscordPreview(listings, codeToShort, codeToRole) {
93
+ if (listings.length === 0)
94
+ return "該当の募集は見つかりませんでした";
95
+ return listings.map((l) => (0, discordWebhook_1.toDiscordCodeBlock)((0, partyText_1.formatListingText)(l, codeToShort, codeToRole))).join("\n\n");
96
+ }
97
+ main().catch((err) => {
98
+ const message = err instanceof Error ? err.stack ?? err.message : String(err);
99
+ process.stderr.write(message + "\n");
100
+ process.exitCode = 1;
101
+ });
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeSpaces = normalizeSpaces;
4
+ exports.applyDescriptionTags = applyDescriptionTags;
5
+ /**
6
+ * 表示用に空白を正規化します。
7
+ */
8
+ function normalizeSpaces(value) {
9
+ return value.replace(/\s+/g, " ").trim();
10
+ }
11
+ /**
12
+ * description からタグ(例: `[Practice]`)を検出してラベルを抽出し、
13
+ * 本文からタグトークンを除去します。
14
+ *
15
+ * `Listing.description` を読みやすく保ち、タグの意味は `Listing.requirements` に移します。
16
+ */
17
+ function applyDescriptionTags(listings, tags) {
18
+ return listings.map((listing) => {
19
+ const requirements = new Set();
20
+ let description = listing.description;
21
+ for (const tag of tags) {
22
+ if (!tag.token)
23
+ continue;
24
+ if (description.includes(tag.token))
25
+ requirements.add(tag.label);
26
+ description = description.split(tag.token).join("");
27
+ }
28
+ return {
29
+ ...listing,
30
+ requirements: Array.from(requirements),
31
+ description: normalizeSpaces(description)
32
+ };
33
+ });
34
+ }
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.loadDescriptionTagsJa = loadDescriptionTagsJa;
4
+ const promises_1 = require("node:fs/promises");
5
+ const node_path_1 = require("node:path");
6
+ let cachedTags;
7
+ /**
8
+ * `data/description_tags_ja.json` を読み込みます(初回のみ読み取り、以後はキャッシュ)。
9
+ */
10
+ async function loadDescriptionTagsJa() {
11
+ if (cachedTags)
12
+ return cachedTags;
13
+ const filePath = (0, node_path_1.resolve)(process.cwd(), "data/description_tags_ja.json");
14
+ const raw = await (0, promises_1.readFile)(filePath, "utf8");
15
+ cachedTags = JSON.parse(raw);
16
+ return cachedTags;
17
+ }
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.sleep = sleep;
7
+ exports.toDiscordCodeBlock = toDiscordCodeBlock;
8
+ exports.postDiscordWebhook = postDiscordWebhook;
9
+ const axios_1 = __importDefault(require("axios"));
10
+ /**
11
+ * 指定ミリ秒だけ待機します。
12
+ */
13
+ async function sleep(ms) {
14
+ await new Promise((resolve) => setTimeout(resolve, ms));
15
+ }
16
+ /**
17
+ * Discord のコードブロック形式に変換し、2000文字制限に引っかからないように切り詰めます。
18
+ */
19
+ function toDiscordCodeBlock(text, maxChars = 1900) {
20
+ const sanitized = text.replace(/```/g, "'''").trim();
21
+ const truncated = sanitized.length > maxChars ? sanitized.slice(0, maxChars - 1).trimEnd() + "…" : sanitized;
22
+ return "```\n" + truncated + "\n```";
23
+ }
24
+ async function sendWebhook(webhookUrl, content) {
25
+ await axios_1.default.post(webhookUrl, { content }, {
26
+ headers: { "Content-Type": "application/json" },
27
+ timeout: 30000
28
+ });
29
+ }
30
+ /**
31
+ * Discord Webhook に送信します(レート制限: HTTP 429 の場合は 1 回だけリトライします)。
32
+ */
33
+ async function postDiscordWebhook(webhookUrl, content) {
34
+ try {
35
+ await sendWebhook(webhookUrl, content);
36
+ return;
37
+ }
38
+ catch (err) {
39
+ if (!axios_1.default.isAxiosError(err))
40
+ throw err;
41
+ if (err.response?.status !== 429)
42
+ throw err;
43
+ const retryAfterMs = typeof err.response.data?.retry_after === "number"
44
+ ? Math.ceil(err.response.data.retry_after * 1000)
45
+ : 1500;
46
+ await sleep(retryAfterMs);
47
+ await sendWebhook(webhookUrl, content);
48
+ }
49
+ }
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getEnvString = getEnvString;
4
+ exports.parsePositiveInt = parsePositiveInt;
5
+ exports.readEnvOptions = readEnvOptions;
6
+ exports.resolveOptions = resolveOptions;
7
+ const config_1 = require("./config");
8
+ /**
9
+ * 環境変数を読み取り、前後空白を除いた文字列を返します(空なら undefined)。
10
+ */
11
+ function getEnvString(name) {
12
+ const value = process.env[name];
13
+ const trimmed = typeof value === "string" ? value.trim() : "";
14
+ return trimmed.length > 0 ? trimmed : undefined;
15
+ }
16
+ /**
17
+ * 正の整数として解釈できる場合のみ数値を返します。
18
+ */
19
+ function parsePositiveInt(value) {
20
+ if (!value)
21
+ return undefined;
22
+ const parsed = Number(value);
23
+ if (!Number.isFinite(parsed) || parsed <= 0)
24
+ return undefined;
25
+ return Math.floor(parsed);
26
+ }
27
+ /**
28
+ * 環境変数からオプションを読み取ります(デフォルト値はここでは適用しません)。
29
+ */
30
+ function readEnvOptions() {
31
+ return {
32
+ limit: parsePositiveInt(getEnvString(config_1.ENV.LIMIT)),
33
+ filterFile: getEnvString(config_1.ENV.FILTER_FILE)
34
+ };
35
+ }
36
+ /**
37
+ * CLIオプションを優先して環境変数オプションとマージし、デフォルト値を適用します。
38
+ */
39
+ function resolveOptions(cli, env) {
40
+ const webhookUrl = getEnvString(config_1.ENV.DISCORD_WEBHOOK_URL);
41
+ if (!webhookUrl) {
42
+ throw new Error(`${config_1.ENV.DISCORD_WEBHOOK_URL} is required.`);
43
+ }
44
+ const searchFilterFile = env.filterFile ?? config_1.DEFAULT_FILTER_FILE;
45
+ return {
46
+ input: config_1.DEFAULT_LISTINGS_URL,
47
+ webhookUrl,
48
+ limit: cli.limit ?? env.limit ?? config_1.DEFAULT_WEBHOOK_LIMIT,
49
+ searchFilter: undefined,
50
+ searchFilterFile
51
+ };
52
+ }