itchlib 0.0.0-stage → 1.0.0
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/CHANGELOG.md +9 -0
- package/CONTRIBUTING.md +32 -0
- package/LICENSE +22 -0
- package/README.md +32 -2
- package/SECURITY.md +15 -0
- package/package.json +67 -4
- package/src/api.js +267 -0
- package/src/client.js +231 -0
- package/src/downloads.js +131 -0
- package/src/errors.js +33 -0
- package/src/http.js +103 -0
- package/src/index.js +40 -0
- package/src/parsers/browse.js +247 -0
- package/src/parsers/cards.js +36 -0
- package/src/parsers/feed.js +31 -0
- package/src/parsers/game.js +164 -0
- package/src/parsers/html.js +114 -0
- package/src/parsers/profile.js +33 -0
- package/src/routes.js +26 -0
- package/src/urls.js +69 -0
- package/types/index.d.ts +420 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import {
|
|
2
|
+
absoluteUrl,
|
|
3
|
+
embedUrls,
|
|
4
|
+
findMeta,
|
|
5
|
+
imageUrls,
|
|
6
|
+
parseHtml,
|
|
7
|
+
readLinks,
|
|
8
|
+
textOf,
|
|
9
|
+
unique,
|
|
10
|
+
videoUrls,
|
|
11
|
+
} from "./html.js";
|
|
12
|
+
|
|
13
|
+
function parseDetails($, baseUrl) {
|
|
14
|
+
const details = [];
|
|
15
|
+
$(".game_info_panel_widget tr").each((_, row) => {
|
|
16
|
+
const cells = $(row).children("th, td");
|
|
17
|
+
if (cells.length < 2) return;
|
|
18
|
+
const label = textOf(cells.first())
|
|
19
|
+
.replace(/\s*\|\s*$/, "")
|
|
20
|
+
.trim();
|
|
21
|
+
if (!label) return;
|
|
22
|
+
const values = cells.slice(1);
|
|
23
|
+
details.push({ label, text: textOf(values), links: readLinks($, values, baseUrl) });
|
|
24
|
+
});
|
|
25
|
+
return details;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseSize(value) {
|
|
29
|
+
const match = String(value || "").match(/([\d.]+)\s*(B|KB|MB|GB|TB)\b/i);
|
|
30
|
+
if (!match) return 0;
|
|
31
|
+
const units = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3, TB: 1024 ** 4 };
|
|
32
|
+
return Math.round(Number(match[1]) * units[match[2].toUpperCase()]);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function parseUploads(markup) {
|
|
36
|
+
const $ = parseHtml(markup);
|
|
37
|
+
const uploads = [];
|
|
38
|
+
$(".upload").each((_, element) => {
|
|
39
|
+
const row = $(element);
|
|
40
|
+
const nameNode = row.find(".name").first();
|
|
41
|
+
const name = textOf(nameNode);
|
|
42
|
+
if (!name) return;
|
|
43
|
+
const id =
|
|
44
|
+
row.attr("data-upload_id") ||
|
|
45
|
+
row.attr("data-upload-id") ||
|
|
46
|
+
row.find('[name="upload_id"]').attr("value") ||
|
|
47
|
+
null;
|
|
48
|
+
const size = textOf(row.find(".file_size").first());
|
|
49
|
+
const version = textOf(row).match(/\bVersion\s+([\d][\w.-]*)/i)?.[1] || null;
|
|
50
|
+
const platforms = [];
|
|
51
|
+
row.find(".download_platforms [title]").each((__, platform) => platforms.push($(platform).attr("title")));
|
|
52
|
+
uploads.push({
|
|
53
|
+
id: id && /^\d+$/.test(id) ? id : null,
|
|
54
|
+
name,
|
|
55
|
+
filename: nameNode.attr("title") || name,
|
|
56
|
+
size: parseSize(size),
|
|
57
|
+
version,
|
|
58
|
+
platforms: unique(platforms),
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
return uploads;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseComments($, baseUrl) {
|
|
65
|
+
const comments = [];
|
|
66
|
+
$(".community_post").each((_, element) => {
|
|
67
|
+
const post = $(element);
|
|
68
|
+
const rawData = post.attr("data-post") || "{}";
|
|
69
|
+
let postData = {};
|
|
70
|
+
try {
|
|
71
|
+
postData = JSON.parse(rawData);
|
|
72
|
+
} catch {
|
|
73
|
+
/* Some page versions provide non-JSON values. */
|
|
74
|
+
}
|
|
75
|
+
const id = post.attr("id")?.match(/post-(\d+)/)?.[1] || post.attr("data-post_id") || null;
|
|
76
|
+
const authorLink = post.find(".post_author a, a.user_name").first();
|
|
77
|
+
const author = textOf(authorLink) || null;
|
|
78
|
+
const authorUrl = absoluteUrl(authorLink.attr("href"), baseUrl);
|
|
79
|
+
const date = post.find(".post_date").first();
|
|
80
|
+
const createdAt =
|
|
81
|
+
date.attr("title") || date.find("time").attr("datetime") || date.attr("datetime") || null;
|
|
82
|
+
const body = post.find(".post_body, .community_post_body, .community_post_content").first();
|
|
83
|
+
const text = textOf(body.length ? body : post);
|
|
84
|
+
if (!text) return;
|
|
85
|
+
const postUrl = absoluteUrl(date.find("a[href]").first().attr("href"), baseUrl);
|
|
86
|
+
comments.push({
|
|
87
|
+
id,
|
|
88
|
+
userId: Number(postData.user_id) || null,
|
|
89
|
+
isReply: post.hasClass("is_reply"),
|
|
90
|
+
author,
|
|
91
|
+
authorUrl,
|
|
92
|
+
createdAt,
|
|
93
|
+
text,
|
|
94
|
+
html: body.length ? body.html() : null,
|
|
95
|
+
url: postUrl,
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
return comments;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function parseGame(markup, pageUrl) {
|
|
102
|
+
const $ = parseHtml(markup);
|
|
103
|
+
const metaTitle = findMeta($, "og:title");
|
|
104
|
+
const metaDescription = findMeta($, "og:description") || findMeta($, "description");
|
|
105
|
+
const title = metaTitle || textOf($(".game_title").first()) || textOf($("title").first());
|
|
106
|
+
const descriptionNode = $(".formatted_description").first();
|
|
107
|
+
const description = metaDescription || textOf(descriptionNode);
|
|
108
|
+
const descriptionHtml = descriptionNode.html()?.trim() || null;
|
|
109
|
+
const authorLink = $("a.user_name").first();
|
|
110
|
+
const author = textOf(authorLink) || null;
|
|
111
|
+
const authorUrl = absoluteUrl(authorLink.attr("href"), pageUrl);
|
|
112
|
+
const screenshotContainer = $(".screenshot_list").first();
|
|
113
|
+
const screenshotSource = screenshotContainer.length ? screenshotContainer : $("body");
|
|
114
|
+
const images = unique([
|
|
115
|
+
absoluteUrl(findMeta($, "og:image"), pageUrl),
|
|
116
|
+
...imageUrls($, screenshotSource, pageUrl),
|
|
117
|
+
]);
|
|
118
|
+
const details = parseDetails($, pageUrl);
|
|
119
|
+
const detail = (label) => details.find((item) => item.label.toLowerCase() === label.toLowerCase());
|
|
120
|
+
const purchase = $("a.buy_btn, a.donate_btn").first();
|
|
121
|
+
const itchPath = findMeta($, "itch:path");
|
|
122
|
+
const numericGameId = itchPath.match(/(?:^|\/)games\/(\d+)/)?.[1];
|
|
123
|
+
const cleanedTitle = String(title).replace(/\s+/g, " ").trim();
|
|
124
|
+
const authors = detail("Authors")?.links || (author ? [{ url: authorUrl, text: author }] : []);
|
|
125
|
+
const tags = detail("Tags")?.links || [];
|
|
126
|
+
const uploads = parseUploads(markup);
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
id: numericGameId ? Number(numericGameId) : null,
|
|
130
|
+
slug: new URL(pageUrl).pathname.replace(/\/$/, "").split("/").pop() || null,
|
|
131
|
+
url: new URL(pageUrl).href,
|
|
132
|
+
title: cleanedTitle,
|
|
133
|
+
author,
|
|
134
|
+
authorUrl,
|
|
135
|
+
authors,
|
|
136
|
+
description,
|
|
137
|
+
descriptionHtml,
|
|
138
|
+
cover: images[0] || null,
|
|
139
|
+
images,
|
|
140
|
+
screenshots: images.slice(1),
|
|
141
|
+
videos: videoUrls($, $("body"), pageUrl),
|
|
142
|
+
embeds: embedUrls($, $("body"), pageUrl),
|
|
143
|
+
links: readLinks($, $("body"), pageUrl),
|
|
144
|
+
price: textOf($(".buy_message").first()) || null,
|
|
145
|
+
purchase: purchase.length
|
|
146
|
+
? {
|
|
147
|
+
url: absoluteUrl(purchase.attr("href"), pageUrl),
|
|
148
|
+
label: textOf(purchase),
|
|
149
|
+
}
|
|
150
|
+
: null,
|
|
151
|
+
details,
|
|
152
|
+
status: detail("Status")?.text || null,
|
|
153
|
+
pagePlatforms: detail("Platforms")?.links || [],
|
|
154
|
+
genres: detail("Genre")?.links || [],
|
|
155
|
+
tags,
|
|
156
|
+
madeWith: detail("Made with")?.links || [],
|
|
157
|
+
languages: detail("Languages")?.links || [],
|
|
158
|
+
inputs: detail("Inputs")?.links || [],
|
|
159
|
+
accessibility: detail("Accessibility")?.links || [],
|
|
160
|
+
developmentLogs: readLinks($, $(".devlog_list").first(), pageUrl),
|
|
161
|
+
comments: parseComments($, pageUrl),
|
|
162
|
+
uploads,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { load } from "cheerio";
|
|
2
|
+
|
|
3
|
+
export function parseHtml(markup, options = {}) {
|
|
4
|
+
return load(String(markup ?? ""), { decodeEntities: true, ...options });
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function textOf(element) {
|
|
8
|
+
if (!element?.length) return "";
|
|
9
|
+
const copy = element.clone();
|
|
10
|
+
copy.find("script, style, template").remove();
|
|
11
|
+
return copy.text().replace(/\s+/g, " ").trim();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function absoluteUrl(value, base) {
|
|
15
|
+
if (typeof value !== "string" || !value.trim() || /^(?:data|javascript|mailto|tel):/i.test(value.trim()))
|
|
16
|
+
return null;
|
|
17
|
+
try {
|
|
18
|
+
const url = new URL(value, base);
|
|
19
|
+
return ["https:", "http:"].includes(url.protocol) ? url.href : null;
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function linkUrl(value, base) {
|
|
26
|
+
if (typeof value !== "string" || !value.trim()) return null;
|
|
27
|
+
const trimmed = value.trim();
|
|
28
|
+
if (/^(?:mailto|tel|itch):/i.test(trimmed)) return trimmed;
|
|
29
|
+
return absoluteUrl(trimmed, base);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function findMeta($, key) {
|
|
33
|
+
const wanted = key.toLowerCase();
|
|
34
|
+
let result = "";
|
|
35
|
+
$("meta[property], meta[name], meta[itemprop]").each((_, element) => {
|
|
36
|
+
const meta = $(element);
|
|
37
|
+
const name = (meta.attr("property") || meta.attr("name") || meta.attr("itemprop") || "").toLowerCase();
|
|
38
|
+
if (!result && name === wanted) result = meta.attr("content") || "";
|
|
39
|
+
});
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function readLinks($, scope, base) {
|
|
44
|
+
const links = [];
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
scope.find("a[href]").each((_, element) => {
|
|
47
|
+
const anchor = $(element);
|
|
48
|
+
const url = linkUrl(anchor.attr("href"), base);
|
|
49
|
+
if (!url) return;
|
|
50
|
+
const text = textOf(anchor);
|
|
51
|
+
const title = anchor.attr("title");
|
|
52
|
+
const key = `${url}\n${text}\n${title || ""}`;
|
|
53
|
+
if (seen.has(key)) return;
|
|
54
|
+
seen.add(key);
|
|
55
|
+
links.push({ url, text, ...(title ? { title } : {}) });
|
|
56
|
+
});
|
|
57
|
+
return links;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function imageUrls($, scope, base) {
|
|
61
|
+
const urls = [];
|
|
62
|
+
scope
|
|
63
|
+
.find('a[data-image_lightbox="true"][href]')
|
|
64
|
+
.each((_, el) => urls.push(absoluteUrl($(el).attr("href"), base)));
|
|
65
|
+
scope.find("img").each((_, el) => {
|
|
66
|
+
const img = $(el);
|
|
67
|
+
const source = img.attr("src") || img.attr("data-src") || img.attr("data-original") || img.attr("poster");
|
|
68
|
+
if (source) urls.push(absoluteUrl(source, base));
|
|
69
|
+
const srcset = img.attr("srcset") || img.attr("data-srcset") || img.attr("data-lazy_srcset");
|
|
70
|
+
if (srcset)
|
|
71
|
+
for (const candidate of srcset.split(","))
|
|
72
|
+
urls.push(absoluteUrl(candidate.trim().split(/\s+/)[0], base));
|
|
73
|
+
});
|
|
74
|
+
return unique(urls);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function videoUrls($, scope, base) {
|
|
78
|
+
const urls = [];
|
|
79
|
+
scope.find("video, source").each((_, el) => {
|
|
80
|
+
const node = $(el);
|
|
81
|
+
const value = node.attr("src") || node.attr("data-src") || node.attr("data-video-url");
|
|
82
|
+
const url = absoluteUrl(value, base);
|
|
83
|
+
if (
|
|
84
|
+
url &&
|
|
85
|
+
(el.tagName === "video" ||
|
|
86
|
+
/video\//i.test(node.attr("type") || "") ||
|
|
87
|
+
/\.(?:mp4|webm|mov|m4v)(?:$|\?)/i.test(url))
|
|
88
|
+
)
|
|
89
|
+
urls.push(url);
|
|
90
|
+
});
|
|
91
|
+
scope.find("iframe").each((_, el) => {
|
|
92
|
+
const url = absoluteUrl($(el).attr("src") || $(el).attr("data-src"), base);
|
|
93
|
+
if (
|
|
94
|
+
url &&
|
|
95
|
+
/(?:youtube(?:-nocookie)?\.com|youtu\.be|vimeo\.com|dailymotion\.com|player\.twitch\.tv|streamable\.com)/i.test(
|
|
96
|
+
url,
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
urls.push(url);
|
|
100
|
+
});
|
|
101
|
+
return unique(urls);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function embedUrls($, scope, base) {
|
|
105
|
+
const urls = [];
|
|
106
|
+
scope
|
|
107
|
+
.find("iframe")
|
|
108
|
+
.each((_, el) => urls.push(absoluteUrl($(el).attr("src") || $(el).attr("data-src"), base)));
|
|
109
|
+
return unique(urls);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function unique(values) {
|
|
113
|
+
return [...new Set(values.filter(Boolean))];
|
|
114
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { absoluteUrl, findMeta, parseHtml, readLinks, textOf } from "./html.js";
|
|
2
|
+
import { parseGameCards } from "./cards.js";
|
|
3
|
+
|
|
4
|
+
export function parseProfile(markup, pageUrl) {
|
|
5
|
+
const $ = parseHtml(markup);
|
|
6
|
+
const canonicalUrl = new URL(pageUrl);
|
|
7
|
+
const title = findMeta($, "og:title") || textOf($("h1").first()) || textOf($("title").first());
|
|
8
|
+
const avatarNode = $(".user_avatar img, .user_cover img, .user_avatar, .user_cover").first();
|
|
9
|
+
const styleUrl = avatarNode.attr("style")?.match(/url\(["']?([^"')]+)["']?\)/i)?.[1];
|
|
10
|
+
const avatar = absoluteUrl(
|
|
11
|
+
findMeta($, "og:image") || avatarNode.attr("src") || avatarNode.attr("data-src") || styleUrl,
|
|
12
|
+
canonicalUrl,
|
|
13
|
+
);
|
|
14
|
+
const bioNode = $(".user_bio").first();
|
|
15
|
+
const description = findMeta($, "og:description") || textOf(bioNode);
|
|
16
|
+
const itchPath = findMeta($, "itch:path");
|
|
17
|
+
const profilePathUserId = itchPath.match(/users\/(\d+)/)?.[1];
|
|
18
|
+
const username = canonicalUrl.hostname.endsWith(".itch.io")
|
|
19
|
+
? canonicalUrl.hostname.slice(0, -".itch.io".length)
|
|
20
|
+
: canonicalUrl.pathname.split("/").filter(Boolean).at(-1) || null;
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
id: Number(profilePathUserId) || null,
|
|
24
|
+
username,
|
|
25
|
+
displayName: title.replace(/\s*(?:\||-)\s*itch\.io\s*$/i, "") || username || "",
|
|
26
|
+
url: canonicalUrl.href,
|
|
27
|
+
avatar,
|
|
28
|
+
bio: description,
|
|
29
|
+
bioHtml: bioNode.html()?.trim() || null,
|
|
30
|
+
links: readLinks($, $(".user_links").first(), canonicalUrl),
|
|
31
|
+
games: parseGameCards(markup, canonicalUrl),
|
|
32
|
+
};
|
|
33
|
+
}
|
package/src/routes.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export const ITCH_BROWSE_PAGES = Object.freeze({
|
|
2
|
+
games: "/games",
|
|
3
|
+
store: "/games/store",
|
|
4
|
+
freeGames: "/games/price-free",
|
|
5
|
+
funGames: "/games/fun",
|
|
6
|
+
demos: "/games/price-demo",
|
|
7
|
+
onSale: "/games/price-sale",
|
|
8
|
+
topRated: "/games/top-rated",
|
|
9
|
+
topSellers: "/games/top-sellers",
|
|
10
|
+
newestGames: "/games/newest",
|
|
11
|
+
newAndPopular: "/games/new-and-popular",
|
|
12
|
+
tools: "/tools",
|
|
13
|
+
assets: "/game-assets",
|
|
14
|
+
soundtracks: "/soundtracks",
|
|
15
|
+
physicalGames: "/physical-games",
|
|
16
|
+
comics: "/comics",
|
|
17
|
+
books: "/books",
|
|
18
|
+
jams: "/jams",
|
|
19
|
+
community: "/community",
|
|
20
|
+
devlogs: "/devlogs",
|
|
21
|
+
tags: "/tags",
|
|
22
|
+
bundles: "/bundles",
|
|
23
|
+
blog: "/blog",
|
|
24
|
+
developers: "/developers",
|
|
25
|
+
search: "/search",
|
|
26
|
+
});
|
package/src/urls.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { ItchError } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
export function validateGameUrl(value) {
|
|
4
|
+
let url;
|
|
5
|
+
try {
|
|
6
|
+
url = new URL(value);
|
|
7
|
+
} catch (cause) {
|
|
8
|
+
throw new ItchError("Expected a valid itch.io game URL", { code: "ITCH_INVALID_URL", cause });
|
|
9
|
+
}
|
|
10
|
+
if (
|
|
11
|
+
url.protocol !== "https:" ||
|
|
12
|
+
!/(^|\.)itch\.io$/i.test(url.hostname) ||
|
|
13
|
+
url.hostname.toLowerCase() === "itch.io"
|
|
14
|
+
) {
|
|
15
|
+
throw new ItchError("Expected an HTTPS game page on an itch.io subdomain", {
|
|
16
|
+
url: url.href,
|
|
17
|
+
code: "ITCH_INVALID_URL",
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
return url;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function normalizeProfileUrl(profile) {
|
|
24
|
+
if (typeof profile !== "string" || !profile.trim()) {
|
|
25
|
+
throw new TypeError("profile must be a username or itch.io profile URL");
|
|
26
|
+
}
|
|
27
|
+
if (/^https?:\/\//i.test(profile)) {
|
|
28
|
+
let url;
|
|
29
|
+
try {
|
|
30
|
+
url = new URL(profile);
|
|
31
|
+
} catch (cause) {
|
|
32
|
+
throw new ItchError("Expected a valid itch.io profile URL", { code: "ITCH_INVALID_URL", cause });
|
|
33
|
+
}
|
|
34
|
+
if (url.protocol !== "https:" || !/(^|\.)itch\.io$/i.test(url.hostname)) {
|
|
35
|
+
throw new ItchError("Only HTTPS itch.io profile URLs are supported", {
|
|
36
|
+
url: url.href,
|
|
37
|
+
code: "ITCH_INVALID_URL",
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return url;
|
|
41
|
+
}
|
|
42
|
+
if (profile.includes("/") || /[?#\s]/.test(profile))
|
|
43
|
+
throw new ItchError("Expected a username or an itch.io profile URL", { code: "ITCH_INVALID_URL" });
|
|
44
|
+
return new URL(`https://itch.io/profile/${encodeURIComponent(profile)}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function resolveGamePath(pageUrl, path) {
|
|
48
|
+
const page = new URL(pageUrl.href);
|
|
49
|
+
page.pathname = `${page.pathname.replace(/\/$/, "")}/`;
|
|
50
|
+
page.search = "";
|
|
51
|
+
page.hash = "";
|
|
52
|
+
return new URL(`./${path}`, page);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function assertItchUrl(value, { allowSubdomains = false } = {}) {
|
|
56
|
+
let url;
|
|
57
|
+
try {
|
|
58
|
+
url = new URL(value);
|
|
59
|
+
} catch (cause) {
|
|
60
|
+
throw new ItchError("Expected an HTTPS itch.io URL", { code: "ITCH_INVALID_URL", cause });
|
|
61
|
+
}
|
|
62
|
+
const validHost = allowSubdomains
|
|
63
|
+
? /(^|\.)itch\.io$/i.test(url.hostname)
|
|
64
|
+
: url.hostname.toLowerCase() === "itch.io";
|
|
65
|
+
if (url.protocol !== "https:" || !validHost) {
|
|
66
|
+
throw new ItchError("URL must target HTTPS itch.io", { url: url.href, code: "ITCH_INVALID_URL" });
|
|
67
|
+
}
|
|
68
|
+
return url;
|
|
69
|
+
}
|