@studyportals/fawkes 7.3.2-0 → 7.3.3-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/dist/src/structured-data/ProgrammeStructuredDataFactory.d.ts +18 -0
- package/dist/src/structured-data/ProgrammeStructuredDataFactory.js +86 -0
- package/dist/src/structured-data/SearchStructuredDataFactory.d.ts +35 -0
- package/dist/src/structured-data/SearchStructuredDataFactory.js +154 -0
- package/dist/src/structured-data/dto/BreadcrumbDTO.d.ts +5 -0
- package/dist/src/structured-data/dto/BreadcrumbDTO.js +1 -0
- package/dist/src/structured-data/dto/EntityDTO.d.ts +7 -0
- package/dist/src/structured-data/dto/EntityDTO.js +1 -0
- package/dist/src/structured-data/dto/FAQItemDto.d.ts +4 -0
- package/dist/src/structured-data/dto/FAQItemDto.js +1 -0
- package/dist/src/structured-data/dto/OfferDTO.d.ts +5 -0
- package/dist/src/structured-data/dto/OfferDTO.js +1 -0
- package/dist/src/structured-data/dto/ReviewRatingDTO.d.ts +4 -0
- package/dist/src/structured-data/dto/ReviewRatingDTO.js +1 -0
- package/dist/src/structured-data/index.d.ts +1 -0
- package/dist/src/structured-data/index.js +1 -0
- package/dist/src/structured-data/interface/StructuredDataCurrencyConversionService.d.ts +3 -0
- package/dist/src/structured-data/interface/StructuredDataCurrencyConversionService.js +1 -0
- package/package.json +4 -2
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { SearchStructuredDataFactory } from "./SearchStructuredDataFactory";
|
|
2
|
+
import { IProgrammeCard } from "@studyportals/domain-client";
|
|
3
|
+
import { ReviewRatingDTO } from "./dto/ReviewRatingDTO";
|
|
4
|
+
import { OfferDTO } from "./dto/OfferDTO";
|
|
5
|
+
import { Course } from "schema-dts";
|
|
6
|
+
import { EntityDTO } from "./dto/EntityDTO";
|
|
7
|
+
import { StructuredDataCurrencyConversionService } from "../structured-data/interface/StructuredDataCurrencyConversionService";
|
|
8
|
+
export declare class ProgrammeStructuredDataFactory extends SearchStructuredDataFactory<IProgrammeCard> {
|
|
9
|
+
private currencyConversionService;
|
|
10
|
+
constructor(currencyConversionService: StructuredDataCurrencyConversionService);
|
|
11
|
+
private regionalDataPriority;
|
|
12
|
+
protected getRating(card: IProgrammeCard): ReviewRatingDTO | undefined;
|
|
13
|
+
protected getOfferData(card: IProgrammeCard): Promise<OfferDTO | undefined>;
|
|
14
|
+
protected buildStructuredDataForCard(entity: EntityDTO<IProgrammeCard>): Course;
|
|
15
|
+
private getCourseMode;
|
|
16
|
+
private getCourseWorkload;
|
|
17
|
+
private getCourseStartDate;
|
|
18
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { SearchStructuredDataFactory } from "./SearchStructuredDataFactory";
|
|
2
|
+
export class ProgrammeStructuredDataFactory extends SearchStructuredDataFactory {
|
|
3
|
+
currencyConversionService;
|
|
4
|
+
constructor(currencyConversionService) {
|
|
5
|
+
super();
|
|
6
|
+
this.currencyConversionService = currencyConversionService;
|
|
7
|
+
}
|
|
8
|
+
regionalDataPriority = ['international', 'general', 'eea', 'national'];
|
|
9
|
+
getRating(card) {
|
|
10
|
+
if (typeof card.getReviewRating === 'undefined')
|
|
11
|
+
return;
|
|
12
|
+
const rating = card.getReviewRating();
|
|
13
|
+
if (!rating)
|
|
14
|
+
return;
|
|
15
|
+
return {
|
|
16
|
+
rating: rating?.getAverageStarRating(),
|
|
17
|
+
quantity: rating?.getQuantity(),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
async getOfferData(card) {
|
|
21
|
+
const tuitionFees = card.getTuitionFees();
|
|
22
|
+
let tuition;
|
|
23
|
+
let timing;
|
|
24
|
+
for (const target of this.regionalDataPriority) {
|
|
25
|
+
if (!tuition) {
|
|
26
|
+
tuition = tuitionFees.find(t => t.getTarget() === target);
|
|
27
|
+
}
|
|
28
|
+
if (!timing) {
|
|
29
|
+
timing = card.canShowTimings() ? card.getTimings().find(t => t.getType() === target && t.canShowStartDate()) : undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (!tuition) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
price: await this.currencyConversionService.convert(tuition.getAmount(), tuition.getCurrency(), 'USD'),
|
|
37
|
+
category: 'tuition',
|
|
38
|
+
validFrom: timing ? timing.getStartDate().getDate() : undefined
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
buildStructuredDataForCard(entity) {
|
|
42
|
+
const card = entity.card;
|
|
43
|
+
return {
|
|
44
|
+
"@type": "Course",
|
|
45
|
+
"name": card.getTitle(),
|
|
46
|
+
"description": card.getSummary(),
|
|
47
|
+
"url": card.getProgrammeLink().getUrl(),
|
|
48
|
+
"provider": {
|
|
49
|
+
"@type": "EducationalOrganization",
|
|
50
|
+
"name": card.getUniversityLink().getDescription(),
|
|
51
|
+
"url": `https://${window.location.host}${card.getUniversityLink().getUrl()}`
|
|
52
|
+
},
|
|
53
|
+
"offers": this.constructOffer(entity.offer),
|
|
54
|
+
"aggregateRating": this.constructAggregateRating(entity.reviewRating),
|
|
55
|
+
"hasCourseInstance": {
|
|
56
|
+
"@type": "CourseInstance",
|
|
57
|
+
"startDate": this.getCourseStartDate(card),
|
|
58
|
+
"courseWorkload": this.getCourseWorkload(card),
|
|
59
|
+
"courseMode": this.getCourseMode(card),
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
getCourseMode(card) {
|
|
64
|
+
if (card.isBlended())
|
|
65
|
+
return 'blended';
|
|
66
|
+
if (card.isOnline())
|
|
67
|
+
return 'online';
|
|
68
|
+
if (card.isOnCampus())
|
|
69
|
+
return 'onsite';
|
|
70
|
+
}
|
|
71
|
+
getCourseWorkload(card) {
|
|
72
|
+
if (card.isFullTime())
|
|
73
|
+
return 'PT40H';
|
|
74
|
+
if (card.isPartTime())
|
|
75
|
+
return 'PT20H';
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
getCourseStartDate(card) {
|
|
79
|
+
for (const target of this.regionalDataPriority) {
|
|
80
|
+
const timing = card.canShowTimings() ? card.getTimings().find(t => t.getType() === target && t.canShowStartDate()) : undefined;
|
|
81
|
+
if (timing) {
|
|
82
|
+
return timing.getStartDate().getDate().toISOString().split('T')[0];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { SearchResultsPage, WithContext, Offer, AggregateRating, Thing, FAQPage } from "schema-dts";
|
|
2
|
+
import { ReviewRatingDTO } from "./dto/ReviewRatingDTO";
|
|
3
|
+
import { OfferDTO } from "./dto/OfferDTO";
|
|
4
|
+
import { EntityDTO } from "./dto/EntityDTO";
|
|
5
|
+
import { FAQItemDto } from "./dto/FAQItemDto";
|
|
6
|
+
import { BreadcrumbDTO } from "./dto/BreadcrumbDTO";
|
|
7
|
+
export declare abstract class SearchStructuredDataFactory<TCard> {
|
|
8
|
+
buildStructuredData(title: string, description: string, cards: TCard[], faqItems?: FAQItemDto[], breadcrumbs?: BreadcrumbDTO[]): Promise<WithContext<SearchResultsPage>>;
|
|
9
|
+
protected abstract buildStructuredDataForCard(entity: EntityDTO<TCard>): Thing;
|
|
10
|
+
/**
|
|
11
|
+
* Get the rating for a card.
|
|
12
|
+
* This method should be overridden by subclasses to provide the specific rating logic.
|
|
13
|
+
* If no rating is available, return undefined and no aggregate rating will be constructed for the card & page.
|
|
14
|
+
*
|
|
15
|
+
* @param card
|
|
16
|
+
* @protected
|
|
17
|
+
*/
|
|
18
|
+
protected getRating(card: TCard): ReviewRatingDTO | undefined;
|
|
19
|
+
/**
|
|
20
|
+
* Get the offer data for a card.
|
|
21
|
+
* This method should be overridden by subclasses to provide the specific offer logic.
|
|
22
|
+
* If no offer is available, return undefined and no aggregate offer will be constructed for the card & page.
|
|
23
|
+
*
|
|
24
|
+
* @param card
|
|
25
|
+
* @protected
|
|
26
|
+
*/
|
|
27
|
+
protected getOfferData(card: TCard): Promise<OfferDTO | undefined>;
|
|
28
|
+
private constructOptionalPageAggregateRating;
|
|
29
|
+
private constructOptionalPageOffers;
|
|
30
|
+
protected constructOptionalFaqPage(faqItems: FAQItemDto[]): FAQPage | undefined;
|
|
31
|
+
private constructOptionalBreadcrumbs;
|
|
32
|
+
protected constructAggregateRating(reviewRating?: ReviewRatingDTO): AggregateRating | undefined;
|
|
33
|
+
protected constructOffer(offerData?: OfferDTO): Offer | undefined;
|
|
34
|
+
private stripHtmlWithListRetain;
|
|
35
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
export class SearchStructuredDataFactory {
|
|
2
|
+
async buildStructuredData(title, description, cards, faqItems = [], breadcrumbs = []) {
|
|
3
|
+
const entities = [];
|
|
4
|
+
const ratings = [];
|
|
5
|
+
const offersData = [];
|
|
6
|
+
for (const card of cards) {
|
|
7
|
+
const reviewRating = this.getRating(card);
|
|
8
|
+
const offer = await this.getOfferData(card);
|
|
9
|
+
if (reviewRating)
|
|
10
|
+
ratings.push(reviewRating);
|
|
11
|
+
if (offer)
|
|
12
|
+
offersData.push(offer);
|
|
13
|
+
entities.push({ card, offer, reviewRating });
|
|
14
|
+
}
|
|
15
|
+
const data = {
|
|
16
|
+
"@context": "https://schema.org",
|
|
17
|
+
"@type": "SearchResultsPage",
|
|
18
|
+
"name": title,
|
|
19
|
+
description,
|
|
20
|
+
"mainEntity": entities.map(entity => this.buildStructuredDataForCard(entity))
|
|
21
|
+
};
|
|
22
|
+
data.aggregateRating = this.constructOptionalPageAggregateRating(ratings);
|
|
23
|
+
data.offers = this.constructOptionalPageOffers(offersData);
|
|
24
|
+
data.hasPart = this.constructOptionalFaqPage(faqItems);
|
|
25
|
+
data.breadcrumb = this.constructOptionalBreadcrumbs(breadcrumbs);
|
|
26
|
+
return data;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Get the rating for a card.
|
|
30
|
+
* This method should be overridden by subclasses to provide the specific rating logic.
|
|
31
|
+
* If no rating is available, return undefined and no aggregate rating will be constructed for the card & page.
|
|
32
|
+
*
|
|
33
|
+
* @param card
|
|
34
|
+
* @protected
|
|
35
|
+
*/
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
37
|
+
getRating(card) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Get the offer data for a card.
|
|
42
|
+
* This method should be overridden by subclasses to provide the specific offer logic.
|
|
43
|
+
* If no offer is available, return undefined and no aggregate offer will be constructed for the card & page.
|
|
44
|
+
*
|
|
45
|
+
* @param card
|
|
46
|
+
* @protected
|
|
47
|
+
*/
|
|
48
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
49
|
+
async getOfferData(card) {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
constructOptionalPageAggregateRating(ratings) {
|
|
53
|
+
if (ratings.length <= 0)
|
|
54
|
+
return;
|
|
55
|
+
let totalQuantity = 0;
|
|
56
|
+
let totalRatingValue = 0;
|
|
57
|
+
for (const rating of ratings) {
|
|
58
|
+
totalQuantity += rating.quantity;
|
|
59
|
+
totalRatingValue += rating.rating;
|
|
60
|
+
}
|
|
61
|
+
const totalAverageRating = Math.round((totalRatingValue / ratings.length) * 10) / 10;
|
|
62
|
+
return this.constructAggregateRating({ rating: totalAverageRating, quantity: totalQuantity });
|
|
63
|
+
}
|
|
64
|
+
constructOptionalPageOffers(offersData) {
|
|
65
|
+
if (offersData.length <= 0)
|
|
66
|
+
return;
|
|
67
|
+
let minPrice = 0;
|
|
68
|
+
let maxPrice = 0;
|
|
69
|
+
let totalPrice = 0;
|
|
70
|
+
for (const offer of offersData) {
|
|
71
|
+
if (offer.price < minPrice || minPrice === 0) {
|
|
72
|
+
minPrice = offer.price;
|
|
73
|
+
}
|
|
74
|
+
if (offer.price > maxPrice) {
|
|
75
|
+
maxPrice = offer.price;
|
|
76
|
+
}
|
|
77
|
+
totalPrice += offer.price;
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
"@type": "AggregateOffer",
|
|
81
|
+
"lowPrice": minPrice,
|
|
82
|
+
"highPrice": maxPrice,
|
|
83
|
+
"priceCurrency": "USD",
|
|
84
|
+
"offerCount": offersData.length,
|
|
85
|
+
"priceSpecification": {
|
|
86
|
+
"@type": "PriceSpecification",
|
|
87
|
+
"price": Math.round(totalPrice / offersData.length),
|
|
88
|
+
"priceCurrency": "USD"
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
constructOptionalFaqPage(faqItems) {
|
|
93
|
+
if (faqItems.length <= 0)
|
|
94
|
+
return;
|
|
95
|
+
return {
|
|
96
|
+
"@type": "FAQPage",
|
|
97
|
+
"mainEntity": faqItems.map(item => ({
|
|
98
|
+
"@type": "Question",
|
|
99
|
+
"name": item.question,
|
|
100
|
+
"acceptedAnswer": {
|
|
101
|
+
"@type": "Answer",
|
|
102
|
+
"text": this.stripHtmlWithListRetain(item.answer)
|
|
103
|
+
}
|
|
104
|
+
}))
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
constructOptionalBreadcrumbs(breadcrumbs) {
|
|
108
|
+
if (breadcrumbs.length <= 0)
|
|
109
|
+
return;
|
|
110
|
+
return {
|
|
111
|
+
"@type": "BreadcrumbList",
|
|
112
|
+
"itemListElement": breadcrumbs.map((breadcrumb) => ({
|
|
113
|
+
"@type": "ListItem",
|
|
114
|
+
"@id": `${breadcrumb.url}#listitem#thing`,
|
|
115
|
+
"name": breadcrumb.name,
|
|
116
|
+
"position": breadcrumb.position,
|
|
117
|
+
"item": breadcrumb.url
|
|
118
|
+
}))
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
constructAggregateRating(reviewRating) {
|
|
122
|
+
if (!reviewRating)
|
|
123
|
+
return;
|
|
124
|
+
return {
|
|
125
|
+
"@type": "AggregateRating",
|
|
126
|
+
"ratingValue": reviewRating.rating,
|
|
127
|
+
"reviewCount": reviewRating.quantity,
|
|
128
|
+
"bestRating": 5,
|
|
129
|
+
"worstRating": 1
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
constructOffer(offerData) {
|
|
133
|
+
if (!offerData)
|
|
134
|
+
return;
|
|
135
|
+
return {
|
|
136
|
+
"@type": "Offer",
|
|
137
|
+
"price": offerData.price,
|
|
138
|
+
"priceCurrency": "USD",
|
|
139
|
+
"availability": "https://schema.org/InStock",
|
|
140
|
+
"validFrom": offerData.validFrom ? offerData.validFrom.toISOString().split('T')[0] : undefined,
|
|
141
|
+
"category": "tuition"
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
stripHtmlWithListRetain(html) {
|
|
145
|
+
const tempDiv = document.createElement('div');
|
|
146
|
+
tempDiv.innerHTML = html;
|
|
147
|
+
// Replace all <li> elements with bullet points manually
|
|
148
|
+
tempDiv.querySelectorAll('li').forEach(li => {
|
|
149
|
+
li.textContent = '• ' + li.textContent;
|
|
150
|
+
});
|
|
151
|
+
// Get the text content, which includes newlines for block elements
|
|
152
|
+
return tempDiv.textContent?.trim() ?? '';
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './ProgrammeStructuredDataFactory';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './ProgrammeStructuredDataFactory';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@studyportals/fawkes",
|
|
3
|
-
"version": "7.3.
|
|
3
|
+
"version": "7.3.3-1",
|
|
4
4
|
"description": "A package to centralize SEO related logic for SBLP and Sitemap Generator.",
|
|
5
5
|
"files": [
|
|
6
6
|
"./dist"
|
|
@@ -67,6 +67,7 @@
|
|
|
67
67
|
"author": "The Jedi Council",
|
|
68
68
|
"license": "ISC",
|
|
69
69
|
"devDependencies": {
|
|
70
|
+
"@adobe/structured-data-validator": "^1.4.1",
|
|
70
71
|
"@studyportals/code-style": "^2.2.1",
|
|
71
72
|
"@studyportals/webpack-helper": "^6.0.6",
|
|
72
73
|
"@vitest/coverage-istanbul": "^2.1.8",
|
|
@@ -79,10 +80,11 @@
|
|
|
79
80
|
"vitest": "^2.1.8"
|
|
80
81
|
},
|
|
81
82
|
"dependencies": {
|
|
82
|
-
"@studyportals/domain-client": "^6.
|
|
83
|
+
"@studyportals/domain-client": "^6.2.0",
|
|
83
84
|
"@studyportals/ranking-api-interface": "^1.3.12",
|
|
84
85
|
"@studyportals/search-filters": "^4.9.1",
|
|
85
86
|
"@studyportals/static-domain-data": "^6.1.0",
|
|
87
|
+
"schema-dts": "^1.1.5",
|
|
86
88
|
"ts-loader": "^9.5.2"
|
|
87
89
|
},
|
|
88
90
|
"optionalDependencies": {
|