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
@@ -0,0 +1,259 @@
1
+ import type { Listing } from "./types";
2
+ import { readFile } from "node:fs/promises";
3
+ import { resolve } from "node:path";
4
+ import { buildPartyGroups, type PartyRole } from "./partyText";
5
+ import { loadJobsJa } from "./jobs";
6
+ import {
7
+ getUltimateAchievementGroupMap,
8
+ getUltimateAchievementShortMap,
9
+ type HighEndAchievementGroup,
10
+ type UltimateAchievementName
11
+ } from "@piyoraik/ffxiv-lodestone-character-lookup";
12
+
13
+ export type MatchMode = "and" | "or";
14
+
15
+ export type TextFilter = {
16
+ terms: string[];
17
+ mode?: MatchMode;
18
+ };
19
+
20
+ export type PartyRoleFilter = Partial<Record<PartyRole, string[]>> & {
21
+ /**
22
+ * 同一ロール内(例: healer の ["白","占"])の評価方法。
23
+ * - "or": いずれかを含めばOK(デフォルト)
24
+ * - "and": 全て含めばOK
25
+ */
26
+ withinRoleMode?: MatchMode;
27
+
28
+ /**
29
+ * 複数ロール指定時(例: tank と healer の両方を指定)の評価方法。
30
+ * - "and": 指定したロール条件を全て満たす(デフォルト)
31
+ * - "or": いずれか1つのロール条件を満たす
32
+ */
33
+ acrossRolesMode?: MatchMode;
34
+
35
+ /**
36
+ * 互換用(非推奨): withinRoleMode の旧名。
37
+ */
38
+ roleMode?: MatchMode;
39
+
40
+ /**
41
+ * 互換用(非推奨): acrossRolesMode の旧名。
42
+ */
43
+ mode?: MatchMode;
44
+ };
45
+
46
+ export type PartyFilter = {
47
+ joined?: PartyRoleFilter;
48
+ recruiting?: PartyRoleFilter;
49
+ };
50
+
51
+ export type AchievementFilter = Partial<Record<HighEndAchievementGroup, string[]>> & {
52
+ mode?: MatchMode;
53
+ };
54
+
55
+ /**
56
+ * `formatListingText` 相当の内容を、オブジェクトで検索条件として指定するためのフィルタ定義です。
57
+ *
58
+ * - `TextFilter` は「部分一致」のみ対応(terms × and/or)
59
+ * - party は、表示で使う「ジョブ略称(例: ナ/白/侍)」で指定できます
60
+ * - achievements は、表示で使う略称(例: 絶テマ/【パンデモ】煉獄)で指定できます
61
+ */
62
+ export type ListingSearchFilter = {
63
+ dutyTitle?: TextFilter;
64
+ creator?: TextFilter;
65
+ dataCentres?: string[];
66
+ pfCategories?: string[];
67
+ requirements?: TextFilter;
68
+ description?: TextFilter;
69
+ party?: PartyFilter;
70
+ achievements?: AchievementFilter;
71
+ formattedText?: TextFilter;
72
+ };
73
+
74
+ function normalizeTerms(terms: string[]): string[] {
75
+ return terms.map((t) => t.trim()).filter(Boolean);
76
+ }
77
+
78
+ function getMode(mode: MatchMode | undefined): MatchMode {
79
+ return mode ?? "and";
80
+ }
81
+
82
+ function matchText(value: string, filter: TextFilter | undefined): boolean {
83
+ if (!filter) return true;
84
+ const terms = normalizeTerms(filter.terms ?? []);
85
+ if (terms.length === 0) return true;
86
+
87
+ const mode = getMode(filter.mode);
88
+ return mode === "and"
89
+ ? terms.every((t) => value.includes(t))
90
+ : terms.some((t) => value.includes(t));
91
+ }
92
+
93
+ /**
94
+ * テキストフィルタ(部分一致 × and/or)の一致判定を行います。
95
+ */
96
+ export function matchTextFilter(value: string, filter: TextFilter | undefined): boolean {
97
+ return matchText(value, filter);
98
+ }
99
+
100
+ function matchInList(value: string | undefined, allowList: string[] | undefined): boolean {
101
+ if (!allowList || allowList.length === 0) return true;
102
+ if (!value) return false;
103
+ return allowList.includes(value);
104
+ }
105
+
106
+ function matchStringArray(values: string[], filter: TextFilter | undefined): boolean {
107
+ if (!filter) return true;
108
+ return matchText(values.join(" "), filter);
109
+ }
110
+
111
+ function filterRoleItems(
112
+ roleValues: Record<PartyRole, string[]>,
113
+ filter: PartyRoleFilter | undefined
114
+ ): boolean {
115
+ if (!filter) return true;
116
+ const acrossRolesMode = getMode(filter.acrossRolesMode ?? filter.mode);
117
+ const withinRoleMode = getMode(filter.withinRoleMode ?? filter.roleMode ?? "or");
118
+ const roles: PartyRole[] = ["tank", "healer", "dps"];
119
+
120
+ const checks = roles
121
+ .filter((r) => Array.isArray(filter[r]) && (filter[r] ?? []).length > 0)
122
+ .map((role) => {
123
+ const expected = new Set(normalizeTerms(filter[role] ?? []));
124
+ const actual = new Set(roleValues[role] ?? []);
125
+ const list = Array.from(expected);
126
+ return withinRoleMode === "and"
127
+ ? list.every((v) => actual.has(v))
128
+ : list.some((v) => actual.has(v));
129
+ });
130
+
131
+ if (checks.length === 0) return true;
132
+ return acrossRolesMode === "and" ? checks.every(Boolean) : checks.some(Boolean);
133
+ }
134
+
135
+ function computeAchievementShortsByGroup(listing: Listing): Record<HighEndAchievementGroup, string[]> {
136
+ const shortMap = getUltimateAchievementShortMap();
137
+ const groupMap = getUltimateAchievementGroupMap();
138
+
139
+ const result: Record<HighEndAchievementGroup, string[]> = { ultimate: [], savage: [] };
140
+ const clears = listing.creatorUltimateClears ?? [];
141
+
142
+ for (const name of clears) {
143
+ const group = groupMap.get(name as UltimateAchievementName);
144
+ if (!group) continue;
145
+ const short = shortMap.get(name as UltimateAchievementName) ?? name;
146
+ result[group].push(short);
147
+ }
148
+
149
+ return result;
150
+ }
151
+
152
+ function matchAchievements(listing: Listing, filter: AchievementFilter | undefined): boolean {
153
+ if (!filter) return true;
154
+
155
+ const status = listing.creatorUltimateClearsStatus;
156
+ if (status === "private_or_unavailable" || status === "error") return false;
157
+
158
+ const actual = computeAchievementShortsByGroup(listing);
159
+ const mode = getMode(filter.mode);
160
+
161
+ const checks = (["ultimate", "savage"] as const)
162
+ .filter((g) => Array.isArray(filter[g]) && (filter[g] ?? []).length > 0)
163
+ .map((group) => {
164
+ const expected = new Set(normalizeTerms(filter[group] ?? []));
165
+ const got = new Set(actual[group] ?? []);
166
+ return Array.from(expected).every((v) => got.has(v));
167
+ });
168
+
169
+ if (checks.length === 0) return true;
170
+ return mode === "and" ? checks.every(Boolean) : checks.some(Boolean);
171
+ }
172
+
173
+ /**
174
+ * フィルタのうち party 条件を評価するためのジョブ辞書を読み込み、略称ベースのグルーピングを返します。
175
+ */
176
+ async function buildPartyRoleValues(listing: Listing): Promise<{
177
+ joined: Record<PartyRole, string[]>;
178
+ recruiting: Record<PartyRole, string[]>;
179
+ }> {
180
+ const jobsJa = await loadJobsJa();
181
+ const entries = Object.entries(jobsJa.jobs);
182
+ const codeToShort = new Map(entries.map(([code, info]) => [code, info.short]));
183
+ const codeToRole = new Map(entries.map(([code, info]) => [code, info.role]));
184
+
185
+ const groups = buildPartyGroups(listing, codeToShort, codeToRole);
186
+ return {
187
+ joined: groups.joined,
188
+ recruiting: {
189
+ tank: Array.from(groups.recruiting.tank),
190
+ healer: Array.from(groups.recruiting.healer),
191
+ dps: Array.from(groups.recruiting.dps)
192
+ }
193
+ };
194
+ }
195
+
196
+ /**
197
+ * 募集(Listing)がフィルタ条件に一致するか判定します。
198
+ *
199
+ * - `formattedText` は呼び出し元で生成した文字列を渡してください(Lodestone情報などを含めるため)
200
+ */
201
+ export async function matchListing(params: {
202
+ listing: Listing;
203
+ filter?: ListingSearchFilter;
204
+ formattedText?: string;
205
+ }): Promise<boolean> {
206
+ const { listing, filter, formattedText } = params;
207
+ if (!filter) return true;
208
+
209
+ if (!matchText(listing.duty?.title ?? "", filter.dutyTitle)) return false;
210
+ if (!matchText(listing.creator ?? "", filter.creator)) return false;
211
+ if (!matchInList(listing.dataCentre, filter.dataCentres)) return false;
212
+ if (!matchInList(listing.dataPfCategory, filter.pfCategories)) return false;
213
+ if (!matchStringArray(listing.requirements ?? [], filter.requirements)) return false;
214
+ if (!matchText(listing.description ?? "", filter.description)) return false;
215
+
216
+ if (filter.achievements && !matchAchievements(listing, filter.achievements)) return false;
217
+ if (filter.formattedText && !matchText(formattedText ?? "", filter.formattedText)) return false;
218
+
219
+ if (filter.party) {
220
+ const roleValues = await buildPartyRoleValues(listing);
221
+ if (!filterRoleItems(roleValues.joined, filter.party.joined)) return false;
222
+ if (!filterRoleItems(roleValues.recruiting, filter.party.recruiting)) return false;
223
+ }
224
+
225
+ return true;
226
+ }
227
+
228
+ /**
229
+ * JSON文字列から ListingSearchFilter を読み取ります(不正な場合は undefined)。
230
+ */
231
+ export function parseListingSearchFilterJson(raw: string | undefined): ListingSearchFilter | undefined {
232
+ const value = raw?.trim();
233
+ if (!value) return undefined;
234
+ try {
235
+ const parsed = JSON.parse(value) as unknown;
236
+ if (!parsed || typeof parsed !== "object") return undefined;
237
+ return parsed as ListingSearchFilter;
238
+ } catch {
239
+ return undefined;
240
+ }
241
+ }
242
+
243
+ /**
244
+ * JSONファイルから ListingSearchFilter を読み込みます。
245
+ * ファイルが存在しない/読めない/JSONが不正な場合は undefined を返します。
246
+ */
247
+ export async function loadListingSearchFilterFromFile(
248
+ filePath: string | undefined
249
+ ): Promise<ListingSearchFilter | undefined> {
250
+ const path = filePath?.trim();
251
+ if (!path) return undefined;
252
+
253
+ try {
254
+ const raw = await readFile(resolve(process.cwd(), path), "utf8");
255
+ return parseListingSearchFilterJson(raw);
256
+ } catch {
257
+ return undefined;
258
+ }
259
+ }
package/src/types.ts ADDED
@@ -0,0 +1,23 @@
1
+ export type PartyItem = {
2
+ classList: string[];
3
+ title?: string;
4
+ };
5
+
6
+ export type Listing = {
7
+ id: string;
8
+ dataCentre?: string;
9
+ dataPfCategory?: string;
10
+ duty?: {
11
+ classList: string[];
12
+ title: string;
13
+ };
14
+ creator?: string;
15
+ creatorLodestoneSearchUrl?: string;
16
+ creatorLodestoneUrl?: string;
17
+ creatorAchievementUrl?: string;
18
+ creatorUltimateClears?: string[];
19
+ creatorUltimateClearsStatus?: "ok" | "private_or_unavailable" | "error";
20
+ requirements: string[];
21
+ description: string;
22
+ party: PartyItem[];
23
+ };
package/template.yaml ADDED
@@ -0,0 +1,50 @@
1
+ AWSTemplateFormatVersion: "2010-09-09"
2
+ Transform: AWS::Serverless-2016-10-31
3
+ Description: ffxiv party finder notifier (EventBridge -> Lambda -> Discord webhook)
4
+
5
+ Parameters:
6
+ DiscordWebhookUrl:
7
+ Type: String
8
+ NoEcho: true
9
+ Description: Discord Webhook URL
10
+ Limit:
11
+ Type: Number
12
+ Default: 5
13
+ Description: "Max messages per run (FFXIV_PTFINDER_LIMIT)"
14
+ FilterFile:
15
+ Type: String
16
+ Default: "data/filter.json"
17
+ Description: "Path to JSON filter file in the deployment package (FFXIV_PTFINDER_FILTER_FILE)"
18
+
19
+ Resources:
20
+ PtfinderFunction:
21
+ Type: AWS::Serverless::Function
22
+ Properties:
23
+ FunctionName: ffxiv-ptfinder-notifier
24
+ Runtime: nodejs20.x
25
+ Architectures:
26
+ - arm64
27
+ Handler: dist/lambdaHandler.handler
28
+ CodeUri: .
29
+ MemorySize: 512
30
+ Timeout: 120
31
+ Environment:
32
+ Variables:
33
+ DISCORD_WEBHOOK_URL: !Ref DiscordWebhookUrl
34
+ FFXIV_PTFINDER_LIMIT: !Ref Limit
35
+ FFXIV_PTFINDER_FILTER_FILE: !Ref FilterFile
36
+ Events:
37
+ Schedule30m:
38
+ Type: Schedule
39
+ Properties:
40
+ # JST 18:00-23:30 (UTC 09:00-14:30) のみ30分間隔で実行
41
+ Schedule: cron(0/30 9-14 ? * * *)
42
+ Enabled: true
43
+ Metadata:
44
+ BuildMethod: makefile
45
+
46
+ Outputs:
47
+ FunctionName:
48
+ Value: !Ref PtfinderFunction
49
+ FunctionArn:
50
+ Value: !GetAtt PtfinderFunction.Arn
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "CommonJS",
5
+ "lib": ["ES2020"],
6
+ "moduleResolution": "node",
7
+ "rootDir": "src",
8
+ "outDir": "dist",
9
+ "strict": true,
10
+ "esModuleInterop": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "skipLibCheck": true
13
+ },
14
+ "include": ["src/**/*.ts"]
15
+ }