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.
- package/Makefile +16 -0
- package/README.md +83 -0
- package/data/description_tags_ja.json +11 -0
- package/data/filter.json +21 -0
- package/data/jobs_ja.json +26 -0
- package/dist/cli.js +35 -0
- package/dist/cliOptions.js +68 -0
- package/dist/config.js +16 -0
- package/dist/debugCli.js +101 -0
- package/dist/descriptionRequirements.js +34 -0
- package/dist/descriptionTags.js +17 -0
- package/dist/discordWebhook.js +49 -0
- package/dist/envOptions.js +52 -0
- package/dist/extractListings.js +146 -0
- package/dist/fetchHtml.js +44 -0
- package/dist/jobMaps.js +13 -0
- package/dist/jobs.js +17 -0
- package/dist/lambdaHandler.js +31 -0
- package/dist/listingsPipeline.js +47 -0
- package/dist/lodestone.js +8 -0
- package/dist/lodestoneAchievements.js +16 -0
- package/dist/lodestoneEnrichment.js +74 -0
- package/dist/logger.js +45 -0
- package/dist/notifyCli.js +31 -0
- package/dist/partyText.js +133 -0
- package/dist/runApp.js +95 -0
- package/dist/searchFilter.js +184 -0
- package/dist/types.js +2 -0
- package/duties_all.json +3322 -0
- package/fetch-content.sh +44 -0
- package/package.json +24 -0
- package/samconfig.toml +13 -0
- package/src/cliOptions.ts +79 -0
- package/src/config.ts +16 -0
- package/src/debugCli.ts +124 -0
- package/src/descriptionRequirements.ts +36 -0
- package/src/descriptionTags.ts +20 -0
- package/src/discordWebhook.ts +49 -0
- package/src/envOptions.ts +72 -0
- package/src/extractListings.ts +121 -0
- package/src/fetchHtml.ts +41 -0
- package/src/jobMaps.ts +16 -0
- package/src/jobs.ts +20 -0
- package/src/lambdaHandler.ts +39 -0
- package/src/listingsPipeline.ts +50 -0
- package/src/lodestoneEnrichment.ts +96 -0
- package/src/logger.ts +60 -0
- package/src/notifyCli.ts +32 -0
- package/src/partyText.ts +161 -0
- package/src/runApp.ts +119 -0
- package/src/searchFilter.ts +259 -0
- package/src/types.ts +23 -0
- package/template.yaml +50 -0
- package/tsconfig.json +15 -0
package/fetch-content.sh
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
BASE="https://v2.xivapi.com/api/sheet/ContentFinderCondition"
|
|
5
|
+
after=0
|
|
6
|
+
|
|
7
|
+
TMP_NDJSON="duties_all.tmp.ndjson"
|
|
8
|
+
OUT_JSON="duties_all.json"
|
|
9
|
+
|
|
10
|
+
# 一旦 tmp を消す
|
|
11
|
+
rm -f "$TMP_NDJSON"
|
|
12
|
+
|
|
13
|
+
echo "Fetching duties from XIVAPI..."
|
|
14
|
+
|
|
15
|
+
while :; do
|
|
16
|
+
echo " - after=${after} で取得中..."
|
|
17
|
+
res=$(curl -s "${BASE}?fields=Name&language=ja&limit=200&after=${after}")
|
|
18
|
+
|
|
19
|
+
# このページの rows を NDJSON 形式で追記
|
|
20
|
+
echo "$res" \
|
|
21
|
+
| jq -c '.rows[] | select(.fields.Name != "") | {duty_id: .row_id, duty_name: .fields.Name}' \
|
|
22
|
+
>> "$TMP_NDJSON"
|
|
23
|
+
|
|
24
|
+
# 取れた件数確認
|
|
25
|
+
count=$(echo "$res" | jq '.rows | length')
|
|
26
|
+
|
|
27
|
+
# 200件未満になったら最後のページ
|
|
28
|
+
if [ "$count" -lt 200 ]; then
|
|
29
|
+
break
|
|
30
|
+
fi
|
|
31
|
+
|
|
32
|
+
# 次ページ用の after を最後の row_id に更新
|
|
33
|
+
after=$(echo "$res" | jq '.rows[-1].row_id')
|
|
34
|
+
done
|
|
35
|
+
|
|
36
|
+
echo "Building pretty JSON..."
|
|
37
|
+
|
|
38
|
+
# NDJSON を配列にまとめて、キーを duty_id 順にソートして整形出力
|
|
39
|
+
jq -s 'sort_by(.duty_id)' "$TMP_NDJSON" > "$OUT_JSON"
|
|
40
|
+
|
|
41
|
+
# tmp は削除
|
|
42
|
+
rm -f "$TMP_NDJSON"
|
|
43
|
+
|
|
44
|
+
echo "✅ Done: ${OUT_JSON}"
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ffxiv_ptfinder",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"main": "dist/cli.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "tsc -p tsconfig.json",
|
|
8
|
+
"start": "tsx src/debugCli.ts",
|
|
9
|
+
"dev": "tsx watch src/debugCli.ts",
|
|
10
|
+
"notify": "tsx src/notifyCli.ts",
|
|
11
|
+
"sam:build": "SAM_CLI_HOME=.samcli sam build",
|
|
12
|
+
"sam:deploy": "SAM_CLI_HOME=.samcli sam deploy --guided"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@piyoraik/ffxiv-lodestone-character-lookup": "^1.0.0",
|
|
16
|
+
"axios": "^1.7.9",
|
|
17
|
+
"cheerio": "^1.1.2"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/node": "^22.10.2",
|
|
21
|
+
"tsx": "^4.20.0",
|
|
22
|
+
"typescript": "^5.7.2"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/samconfig.toml
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
version = 0.1
|
|
2
|
+
|
|
3
|
+
[default.deploy.parameters]
|
|
4
|
+
stack_name = "ffxiv-finder-ultima"
|
|
5
|
+
resolve_s3 = true
|
|
6
|
+
s3_prefix = "ffxiv-finder-ultima"
|
|
7
|
+
region = "ap-northeast-1"
|
|
8
|
+
capabilities = "CAPABILITY_IAM"
|
|
9
|
+
parameter_overrides = "Limit=\"5\" FilterFile=\"data/filter.json\""
|
|
10
|
+
image_repositories = []
|
|
11
|
+
|
|
12
|
+
[default.global.parameters]
|
|
13
|
+
region = "ap-northeast-1"
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { DEFAULT_LISTINGS_URL, DEFAULT_WEBHOOK_LIMIT, ENV } from "./config";
|
|
2
|
+
|
|
3
|
+
export type CliOptions = {
|
|
4
|
+
query?: string;
|
|
5
|
+
limit?: number;
|
|
6
|
+
output?: "json" | "discord";
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Usage を stderr に出力して終了します。
|
|
11
|
+
*/
|
|
12
|
+
export function printUsageAndExit(exitCode = 0): never {
|
|
13
|
+
process.stderr.write(getUsageText() + "\n");
|
|
14
|
+
process.exit(exitCode);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Usage 文字列を生成します(テストしやすいように純粋関数にしています)。
|
|
19
|
+
*/
|
|
20
|
+
export function getUsageText(): string {
|
|
21
|
+
return [
|
|
22
|
+
"Usage:",
|
|
23
|
+
` yarn start -- [--limit <n>] [--discord]`,
|
|
24
|
+
` defaults: --limit ${DEFAULT_WEBHOOK_LIMIT}`,
|
|
25
|
+
"",
|
|
26
|
+
"Environment variables (Lambda-friendly):",
|
|
27
|
+
` ${ENV.LIMIT}=<n> (default: ${DEFAULT_WEBHOOK_LIMIT})`,
|
|
28
|
+
` ${ENV.DISCORD_WEBHOOK_URL}=<discord_webhook_url> (required)`,
|
|
29
|
+
` ${ENV.FILTER_FILE}=<path> (default: data/filter.json)`,
|
|
30
|
+
"Examples:",
|
|
31
|
+
` ${ENV.DISCORD_WEBHOOK_URL}=\"https://discord.com/api/webhooks/...\" yarn notify`,
|
|
32
|
+
` ${ENV.LIMIT}=5 ${ENV.DISCORD_WEBHOOK_URL}=\"https://discord.com/api/webhooks/...\" yarn notify`,
|
|
33
|
+
` yarn start -- --discord --limit 5`
|
|
34
|
+
].join("\n");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* CLI 引数(argv)をオプションにパースします。
|
|
39
|
+
* ここでは argv の解析のみ行い、環境変数とのマージやデフォルト適用は別モジュールで行います。
|
|
40
|
+
*/
|
|
41
|
+
export function parseCliArgs(argv: string[]): CliOptions {
|
|
42
|
+
const options: CliOptions = {};
|
|
43
|
+
|
|
44
|
+
for (let index = 0; index < argv.length; index++) {
|
|
45
|
+
const arg = argv[index];
|
|
46
|
+
|
|
47
|
+
if (arg === "--help" || arg === "-h") {
|
|
48
|
+
printUsageAndExit(0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (arg === "--query" || arg === "-q") {
|
|
52
|
+
options.query = argv[++index];
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (arg === "--discord") {
|
|
57
|
+
options.output = "discord";
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (arg === "--limit" || arg === "-l") {
|
|
62
|
+
const raw = argv[++index];
|
|
63
|
+
const parsed = Number(raw);
|
|
64
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
65
|
+
process.stderr.write(`Invalid --limit: ${raw}\n`);
|
|
66
|
+
process.exit(2);
|
|
67
|
+
}
|
|
68
|
+
options.limit = Math.floor(parsed);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!options.query) {
|
|
73
|
+
options.query = arg;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return options;
|
|
79
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI 全体で共有するデフォルト値・設定。
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_LISTINGS_URL = "https://xivpf.com/listings";
|
|
6
|
+
export const DEFAULT_COOKIE = "lang=ja";
|
|
7
|
+
export const DEFAULT_WEBHOOK_LIMIT = 5;
|
|
8
|
+
export const DEFAULT_FILTER_FILE = "data/filter.json";
|
|
9
|
+
|
|
10
|
+
export const ALLOWED_DATA_CENTRES = new Set(["Elemental", "Mana", "Meteor", "Gaia"]);
|
|
11
|
+
|
|
12
|
+
export const ENV = {
|
|
13
|
+
LIMIT: "FFXIV_PTFINDER_LIMIT",
|
|
14
|
+
DISCORD_WEBHOOK_URL: "DISCORD_WEBHOOK_URL",
|
|
15
|
+
FILTER_FILE: "FFXIV_PTFINDER_FILTER_FILE"
|
|
16
|
+
} as const;
|
package/src/debugCli.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { DEFAULT_FILTER_FILE, DEFAULT_LISTINGS_URL } from "./config";
|
|
2
|
+
import { parseCliArgs } from "./cliOptions";
|
|
3
|
+
import { readEnvOptions } from "./envOptions";
|
|
4
|
+
import { loadJobsJa } from "./jobs";
|
|
5
|
+
import { buildListings } from "./listingsPipeline";
|
|
6
|
+
import { toDiscordCodeBlock } from "./discordWebhook";
|
|
7
|
+
import { formatListingText, type PartyRole } from "./partyText";
|
|
8
|
+
import { buildJobMaps } from "./jobMaps";
|
|
9
|
+
import { createLodestoneEnrichmentCache, enrichListingWithLodestone } from "./lodestoneEnrichment";
|
|
10
|
+
import { loadListingSearchFilterFromFile, matchListing } from "./searchFilter";
|
|
11
|
+
import type { ListingSearchFilter } from "./searchFilter";
|
|
12
|
+
import type { Listing } from "./types";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* パイプ出力時(例: `| head`)の EPIPE を無視するハンドラを登録します。
|
|
16
|
+
*/
|
|
17
|
+
function installStdoutEpipeHandler(): void {
|
|
18
|
+
process.stdout.on("error", (err: NodeJS.ErrnoException) => {
|
|
19
|
+
if (err.code === "EPIPE") process.exit(0);
|
|
20
|
+
throw err;
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* デバッグ用エントリポイント。
|
|
26
|
+
*
|
|
27
|
+
* - Webhook 送信はせず、募集一覧を JSON で標準出力に吐き出します。
|
|
28
|
+
* - 入力は固定で `https://xivpf.com/listings` を使用します。
|
|
29
|
+
*/
|
|
30
|
+
async function main(): Promise<void> {
|
|
31
|
+
installStdoutEpipeHandler();
|
|
32
|
+
|
|
33
|
+
const cli = parseCliArgs(process.argv.slice(2));
|
|
34
|
+
const env = readEnvOptions();
|
|
35
|
+
const filterFile = env.filterFile ?? DEFAULT_FILTER_FILE;
|
|
36
|
+
const searchFilter = await loadListingSearchFilterFromFile(filterFile);
|
|
37
|
+
|
|
38
|
+
const preFiltered = await buildListings({
|
|
39
|
+
input: DEFAULT_LISTINGS_URL,
|
|
40
|
+
searchFilter
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const limit = cli.limit ?? env.limit;
|
|
44
|
+
const jobsJa = await loadJobsJa();
|
|
45
|
+
const { codeToShort, codeToRole } = buildJobMaps(jobsJa.jobs);
|
|
46
|
+
|
|
47
|
+
const listings = await applyFullFilter({
|
|
48
|
+
listings: preFiltered,
|
|
49
|
+
searchFilter,
|
|
50
|
+
limit,
|
|
51
|
+
codeToShort,
|
|
52
|
+
codeToRole,
|
|
53
|
+
enrichLodestone: cli.output === "discord" || Boolean(searchFilter?.achievements)
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
if (cli.output === "discord") {
|
|
57
|
+
process.stdout.write(formatDiscordPreview(listings, codeToShort, codeToRole) + "\n");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
process.stdout.write(JSON.stringify(listings, null, 2) + "\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `filter.json` の内容をできるだけ実運用(Lambda)に近い形で評価します。
|
|
66
|
+
*
|
|
67
|
+
* - party/formattedText はここで評価します(`buildListings` の事前フィルタには含まれないため)
|
|
68
|
+
* - achievements 指定がある場合は、Lodestoneアクセスを行い達成状況を補完します
|
|
69
|
+
*/
|
|
70
|
+
async function applyFullFilter(params: {
|
|
71
|
+
listings: Listing[];
|
|
72
|
+
searchFilter: ListingSearchFilter | undefined;
|
|
73
|
+
limit: number | undefined;
|
|
74
|
+
codeToShort: Map<string, string>;
|
|
75
|
+
codeToRole: Map<string, PartyRole>;
|
|
76
|
+
enrichLodestone: boolean;
|
|
77
|
+
}): Promise<Listing[]> {
|
|
78
|
+
const hasSearchFilter = Boolean(params.searchFilter);
|
|
79
|
+
if (!hasSearchFilter) return params.limit ? params.listings.slice(0, params.limit) : params.listings;
|
|
80
|
+
|
|
81
|
+
const lodestoneCache = params.enrichLodestone ? createLodestoneEnrichmentCache() : undefined;
|
|
82
|
+
|
|
83
|
+
const matched: Listing[] = [];
|
|
84
|
+
for (const listing of params.listings) {
|
|
85
|
+
const enriched =
|
|
86
|
+
params.enrichLodestone && lodestoneCache
|
|
87
|
+
? await enrichListingWithLodestone(listing, lodestoneCache)
|
|
88
|
+
: listing;
|
|
89
|
+
|
|
90
|
+
const formattedText = formatListingText(enriched, params.codeToShort, params.codeToRole);
|
|
91
|
+
const ok = await matchListing({
|
|
92
|
+
listing: enriched,
|
|
93
|
+
filter: params.searchFilter,
|
|
94
|
+
formattedText
|
|
95
|
+
});
|
|
96
|
+
if (!ok) continue;
|
|
97
|
+
|
|
98
|
+
matched.push(enriched);
|
|
99
|
+
if (params.limit && matched.length >= params.limit) break;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return matched;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Webhook送信の本文と同じ形式(コードブロック)で標準出力に出します。
|
|
107
|
+
*
|
|
108
|
+
* - 1募集=1メッセージ想定なので、募集ごとにコードブロックを出します
|
|
109
|
+
* - ヒット0件の場合は、Webhookで送る文言と同じ文言を出します
|
|
110
|
+
*/
|
|
111
|
+
function formatDiscordPreview(
|
|
112
|
+
listings: Listing[],
|
|
113
|
+
codeToShort: Map<string, string>,
|
|
114
|
+
codeToRole: Map<string, PartyRole>
|
|
115
|
+
): string {
|
|
116
|
+
if (listings.length === 0) return "該当の募集は見つかりませんでした";
|
|
117
|
+
return listings.map((l) => toDiscordCodeBlock(formatListingText(l, codeToShort, codeToRole))).join("\n\n");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
main().catch((err: unknown) => {
|
|
121
|
+
const message = err instanceof Error ? err.stack ?? err.message : String(err);
|
|
122
|
+
process.stderr.write(message + "\n");
|
|
123
|
+
process.exitCode = 1;
|
|
124
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Listing } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 表示用に空白を正規化します。
|
|
5
|
+
*/
|
|
6
|
+
export function normalizeSpaces(value: string): string {
|
|
7
|
+
return value.replace(/\s+/g, " ").trim();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* description からタグ(例: `[Practice]`)を検出してラベルを抽出し、
|
|
12
|
+
* 本文からタグトークンを除去します。
|
|
13
|
+
*
|
|
14
|
+
* `Listing.description` を読みやすく保ち、タグの意味は `Listing.requirements` に移します。
|
|
15
|
+
*/
|
|
16
|
+
export function applyDescriptionTags(
|
|
17
|
+
listings: Listing[],
|
|
18
|
+
tags: Array<{ token: string; label: string }>
|
|
19
|
+
): Listing[] {
|
|
20
|
+
return listings.map((listing) => {
|
|
21
|
+
const requirements = new Set<string>();
|
|
22
|
+
let description = listing.description;
|
|
23
|
+
|
|
24
|
+
for (const tag of tags) {
|
|
25
|
+
if (!tag.token) continue;
|
|
26
|
+
if (description.includes(tag.token)) requirements.add(tag.label);
|
|
27
|
+
description = description.split(tag.token).join("");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
...listing,
|
|
32
|
+
requirements: Array.from(requirements),
|
|
33
|
+
description: normalizeSpaces(description)
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
export type DescriptionTagsJa = {
|
|
5
|
+
version: number;
|
|
6
|
+
tags: Array<{ token: string; label: string }>;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
let cachedTags: DescriptionTagsJa | undefined;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `data/description_tags_ja.json` を読み込みます(初回のみ読み取り、以後はキャッシュ)。
|
|
13
|
+
*/
|
|
14
|
+
export async function loadDescriptionTagsJa(): Promise<DescriptionTagsJa> {
|
|
15
|
+
if (cachedTags) return cachedTags;
|
|
16
|
+
const filePath = resolve(process.cwd(), "data/description_tags_ja.json");
|
|
17
|
+
const raw = await readFile(filePath, "utf8");
|
|
18
|
+
cachedTags = JSON.parse(raw) as DescriptionTagsJa;
|
|
19
|
+
return cachedTags;
|
|
20
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 指定ミリ秒だけ待機します。
|
|
5
|
+
*/
|
|
6
|
+
export async function sleep(ms: number): Promise<void> {
|
|
7
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Discord のコードブロック形式に変換し、2000文字制限に引っかからないように切り詰めます。
|
|
12
|
+
*/
|
|
13
|
+
export function toDiscordCodeBlock(text: string, maxChars = 1900): string {
|
|
14
|
+
const sanitized = text.replace(/```/g, "'''").trim();
|
|
15
|
+
const truncated =
|
|
16
|
+
sanitized.length > maxChars ? sanitized.slice(0, maxChars - 1).trimEnd() + "…" : sanitized;
|
|
17
|
+
return "```\n" + truncated + "\n```";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function sendWebhook(webhookUrl: string, content: string): Promise<void> {
|
|
21
|
+
await axios.post(
|
|
22
|
+
webhookUrl,
|
|
23
|
+
{ content },
|
|
24
|
+
{
|
|
25
|
+
headers: { "Content-Type": "application/json" },
|
|
26
|
+
timeout: 30_000
|
|
27
|
+
}
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Discord Webhook に送信します(レート制限: HTTP 429 の場合は 1 回だけリトライします)。
|
|
33
|
+
*/
|
|
34
|
+
export async function postDiscordWebhook(webhookUrl: string, content: string): Promise<void> {
|
|
35
|
+
try {
|
|
36
|
+
await sendWebhook(webhookUrl, content);
|
|
37
|
+
return;
|
|
38
|
+
} catch (err: unknown) {
|
|
39
|
+
if (!axios.isAxiosError(err)) throw err;
|
|
40
|
+
if (err.response?.status !== 429) throw err;
|
|
41
|
+
|
|
42
|
+
const retryAfterMs =
|
|
43
|
+
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,72 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_FILTER_FILE,
|
|
3
|
+
DEFAULT_LISTINGS_URL,
|
|
4
|
+
DEFAULT_WEBHOOK_LIMIT,
|
|
5
|
+
ENV
|
|
6
|
+
} from "./config";
|
|
7
|
+
import type { CliOptions } from "./cliOptions";
|
|
8
|
+
import { type ListingSearchFilter } from "./searchFilter";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 環境変数を読み取り、前後空白を除いた文字列を返します(空なら undefined)。
|
|
12
|
+
*/
|
|
13
|
+
export function getEnvString(name: string): string | undefined {
|
|
14
|
+
const value = process.env[name];
|
|
15
|
+
const trimmed = typeof value === "string" ? value.trim() : "";
|
|
16
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 正の整数として解釈できる場合のみ数値を返します。
|
|
21
|
+
*/
|
|
22
|
+
export function parsePositiveInt(value: string | undefined): number | undefined {
|
|
23
|
+
if (!value) return undefined;
|
|
24
|
+
const parsed = Number(value);
|
|
25
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
|
|
26
|
+
return Math.floor(parsed);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 環境変数からオプションを読み取ります(デフォルト値はここでは適用しません)。
|
|
31
|
+
*/
|
|
32
|
+
export function readEnvOptions(): Partial<CliOptions> & {
|
|
33
|
+
filterFile?: string;
|
|
34
|
+
} {
|
|
35
|
+
return {
|
|
36
|
+
limit: parsePositiveInt(getEnvString(ENV.LIMIT)),
|
|
37
|
+
filterFile: getEnvString(ENV.FILTER_FILE)
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type ResolvedCliOptions = {
|
|
42
|
+
input: string;
|
|
43
|
+
webhookUrl: string;
|
|
44
|
+
limit: number;
|
|
45
|
+
searchFilter?: ListingSearchFilter;
|
|
46
|
+
searchFilterFile: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* CLIオプションを優先して環境変数オプションとマージし、デフォルト値を適用します。
|
|
51
|
+
*/
|
|
52
|
+
export function resolveOptions(
|
|
53
|
+
cli: CliOptions,
|
|
54
|
+
env: Partial<CliOptions> & {
|
|
55
|
+
filterFile?: string;
|
|
56
|
+
}
|
|
57
|
+
): ResolvedCliOptions {
|
|
58
|
+
const webhookUrl = getEnvString(ENV.DISCORD_WEBHOOK_URL);
|
|
59
|
+
if (!webhookUrl) {
|
|
60
|
+
throw new Error(`${ENV.DISCORD_WEBHOOK_URL} is required.`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const searchFilterFile = env.filterFile ?? DEFAULT_FILTER_FILE;
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
input: DEFAULT_LISTINGS_URL,
|
|
67
|
+
webhookUrl,
|
|
68
|
+
limit: cli.limit ?? env.limit ?? DEFAULT_WEBHOOK_LIMIT,
|
|
69
|
+
searchFilter: undefined,
|
|
70
|
+
searchFilterFile
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import * as cheerio from "cheerio";
|
|
2
|
+
import type { CheerioAPI } from "cheerio";
|
|
3
|
+
import type { Listing, PartyItem } from "./types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* HTML から取り出したテキストを、出力用に読みやすく正規化します。
|
|
7
|
+
*/
|
|
8
|
+
function normalizeText(text: string): string {
|
|
9
|
+
return text
|
|
10
|
+
.replace(/\r\n/g, "\n")
|
|
11
|
+
.replace(/[ \t]+/g, " ")
|
|
12
|
+
.replace(/\n[ \t]+/g, "\n")
|
|
13
|
+
.trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* class 属性(スペース区切り)を配列に分解します。
|
|
18
|
+
*/
|
|
19
|
+
function splitClassList(value: string | undefined): string[] {
|
|
20
|
+
return (value ?? "").split(/\s+/).map((s) => s.trim()).filter(Boolean);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 募集一覧の要素(`.listing[data-id]`)を取得します。
|
|
25
|
+
* 可能なら `#listings` 配下から取り、なければドキュメント全体から探索します。
|
|
26
|
+
*/
|
|
27
|
+
function selectListingElements($: CheerioAPI) {
|
|
28
|
+
const listingsRoot = $("#listings");
|
|
29
|
+
return listingsRoot.length
|
|
30
|
+
? listingsRoot.find("div.listing[data-id]")
|
|
31
|
+
: $("div.listing[data-id]");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `.listing` 要素から duty 情報(class と title)を抽出します。
|
|
36
|
+
*/
|
|
37
|
+
function parseDuty(listing: cheerio.Cheerio<any>): Listing["duty"] | undefined {
|
|
38
|
+
const dutyNode = listing.find(".duty").first();
|
|
39
|
+
if (!dutyNode.length) return undefined;
|
|
40
|
+
|
|
41
|
+
const text = normalizeText(dutyNode.text());
|
|
42
|
+
const title = (dutyNode.attr("title") ?? text).trim();
|
|
43
|
+
if (!title) return undefined;
|
|
44
|
+
|
|
45
|
+
return { classList: splitClassList(dutyNode.attr("class")), title };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `.listing` 要素から description テキストを抽出します。
|
|
50
|
+
*/
|
|
51
|
+
function parseDescription(listing: cheerio.Cheerio<any>): string {
|
|
52
|
+
const descriptionText = listing.find(".description").first().text();
|
|
53
|
+
return normalizeText(descriptionText);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* `.listing` 要素から募集者(creator)を抽出します。
|
|
58
|
+
*/
|
|
59
|
+
function parseCreator(listing: cheerio.Cheerio<any>): string | undefined {
|
|
60
|
+
const creatorText = listing.find(".right.meta .item.creator .text").first().text();
|
|
61
|
+
const normalized = normalizeText(creatorText);
|
|
62
|
+
return normalized.length > 0 ? normalized : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* `.listing` 要素から party 情報(各 slot の class と title)を抽出します。
|
|
67
|
+
*/
|
|
68
|
+
function parseParty($: CheerioAPI, listing: cheerio.Cheerio<any>): PartyItem[] {
|
|
69
|
+
const party: PartyItem[] = [];
|
|
70
|
+
const partyRoot = listing.find(".party").first();
|
|
71
|
+
if (!partyRoot.length) return party;
|
|
72
|
+
|
|
73
|
+
partyRoot.children("div").each((_, child) => {
|
|
74
|
+
const node = $(child);
|
|
75
|
+
const classList = splitClassList(node.attr("class"));
|
|
76
|
+
if (classList.length === 0) return;
|
|
77
|
+
const title = node.attr("title") ?? undefined;
|
|
78
|
+
party.push(title ? { classList, title } : { classList });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return party;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* `.listing` 要素を `Listing` に変換します。
|
|
86
|
+
*/
|
|
87
|
+
function parseListingElement($: CheerioAPI, el: unknown): Listing | undefined {
|
|
88
|
+
const listing = $(el as any);
|
|
89
|
+
const id = listing.attr("data-id");
|
|
90
|
+
if (!id) return undefined;
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
id,
|
|
94
|
+
dataCentre: listing.attr("data-centre") ?? undefined,
|
|
95
|
+
dataPfCategory: listing.attr("data-pf-category") ?? undefined,
|
|
96
|
+
duty: parseDuty(listing),
|
|
97
|
+
creator: parseCreator(listing),
|
|
98
|
+
requirements: [],
|
|
99
|
+
description: parseDescription(listing),
|
|
100
|
+
party: parseParty($, listing)
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* listings HTML から募集一覧を抽出します。
|
|
106
|
+
*/
|
|
107
|
+
export function extractListingsFromHtml(html: string): Listing[] {
|
|
108
|
+
const $ = cheerio.load(html);
|
|
109
|
+
|
|
110
|
+
const results: Listing[] = [];
|
|
111
|
+
const seenIds = new Set<string>();
|
|
112
|
+
selectListingElements($).each((_, el) => {
|
|
113
|
+
const parsed = parseListingElement($, el);
|
|
114
|
+
if (!parsed) return;
|
|
115
|
+
if (seenIds.has(parsed.id)) return;
|
|
116
|
+
seenIds.add(parsed.id);
|
|
117
|
+
results.push(parsed);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return results;
|
|
121
|
+
}
|
package/src/fetchHtml.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { DEFAULT_COOKIE, DEFAULT_LISTINGS_URL } from "./config";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 入力が HTTP(S) URL っぽい場合に true を返します。
|
|
7
|
+
*/
|
|
8
|
+
export function isHttpUrl(value: string): boolean {
|
|
9
|
+
return /^https?:\/\//i.test(value);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* HTML をローカルファイル、または URL から読み込みます。
|
|
14
|
+
*
|
|
15
|
+
* URL の場合は `Cookie: lang=ja` を付与します。
|
|
16
|
+
* ※ Lambda(Node.js) の CommonJS から ESM 専用ライブラリを require できない問題を避けるため、
|
|
17
|
+
* CookieJar は使わずヘッダで渡します(xivpf は通常リダイレクトしない想定)。
|
|
18
|
+
*/
|
|
19
|
+
export async function loadHtml(input: string): Promise<string> {
|
|
20
|
+
if (!isHttpUrl(input)) {
|
|
21
|
+
return await readFile(input, "utf8");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const response = await axios.get<string>(input, {
|
|
25
|
+
responseType: "text",
|
|
26
|
+
maxRedirects: 5,
|
|
27
|
+
beforeRedirect: (options) => {
|
|
28
|
+
options.headers = options.headers ?? {};
|
|
29
|
+
options.headers["Cookie"] = DEFAULT_COOKIE;
|
|
30
|
+
options.headers["Accept-Language"] = "ja,en;q=0.8";
|
|
31
|
+
},
|
|
32
|
+
headers: {
|
|
33
|
+
Accept: "text/html,application/xhtml+xml",
|
|
34
|
+
"Accept-Language": "ja,en;q=0.8",
|
|
35
|
+
Cookie: DEFAULT_COOKIE,
|
|
36
|
+
"User-Agent": "ffxiv_ptfinder/1.0 (+https://xivpf.com)"
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return response.data;
|
|
41
|
+
}
|
package/src/jobMaps.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { PartyRole } from "./partyText";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* party の整形に使うジョブ変換マップを作成します。
|
|
5
|
+
*/
|
|
6
|
+
export function buildJobMaps(jobs: Record<string, { short: string; role: PartyRole }>): {
|
|
7
|
+
codeToShort: Map<string, string>;
|
|
8
|
+
codeToRole: Map<string, PartyRole>;
|
|
9
|
+
} {
|
|
10
|
+
const entries = Object.entries(jobs);
|
|
11
|
+
return {
|
|
12
|
+
codeToShort: new Map(entries.map(([code, info]) => [code, info.short])),
|
|
13
|
+
codeToRole: new Map(entries.map(([code, info]) => [code, info.role]))
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
package/src/jobs.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
export type JobsJa = {
|
|
5
|
+
version: number;
|
|
6
|
+
jobs: Record<string, { short: string; role: "tank" | "healer" | "dps" }>;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
let cachedJobs: JobsJa | undefined;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `data/jobs_ja.json` を読み込みます(初回のみ読み取り、以後はキャッシュ)。
|
|
13
|
+
*/
|
|
14
|
+
export async function loadJobsJa(): Promise<JobsJa> {
|
|
15
|
+
if (cachedJobs) return cachedJobs;
|
|
16
|
+
const filePath = resolve(process.cwd(), "data/jobs_ja.json");
|
|
17
|
+
const raw = await readFile(filePath, "utf8");
|
|
18
|
+
cachedJobs = JSON.parse(raw) as JobsJa;
|
|
19
|
+
return cachedJobs;
|
|
20
|
+
}
|