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
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { readEnvOptions, resolveOptions } from "./envOptions";
|
|
2
|
+
import { runApp } from "./runApp";
|
|
3
|
+
import { createLogger } from "./logger";
|
|
4
|
+
|
|
5
|
+
type ApiGatewayResultV2 = {
|
|
6
|
+
statusCode: number;
|
|
7
|
+
headers?: Record<string, string>;
|
|
8
|
+
body?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* AWS Lambda handler。
|
|
13
|
+
*
|
|
14
|
+
* EventBridge のスケジュール実行を想定し、設定は環境変数のみで行います。
|
|
15
|
+
*/
|
|
16
|
+
export async function handler(
|
|
17
|
+
_event?: unknown,
|
|
18
|
+
context?: { awsRequestId?: string }
|
|
19
|
+
): Promise<ApiGatewayResultV2> {
|
|
20
|
+
const logger = createLogger("lambda");
|
|
21
|
+
const startedAt = Date.now();
|
|
22
|
+
logger.info("invoke", { awsRequestId: context?.awsRequestId });
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const env = readEnvOptions();
|
|
26
|
+
const options = resolveOptions({}, env);
|
|
27
|
+
const result = await runApp(options);
|
|
28
|
+
|
|
29
|
+
logger.info("success", { awsRequestId: context?.awsRequestId, sent: result.sent, ms: Date.now() - startedAt });
|
|
30
|
+
return {
|
|
31
|
+
statusCode: 200,
|
|
32
|
+
headers: { "content-type": "application/json; charset=utf-8" },
|
|
33
|
+
body: JSON.stringify({ ok: true, sent: result.sent })
|
|
34
|
+
};
|
|
35
|
+
} catch (err: unknown) {
|
|
36
|
+
logger.error("error", { awsRequestId: context?.awsRequestId, err, ms: Date.now() - startedAt });
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { extractListingsFromHtml } from "./extractListings";
|
|
2
|
+
import { applyDescriptionTags } from "./descriptionRequirements";
|
|
3
|
+
import { loadDescriptionTagsJa } from "./descriptionTags";
|
|
4
|
+
import { loadHtml } from "./fetchHtml";
|
|
5
|
+
import {
|
|
6
|
+
ALLOWED_DATA_CENTRES,
|
|
7
|
+
DEFAULT_FILTER_FILE
|
|
8
|
+
} from "./config";
|
|
9
|
+
import type { Listing } from "./types";
|
|
10
|
+
import { matchTextFilter, type ListingSearchFilter } from "./searchFilter";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 対象DC(Elemental/Mana/Meteor/Gaia)のみに絞り込みます。
|
|
14
|
+
*/
|
|
15
|
+
function filterAllowedDataCentres(listings: Listing[]): Listing[] {
|
|
16
|
+
return listings.filter((l) => l.dataCentre && ALLOWED_DATA_CENTRES.has(l.dataCentre));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type PipelineOptions = {
|
|
20
|
+
input: string;
|
|
21
|
+
searchFilter?: ListingSearchFilter;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* HTML取得→募集抽出→DC/クエリ絞り込み→要件抽出、までを行います。
|
|
26
|
+
*
|
|
27
|
+
* Webhook送信やテキスト整形はこの関数の責務に含めません。
|
|
28
|
+
*/
|
|
29
|
+
export async function buildListings(options: PipelineOptions): Promise<Listing[]> {
|
|
30
|
+
const html = await loadHtml(options.input);
|
|
31
|
+
const allListings = extractListingsFromHtml(html);
|
|
32
|
+
const dcFiltered = filterAllowedDataCentres(allListings);
|
|
33
|
+
const queryFiltered = dcFiltered;
|
|
34
|
+
|
|
35
|
+
const tagsJa = await loadDescriptionTagsJa();
|
|
36
|
+
const tagged = applyDescriptionTags(queryFiltered, tagsJa.tags);
|
|
37
|
+
const preFiltered = tagged;
|
|
38
|
+
if (!options.searchFilter) return preFiltered;
|
|
39
|
+
|
|
40
|
+
const f = options.searchFilter;
|
|
41
|
+
return preFiltered.filter((l) => {
|
|
42
|
+
if (!matchTextFilter(l.duty?.title ?? "", f.dutyTitle)) return false;
|
|
43
|
+
if (!matchTextFilter(l.creator ?? "", f.creator)) return false;
|
|
44
|
+
if (f.dataCentres && f.dataCentres.length > 0 && !f.dataCentres.includes(l.dataCentre ?? "")) return false;
|
|
45
|
+
if (f.pfCategories && f.pfCategories.length > 0 && !f.pfCategories.includes(l.dataPfCategory ?? "")) return false;
|
|
46
|
+
if (!matchTextFilter(l.description ?? "", f.description)) return false;
|
|
47
|
+
if (!matchTextFilter((l.requirements ?? []).join(" "), f.requirements)) return false;
|
|
48
|
+
return true;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { Listing } from "./types";
|
|
2
|
+
import {
|
|
3
|
+
buildAchievementCategoryUrl,
|
|
4
|
+
fetchAchievementCategoryHtml,
|
|
5
|
+
parseUltimateClearsFromAchievementHtml
|
|
6
|
+
} from "@piyoraik/ffxiv-lodestone-character-lookup";
|
|
7
|
+
import {
|
|
8
|
+
buildLodestoneSearchUrl,
|
|
9
|
+
fetchTopCharacterUrl,
|
|
10
|
+
parseCreator
|
|
11
|
+
} from "@piyoraik/ffxiv-lodestone-character-lookup";
|
|
12
|
+
|
|
13
|
+
type CacheEntry = {
|
|
14
|
+
searchUrl: string;
|
|
15
|
+
characterUrl?: string;
|
|
16
|
+
achievementUrl?: string;
|
|
17
|
+
ultimateStatus?: "ok" | "private_or_unavailable" | "error";
|
|
18
|
+
ultimateClears?: string[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type LodestoneEnrichmentCache = Map<string, CacheEntry>;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Lodestone補完(検索URL/キャラクターURL/アチーブ取得)用のキャッシュを作成します。
|
|
25
|
+
*
|
|
26
|
+
* 同一募集者が複数回出てくるケースがあるため、1実行内で使い回します。
|
|
27
|
+
*/
|
|
28
|
+
export function createLodestoneEnrichmentCache(): LodestoneEnrichmentCache {
|
|
29
|
+
return new Map<string, CacheEntry>();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 募集者情報(`Name @ World`)から Lodestone の検索URL/キャラクターURL/アチーブ達成状況を補完します。
|
|
34
|
+
*
|
|
35
|
+
* 検索の結果、先頭にヒットしたURLが取得できない場合でも、検索URLはセットします。
|
|
36
|
+
*/
|
|
37
|
+
export async function enrichListingWithLodestone(
|
|
38
|
+
listing: Listing,
|
|
39
|
+
cache: LodestoneEnrichmentCache
|
|
40
|
+
): Promise<Listing> {
|
|
41
|
+
const creator = listing.creator?.trim();
|
|
42
|
+
if (!creator) return listing;
|
|
43
|
+
|
|
44
|
+
const cached = cache.get(creator);
|
|
45
|
+
if (cached) {
|
|
46
|
+
return {
|
|
47
|
+
...listing,
|
|
48
|
+
creatorLodestoneSearchUrl: cached.searchUrl,
|
|
49
|
+
creatorLodestoneUrl: cached.characterUrl,
|
|
50
|
+
creatorAchievementUrl: cached.achievementUrl,
|
|
51
|
+
creatorUltimateClears: cached.ultimateClears,
|
|
52
|
+
creatorUltimateClearsStatus: cached.ultimateStatus
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const info = parseCreator(creator);
|
|
57
|
+
if (!info) return listing;
|
|
58
|
+
|
|
59
|
+
const searchUrl = buildLodestoneSearchUrl(info);
|
|
60
|
+
let characterUrl: string | undefined;
|
|
61
|
+
let achievementUrl: string | undefined;
|
|
62
|
+
let ultimateStatus: "ok" | "private_or_unavailable" | "error" | undefined;
|
|
63
|
+
let ultimateClears: string[] | undefined;
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
characterUrl = await fetchTopCharacterUrl(searchUrl);
|
|
67
|
+
} catch {
|
|
68
|
+
// 失敗しても後続処理は継続する
|
|
69
|
+
characterUrl = undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (characterUrl) {
|
|
73
|
+
achievementUrl = buildAchievementCategoryUrl(characterUrl);
|
|
74
|
+
if (achievementUrl) {
|
|
75
|
+
try {
|
|
76
|
+
const html = await fetchAchievementCategoryHtml(achievementUrl);
|
|
77
|
+
const parsed = parseUltimateClearsFromAchievementHtml(html);
|
|
78
|
+
ultimateStatus = parsed.status;
|
|
79
|
+
ultimateClears = parsed.clears;
|
|
80
|
+
} catch {
|
|
81
|
+
ultimateStatus = "error";
|
|
82
|
+
ultimateClears = undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
cache.set(creator, { searchUrl, characterUrl, achievementUrl, ultimateStatus, ultimateClears });
|
|
88
|
+
return {
|
|
89
|
+
...listing,
|
|
90
|
+
creatorLodestoneSearchUrl: searchUrl,
|
|
91
|
+
creatorLodestoneUrl: characterUrl,
|
|
92
|
+
creatorAchievementUrl: achievementUrl,
|
|
93
|
+
creatorUltimateClears: ultimateClears,
|
|
94
|
+
creatorUltimateClearsStatus: ultimateStatus
|
|
95
|
+
};
|
|
96
|
+
}
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export type LogLevel = "debug" | "info" | "warn" | "error";
|
|
2
|
+
|
|
3
|
+
export type Logger = {
|
|
4
|
+
debug: (message: string, fields?: Record<string, unknown>) => void;
|
|
5
|
+
info: (message: string, fields?: Record<string, unknown>) => void;
|
|
6
|
+
warn: (message: string, fields?: Record<string, unknown>) => void;
|
|
7
|
+
error: (message: string, fields?: Record<string, unknown>) => void;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function serializeError(err: unknown): Record<string, unknown> {
|
|
11
|
+
if (err instanceof Error) {
|
|
12
|
+
return {
|
|
13
|
+
name: err.name,
|
|
14
|
+
message: err.message,
|
|
15
|
+
stack: err.stack
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
return { message: String(err) };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function toLogObject(params: {
|
|
22
|
+
level: LogLevel;
|
|
23
|
+
component: string;
|
|
24
|
+
message: string;
|
|
25
|
+
fields?: Record<string, unknown>;
|
|
26
|
+
}): Record<string, unknown> {
|
|
27
|
+
const base: Record<string, unknown> = {
|
|
28
|
+
ts: new Date().toISOString(),
|
|
29
|
+
level: params.level,
|
|
30
|
+
component: params.component,
|
|
31
|
+
message: params.message
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const fields = params.fields ?? {};
|
|
35
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
36
|
+
if (value instanceof Error) base[key] = serializeError(value);
|
|
37
|
+
else if (value !== undefined) base[key] = value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return base;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* CloudWatch Logs で追いやすい JSON 1行ログを出すための logger を作ります。
|
|
45
|
+
*/
|
|
46
|
+
export function createLogger(component: string): Logger {
|
|
47
|
+
const write = (level: LogLevel, message: string, fields?: Record<string, unknown>) => {
|
|
48
|
+
// 1行JSONに揃える(CloudWatch上でフィルタしやすくする)
|
|
49
|
+
const obj = toLogObject({ level, component, message, fields });
|
|
50
|
+
process.stdout.write(JSON.stringify(obj) + "\n");
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
debug: (message, fields) => write("debug", message, fields),
|
|
55
|
+
info: (message, fields) => write("info", message, fields),
|
|
56
|
+
warn: (message, fields) => write("warn", message, fields),
|
|
57
|
+
error: (message, fields) => write("error", message, fields)
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
package/src/notifyCli.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { parseCliArgs } from "./cliOptions";
|
|
2
|
+
import { readEnvOptions, resolveOptions } from "./envOptions";
|
|
3
|
+
import { runApp } from "./runApp";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* パイプ出力時(例: `| head`)の EPIPE を無視するハンドラを登録します。
|
|
7
|
+
*/
|
|
8
|
+
function installStdoutEpipeHandler(): void {
|
|
9
|
+
process.stdout.on("error", (err: NodeJS.ErrnoException) => {
|
|
10
|
+
if (err.code === "EPIPE") process.exit(0);
|
|
11
|
+
throw err;
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 通知(Webhook送信)用 CLI。
|
|
17
|
+
* EventBridge/Lambda と同じ `runApp` を呼び出して実行します。
|
|
18
|
+
*/
|
|
19
|
+
async function main(): Promise<void> {
|
|
20
|
+
installStdoutEpipeHandler();
|
|
21
|
+
const cliOptions = parseCliArgs(process.argv.slice(2));
|
|
22
|
+
const envOptions = readEnvOptions();
|
|
23
|
+
const options = resolveOptions(cliOptions, envOptions);
|
|
24
|
+
await runApp(options);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
main().catch((err: unknown) => {
|
|
28
|
+
const message = err instanceof Error ? err.stack ?? err.message : String(err);
|
|
29
|
+
process.stderr.write(message + "\n");
|
|
30
|
+
process.exitCode = 1;
|
|
31
|
+
});
|
|
32
|
+
|
package/src/partyText.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type { Listing } from "./types";
|
|
2
|
+
import {
|
|
3
|
+
getUltimateAchievementGroupMap,
|
|
4
|
+
getUltimateAchievementShortMap,
|
|
5
|
+
type HighEndAchievementGroup,
|
|
6
|
+
type UltimateAchievementName
|
|
7
|
+
} from "@piyoraik/ffxiv-lodestone-character-lookup";
|
|
8
|
+
|
|
9
|
+
export type PartyRole = "tank" | "healer" | "dps";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `title` 内のスペース区切りジョブコードを分割します。
|
|
13
|
+
*/
|
|
14
|
+
export function splitJobList(value: string): string[] {
|
|
15
|
+
return value
|
|
16
|
+
.split(/\s+/)
|
|
17
|
+
.map((s) => s.trim())
|
|
18
|
+
.filter(Boolean);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* party の classList からロールヒント(`tank`/`healer`/`dps`)を取得します。
|
|
23
|
+
*/
|
|
24
|
+
export function getRolesFromClassList(classList: string[]): PartyRole[] {
|
|
25
|
+
const roles: PartyRole[] = [];
|
|
26
|
+
if (classList.includes("tank")) roles.push("tank");
|
|
27
|
+
if (classList.includes("healer")) roles.push("healer");
|
|
28
|
+
if (classList.includes("dps")) roles.push("dps");
|
|
29
|
+
return roles;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* ジョブコードからロールを解決します(ルックアップマップを使用)。
|
|
34
|
+
*/
|
|
35
|
+
export function getRoleFromJobCode(
|
|
36
|
+
jobCode: string,
|
|
37
|
+
jobCodeToRole: Map<string, PartyRole>
|
|
38
|
+
): PartyRole | undefined {
|
|
39
|
+
return jobCodeToRole.get(jobCode);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function formatLine(label: string, values: Iterable<string>): string {
|
|
43
|
+
const text = Array.from(values).join(" ");
|
|
44
|
+
return `${label}:${text}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type PartyGroups = {
|
|
48
|
+
joined: Record<PartyRole, string[]>;
|
|
49
|
+
recruiting: Record<PartyRole, Set<string>>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function createEmptyPartyGroups(): PartyGroups {
|
|
53
|
+
return {
|
|
54
|
+
joined: { tank: [], healer: [], dps: [] },
|
|
55
|
+
recruiting: { tank: new Set<string>(), healer: new Set<string>(), dps: new Set<string>() }
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* listing の `party` 配列から、ロール別の参加/募集情報を組み立てます。
|
|
61
|
+
*/
|
|
62
|
+
export function buildPartyGroups(
|
|
63
|
+
listing: Listing,
|
|
64
|
+
codeToShort: Map<string, string>,
|
|
65
|
+
codeToRole: Map<string, PartyRole>
|
|
66
|
+
): PartyGroups {
|
|
67
|
+
const groups = createEmptyPartyGroups();
|
|
68
|
+
|
|
69
|
+
for (const item of listing.party) {
|
|
70
|
+
if (item.classList.includes("total")) continue;
|
|
71
|
+
const title = item.title?.trim();
|
|
72
|
+
if (!title) continue;
|
|
73
|
+
|
|
74
|
+
if (item.classList.includes("filled")) {
|
|
75
|
+
const role = getRoleFromJobCode(title, codeToRole);
|
|
76
|
+
if (!role) continue;
|
|
77
|
+
groups.joined[role].push(codeToShort.get(title) ?? title);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const rolesFromClass = getRolesFromClassList(item.classList);
|
|
82
|
+
for (const job of splitJobList(title)) {
|
|
83
|
+
const short = codeToShort.get(job) ?? job;
|
|
84
|
+
const roleFromCode = getRoleFromJobCode(job, codeToRole);
|
|
85
|
+
|
|
86
|
+
if (rolesFromClass.length > 0) {
|
|
87
|
+
for (const role of rolesFromClass) groups.recruiting[role].add(short);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (roleFromCode) groups.recruiting[roleFromCode].add(short);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return groups;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 1件の募集をテキストブロックとして整形します。
|
|
100
|
+
*/
|
|
101
|
+
export function formatListingText(
|
|
102
|
+
listing: Listing,
|
|
103
|
+
codeToShort: Map<string, string>,
|
|
104
|
+
codeToRole: Map<string, PartyRole>
|
|
105
|
+
): string {
|
|
106
|
+
const { joined, recruiting } = buildPartyGroups(listing, codeToShort, codeToRole);
|
|
107
|
+
|
|
108
|
+
return [
|
|
109
|
+
`コンテンツ: ${listing.duty?.title ?? ""}`,
|
|
110
|
+
`募集者: ${listing.creator ?? ""}`,
|
|
111
|
+
`ロードストーン: ${listing.creatorLodestoneUrl ?? listing.creatorLodestoneSearchUrl ?? ""}`,
|
|
112
|
+
`絶クリア: ${formatHighEndClears(listing, "ultimate")}`,
|
|
113
|
+
`零式クリア: ${formatHighEndClears(listing, "savage")}`,
|
|
114
|
+
`DC: ${listing.dataCentre ?? ""}`,
|
|
115
|
+
`カテゴリ: ${listing.dataPfCategory ?? ""}`,
|
|
116
|
+
`要件: ${listing.requirements.join(" ")}`,
|
|
117
|
+
`募集文: ${listing.description}`,
|
|
118
|
+
"パーティ:",
|
|
119
|
+
"【参加ジョブ】",
|
|
120
|
+
formatLine("タンク", joined.tank),
|
|
121
|
+
formatLine("ヒーラー", joined.healer),
|
|
122
|
+
formatLine("DPS", joined.dps),
|
|
123
|
+
"【募集ジョブ】",
|
|
124
|
+
formatLine("タンク", recruiting.tank),
|
|
125
|
+
formatLine("ヒーラー", recruiting.healer),
|
|
126
|
+
formatLine("DPS", recruiting.dps)
|
|
127
|
+
].join("\n");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 高難度(絶/零式)アチーブ達成状況を表示用に整形します。
|
|
132
|
+
*/
|
|
133
|
+
function formatHighEndClears(listing: Listing, group: HighEndAchievementGroup): string {
|
|
134
|
+
if (!listing.creatorAchievementUrl && !listing.creatorLodestoneUrl) return "";
|
|
135
|
+
|
|
136
|
+
const status = listing.creatorUltimateClearsStatus;
|
|
137
|
+
if (status === "private_or_unavailable") return "非公開/取得不可";
|
|
138
|
+
if (status === "error") return "取得エラー";
|
|
139
|
+
|
|
140
|
+
const clears = listing.creatorUltimateClears ?? [];
|
|
141
|
+
if (clears.length === 0) return "なし";
|
|
142
|
+
|
|
143
|
+
const shortMap = getUltimateAchievementShortMap();
|
|
144
|
+
const groupMap = getUltimateAchievementGroupMap();
|
|
145
|
+
const short = clears
|
|
146
|
+
.filter((name) => groupMap.get(name as UltimateAchievementName) === group)
|
|
147
|
+
.map((name) => shortMap.get(name as UltimateAchievementName) ?? name);
|
|
148
|
+
|
|
149
|
+
return short.length > 0 ? short.join(" / ") : "なし";
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 複数募集を空行区切りで整形します。
|
|
154
|
+
*/
|
|
155
|
+
export function formatListingsText(
|
|
156
|
+
listings: Listing[],
|
|
157
|
+
codeToShort: Map<string, string>,
|
|
158
|
+
codeToRole: Map<string, PartyRole>
|
|
159
|
+
): string {
|
|
160
|
+
return listings.map((l) => formatListingText(l, codeToShort, codeToRole)).join("\n\n");
|
|
161
|
+
}
|
package/src/runApp.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { loadJobsJa } from "./jobs";
|
|
2
|
+
import { DEFAULT_WEBHOOK_LIMIT } from "./config";
|
|
3
|
+
import { postDiscordWebhook, sleep, toDiscordCodeBlock } from "./discordWebhook";
|
|
4
|
+
import { formatListingText, type PartyRole } from "./partyText";
|
|
5
|
+
import type { Listing } from "./types";
|
|
6
|
+
import type { ResolvedCliOptions } from "./envOptions";
|
|
7
|
+
import { buildListings } from "./listingsPipeline";
|
|
8
|
+
import {
|
|
9
|
+
loadListingSearchFilterFromFile,
|
|
10
|
+
matchListing,
|
|
11
|
+
type ListingSearchFilter
|
|
12
|
+
} from "./searchFilter";
|
|
13
|
+
import { createLogger, type Logger } from "./logger";
|
|
14
|
+
import { buildJobMaps } from "./jobMaps";
|
|
15
|
+
import { createLodestoneEnrichmentCache, enrichListingWithLodestone } from "./lodestoneEnrichment";
|
|
16
|
+
|
|
17
|
+
function summarizeFilter(filter: ListingSearchFilter | undefined): Record<string, unknown> | undefined {
|
|
18
|
+
if (!filter) return undefined;
|
|
19
|
+
return { keys: Object.keys(filter) };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Discord Webhook に送信します(最大 `limit` 件、1募集=1メッセージ)。
|
|
24
|
+
*/
|
|
25
|
+
async function sendToDiscordWebhook(params: {
|
|
26
|
+
webhookUrl: string;
|
|
27
|
+
limit: number;
|
|
28
|
+
listings: Listing[];
|
|
29
|
+
codeToShort: Map<string, string>;
|
|
30
|
+
codeToRole: Map<string, PartyRole>;
|
|
31
|
+
searchFilter?: ListingSearchFilter;
|
|
32
|
+
logger?: Logger;
|
|
33
|
+
}): Promise<number> {
|
|
34
|
+
const lodestoneCache = createLodestoneEnrichmentCache();
|
|
35
|
+
|
|
36
|
+
let sent = 0;
|
|
37
|
+
let scanned = 0;
|
|
38
|
+
let filteredOut = 0;
|
|
39
|
+
for (const listing of params.listings) {
|
|
40
|
+
if (sent >= params.limit) break;
|
|
41
|
+
scanned++;
|
|
42
|
+
const enriched = await enrichListingWithLodestone(listing, lodestoneCache);
|
|
43
|
+
const text = formatListingText(enriched, params.codeToShort, params.codeToRole);
|
|
44
|
+
|
|
45
|
+
const ok = await matchListing({
|
|
46
|
+
listing: enriched,
|
|
47
|
+
filter: params.searchFilter,
|
|
48
|
+
formattedText: text
|
|
49
|
+
});
|
|
50
|
+
if (!ok) {
|
|
51
|
+
filteredOut++;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const content = toDiscordCodeBlock(text);
|
|
56
|
+
await postDiscordWebhook(params.webhookUrl, content);
|
|
57
|
+
await sleep(350);
|
|
58
|
+
sent++;
|
|
59
|
+
}
|
|
60
|
+
params.logger?.info("send_summary", { scanned, filteredOut, sent, limit: params.limit });
|
|
61
|
+
|
|
62
|
+
if (sent === 0) {
|
|
63
|
+
const message = "該当の募集は見つかりませんでした";
|
|
64
|
+
await postDiscordWebhook(params.webhookUrl, message);
|
|
65
|
+
params.logger?.info("no_match", { posted: true });
|
|
66
|
+
}
|
|
67
|
+
return sent;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type RunResult = { mode: "webhook"; sent: number };
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* アプリのコア処理(CLI/Lambda共通)。
|
|
74
|
+
*
|
|
75
|
+
* - HTML 取得(ファイル or URL)
|
|
76
|
+
* - 募集一覧を抽出
|
|
77
|
+
* - DCフィルタ / クエリフィルタ
|
|
78
|
+
* - description タグを requirements に抽出
|
|
79
|
+
* - JSON/text 出力、または webhook 送信
|
|
80
|
+
*/
|
|
81
|
+
export async function runApp(options: ResolvedCliOptions): Promise<RunResult> {
|
|
82
|
+
const logger = createLogger("runApp");
|
|
83
|
+
const startAt = Date.now();
|
|
84
|
+
|
|
85
|
+
const fileFilter =
|
|
86
|
+
options.searchFilterFile ? await loadListingSearchFilterFromFile(options.searchFilterFile) : undefined;
|
|
87
|
+
const mergedSearchFilter = fileFilter ?? options.searchFilter;
|
|
88
|
+
|
|
89
|
+
logger.info("start", {
|
|
90
|
+
limit: options.limit,
|
|
91
|
+
filterFile: options.searchFilterFile,
|
|
92
|
+
filterLoaded: Boolean(fileFilter),
|
|
93
|
+
filterSummary: summarizeFilter(mergedSearchFilter)
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const buildStartAt = Date.now();
|
|
97
|
+
const taggedListings = await buildListings({
|
|
98
|
+
input: options.input,
|
|
99
|
+
searchFilter: mergedSearchFilter
|
|
100
|
+
});
|
|
101
|
+
logger.info("listings_built", { count: taggedListings.length, ms: Date.now() - buildStartAt });
|
|
102
|
+
|
|
103
|
+
const jobsJa = await loadJobsJa();
|
|
104
|
+
const { codeToShort, codeToRole } = buildJobMaps(jobsJa.jobs);
|
|
105
|
+
|
|
106
|
+
const limit = options.limit ?? DEFAULT_WEBHOOK_LIMIT;
|
|
107
|
+
logger.info("send_start", { limit, candidates: taggedListings.length });
|
|
108
|
+
const sent = await sendToDiscordWebhook({
|
|
109
|
+
webhookUrl: options.webhookUrl,
|
|
110
|
+
limit,
|
|
111
|
+
listings: taggedListings,
|
|
112
|
+
codeToShort,
|
|
113
|
+
codeToRole,
|
|
114
|
+
searchFilter: mergedSearchFilter,
|
|
115
|
+
logger
|
|
116
|
+
});
|
|
117
|
+
logger.info("done", { sent, ms: Date.now() - startAt });
|
|
118
|
+
return { mode: "webhook", sent };
|
|
119
|
+
}
|