@ca-plant-list/ca-plant-list 0.4.30 → 0.4.32
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/data/inatobsphotos.csv +9318 -9281
- package/data/inattaxonphotos.csv +471 -80
- package/data/synonyms.csv +4 -0
- package/data/taxa.csv +20 -18
- package/data/text/Brodiaea-elegans-subsp-elegans.md +1 -0
- package/data/text/Brodiaea-terrestris-subsp-terrestris.md +1 -0
- package/lib/externalsites.js +15 -9
- package/lib/htmltaxon.js +44 -1
- package/lib/photo.js +6 -4
- package/lib/taxonomy/taxa.js +3 -22
- package/lib/tools/inat.js +33 -4
- package/lib/utils/inat-tools.js +211 -43
- package/package.json +1 -1
- package/scripts/cpl-photos.js +334 -62
package/lib/utils/inat-tools.js
CHANGED
@@ -2,25 +2,25 @@ import { ProgressMeter } from "../progressmeter.js";
|
|
2
2
|
import { chunk, sleep } from "../util.js";
|
3
3
|
|
4
4
|
/**
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
| "cc0"} InatLicenseCode
|
5
|
+
* @typedef {"cc0" | "cc-by" | "cc-by-nc"} AllowedLicenseCode
|
6
|
+
* @typedef {{place_id?:string,project_id?:string}} ObsPhotoLocationOptions
|
7
|
+
@typedef {{
|
8
|
+
id:number,
|
9
|
+
observation_photos: {
|
10
|
+
photo: import("./inat-tools.js").InatApiPhoto;
|
11
|
+
}[];
|
12
|
+
}} InatApiObservationPhotos
|
14
13
|
@typedef {{
|
15
14
|
id: string;
|
15
|
+
obsId?:string;
|
16
16
|
ext: string;
|
17
|
-
licenseCode:
|
17
|
+
licenseCode: AllowedLicenseCode;
|
18
18
|
attrName: string | undefined;
|
19
19
|
}} InatPhotoInfo
|
20
20
|
@typedef {{
|
21
21
|
id: number;
|
22
22
|
attribution: string;
|
23
|
-
license_code:
|
23
|
+
license_code: string;
|
24
24
|
medium_url?: string;
|
25
25
|
url?: string;
|
26
26
|
}} InatApiPhoto
|
@@ -30,15 +30,38 @@ import { chunk, sleep } from "../util.js";
|
|
30
30
|
photo: InatApiPhoto;
|
31
31
|
}[];
|
32
32
|
}} InatApiTaxon
|
33
|
-
@typedef {{
|
34
|
-
name: string;
|
35
|
-
id: number;
|
36
|
-
ext: string;
|
37
|
-
licenseCode: InatLicenseCode;
|
38
|
-
attrName: string;
|
39
|
-
}} InatCsvPhoto
|
33
|
+
@typedef {{name: string;} & InatPhotoInfo} InatCsvPhoto
|
40
34
|
*/
|
35
|
+
|
41
36
|
const ALLOWED_LICENSE_CODES = ["cc0", "cc-by", "cc-by-nc"];
|
37
|
+
const FIELDS_OBS_PHOTO =
|
38
|
+
"(id:!t,observation_photos:(photo:(url:!t,attribution:!t,license_code:!t)))";
|
39
|
+
|
40
|
+
/**
|
41
|
+
* @param {InatApiPhoto} apiPhoto
|
42
|
+
* @returns {InatPhotoInfo|undefined}
|
43
|
+
*/
|
44
|
+
export function convertToCSVPhoto(apiPhoto) {
|
45
|
+
const licenseCode = getAllowedLicenseCode(apiPhoto.license_code);
|
46
|
+
if (licenseCode === undefined) {
|
47
|
+
return;
|
48
|
+
}
|
49
|
+
const url = apiPhoto.medium_url || apiPhoto.url;
|
50
|
+
if (!url) {
|
51
|
+
return;
|
52
|
+
}
|
53
|
+
const ext = url.split(".").at(-1);
|
54
|
+
if (!ext) {
|
55
|
+
return;
|
56
|
+
}
|
57
|
+
/** @type {InatPhotoInfo} */
|
58
|
+
return {
|
59
|
+
id: apiPhoto.id.toString(),
|
60
|
+
ext: ext,
|
61
|
+
licenseCode: licenseCode,
|
62
|
+
attrName: getAttribution(apiPhoto.attribution),
|
63
|
+
};
|
64
|
+
}
|
42
65
|
|
43
66
|
/**
|
44
67
|
* @param {string[]} inatTaxonIDs
|
@@ -55,6 +78,122 @@ async function fetchInatTaxa(inatTaxonIDs) {
|
|
55
78
|
return json.results;
|
56
79
|
}
|
57
80
|
|
81
|
+
/**
|
82
|
+
* @param {import("../types.js").Taxon} taxon
|
83
|
+
* @param {{place_id?:string,project_id?:string}} locationOptions
|
84
|
+
* @return {Promise<InatApiObservationPhotos[]|Error>}
|
85
|
+
*/
|
86
|
+
async function fetchObservationsForTaxon(
|
87
|
+
taxon,
|
88
|
+
locationOptions = { place_id: "14" },
|
89
|
+
) {
|
90
|
+
const inatTaxonId = taxon.getINatID();
|
91
|
+
if (!inatTaxonId) return [];
|
92
|
+
let url = new URL(
|
93
|
+
`https://api.inaturalist.org/v2/observations/?taxon_id=${inatTaxonId}` +
|
94
|
+
"&photo_license=" +
|
95
|
+
ALLOWED_LICENSE_CODES.join(",") +
|
96
|
+
"&order=desc" +
|
97
|
+
"&order_by=votes" +
|
98
|
+
"&per_page=5",
|
99
|
+
);
|
100
|
+
url.searchParams.set("fields", FIELDS_OBS_PHOTO);
|
101
|
+
if (locationOptions.place_id) {
|
102
|
+
url.searchParams.set("place_id", locationOptions.place_id);
|
103
|
+
}
|
104
|
+
if (locationOptions.project_id) {
|
105
|
+
url.searchParams.set("project_id", locationOptions.project_id);
|
106
|
+
}
|
107
|
+
const resp = await getResponse(url);
|
108
|
+
if (resp instanceof Error) {
|
109
|
+
return resp;
|
110
|
+
}
|
111
|
+
|
112
|
+
if (!resp.ok) {
|
113
|
+
return new Error(await resp.text());
|
114
|
+
}
|
115
|
+
const json = await resp.json();
|
116
|
+
return json.results;
|
117
|
+
}
|
118
|
+
|
119
|
+
/**
|
120
|
+
* @param {string[]} obsIds
|
121
|
+
* @returns {Promise<InatApiObservationPhotos[]|Error>}
|
122
|
+
*/
|
123
|
+
export async function getObsPhotosForIds(obsIds) {
|
124
|
+
let url = new URL("https://api.inaturalist.org/v2/observations/");
|
125
|
+
url.searchParams.set("fields", FIELDS_OBS_PHOTO);
|
126
|
+
url.searchParams.set("id", obsIds.join(","));
|
127
|
+
url.searchParams.set("per_page", obsIds.length.toString());
|
128
|
+
|
129
|
+
const resp = await getResponse(url);
|
130
|
+
if (resp instanceof Error) {
|
131
|
+
return resp;
|
132
|
+
}
|
133
|
+
|
134
|
+
if (!resp.ok) {
|
135
|
+
return new Error(await resp.text());
|
136
|
+
}
|
137
|
+
const json = await resp.json();
|
138
|
+
return json.results;
|
139
|
+
}
|
140
|
+
|
141
|
+
/**
|
142
|
+
* @param {import("../types.js").Taxon[]} taxaToUpdate
|
143
|
+
* @param {ObsPhotoLocationOptions|undefined} locationOptions
|
144
|
+
* @returns {Promise<Map<string,InatPhotoInfo[]>>}
|
145
|
+
*/
|
146
|
+
export async function getObsPhotosForTaxa(taxaToUpdate, locationOptions) {
|
147
|
+
/** @type {Map<string,InatPhotoInfo[]>} */
|
148
|
+
const photos = new Map();
|
149
|
+
|
150
|
+
const meter = new ProgressMeter(
|
151
|
+
"retrieving observation photos",
|
152
|
+
taxaToUpdate.length,
|
153
|
+
);
|
154
|
+
|
155
|
+
for (let index = 0; index < taxaToUpdate.length; index++) {
|
156
|
+
const taxon = taxaToUpdate[index];
|
157
|
+
const observations = await fetchObservationsForTaxon(
|
158
|
+
taxon,
|
159
|
+
locationOptions,
|
160
|
+
);
|
161
|
+
if (observations instanceof Error) {
|
162
|
+
console.error(observations.message);
|
163
|
+
continue;
|
164
|
+
}
|
165
|
+
|
166
|
+
// Just get the CC-licensed ones, 5 per taxon should be fine (max is 20 on iNat). Whether or not
|
167
|
+
const rawPhotoInfo = observations
|
168
|
+
.map((obs) =>
|
169
|
+
obs.observation_photos.map((op) => {
|
170
|
+
return { obsId: obs.id, ...op.photo };
|
171
|
+
}),
|
172
|
+
)
|
173
|
+
.flat();
|
174
|
+
|
175
|
+
/** @type {InatPhotoInfo[]} */
|
176
|
+
const processedPhotoInfo = [];
|
177
|
+
for (const photo of rawPhotoInfo) {
|
178
|
+
if (processedPhotoInfo.length >= 5) {
|
179
|
+
break;
|
180
|
+
}
|
181
|
+
const obj = convertToCSVPhoto(photo);
|
182
|
+
if (!obj) {
|
183
|
+
continue;
|
184
|
+
}
|
185
|
+
processedPhotoInfo.push(obj);
|
186
|
+
}
|
187
|
+
photos.set(taxon.getName(), processedPhotoInfo);
|
188
|
+
|
189
|
+
meter.update(index + 1);
|
190
|
+
}
|
191
|
+
|
192
|
+
meter.stop();
|
193
|
+
|
194
|
+
return photos;
|
195
|
+
}
|
196
|
+
|
58
197
|
/**
|
59
198
|
* @param {import("../types.js").Taxon[]} taxaToUpdate
|
60
199
|
* @returns {Promise<Map<string,InatPhotoInfo[]>>}
|
@@ -78,37 +217,17 @@ export async function getTaxonPhotos(taxaToUpdate) {
|
|
78
217
|
for (const batch of chunk(taxaToUpdate, 30)) {
|
79
218
|
const inatTaxa = await fetchInatTaxa(batch.map((t) => t.getINatID()));
|
80
219
|
for (const iNatTaxon of inatTaxa) {
|
81
|
-
const iNatTaxonPhotos = iNatTaxon.taxon_photos.filter((tp) =>
|
82
|
-
ALLOWED_LICENSE_CODES.includes(tp.photo.license_code),
|
83
|
-
);
|
84
|
-
|
85
220
|
const taxonName = idMap.get(iNatTaxon.id.toString());
|
86
221
|
if (!taxonName) {
|
87
222
|
throw new Error(`iNat id ${iNatTaxon.id} not found`);
|
88
223
|
}
|
89
224
|
/** @type {InatPhotoInfo[]} */
|
90
225
|
const taxonPhotos = [];
|
91
|
-
for (const taxonPhoto of
|
92
|
-
const
|
93
|
-
if (!
|
94
|
-
|
95
|
-
|
96
|
-
/** @type {InatPhotoInfo} */
|
97
|
-
const obj = {
|
98
|
-
id: taxonPhoto.photo.id.toString(),
|
99
|
-
ext: ext,
|
100
|
-
licenseCode: taxonPhoto.photo.license_code,
|
101
|
-
attrName:
|
102
|
-
// Photographers retain copyright for most CC licenses,
|
103
|
-
// except CC0, so attribution is a bit different
|
104
|
-
taxonPhoto.photo.attribution.match(
|
105
|
-
/\(c\) (.*?),/,
|
106
|
-
)?.[1] ||
|
107
|
-
taxonPhoto.photo.attribution.match(
|
108
|
-
/uploaded by (.*)/,
|
109
|
-
)?.[1],
|
110
|
-
};
|
111
|
-
|
226
|
+
for (const taxonPhoto of iNatTaxon.taxon_photos) {
|
227
|
+
const obj = convertToCSVPhoto(taxonPhoto.photo);
|
228
|
+
if (!obj) {
|
229
|
+
continue;
|
230
|
+
}
|
112
231
|
taxonPhotos.push(obj);
|
113
232
|
}
|
114
233
|
photos.set(taxonName, taxonPhotos);
|
@@ -122,3 +241,52 @@ export async function getTaxonPhotos(taxaToUpdate) {
|
|
122
241
|
|
123
242
|
return photos;
|
124
243
|
}
|
244
|
+
|
245
|
+
/**
|
246
|
+
* @param {string} licenseCode
|
247
|
+
* @returns {AllowedLicenseCode|undefined}
|
248
|
+
*/
|
249
|
+
function getAllowedLicenseCode(licenseCode) {
|
250
|
+
switch (licenseCode) {
|
251
|
+
case "cc0":
|
252
|
+
case "cc-by":
|
253
|
+
case "cc-by-nc":
|
254
|
+
return licenseCode;
|
255
|
+
}
|
256
|
+
}
|
257
|
+
|
258
|
+
/**
|
259
|
+
* @param {string} rawAttribution
|
260
|
+
* @returns {string|undefined}
|
261
|
+
*/
|
262
|
+
function getAttribution(rawAttribution) {
|
263
|
+
// Photographers retain copyright for most CC licenses,
|
264
|
+
// except CC0, so attribution is a bit different
|
265
|
+
return (
|
266
|
+
rawAttribution.match(/\(c\) (.*?),/)?.[1] ||
|
267
|
+
rawAttribution.match(/uploaded by (.*)/)?.[1]
|
268
|
+
);
|
269
|
+
}
|
270
|
+
|
271
|
+
let lastQueryTime = Date.now();
|
272
|
+
/**
|
273
|
+
* @param {URL} url
|
274
|
+
* @returns {Promise<Response|Error>}
|
275
|
+
*/
|
276
|
+
async function getResponse(url) {
|
277
|
+
// If less than one second since last query, delay.
|
278
|
+
const delayTime = 1050 - (Date.now() - lastQueryTime);
|
279
|
+
if (delayTime > 0) {
|
280
|
+
await sleep(delayTime);
|
281
|
+
}
|
282
|
+
|
283
|
+
try {
|
284
|
+
lastQueryTime = Date.now();
|
285
|
+
return await fetch(url);
|
286
|
+
} catch (error) {
|
287
|
+
if (error instanceof Error) {
|
288
|
+
return error;
|
289
|
+
}
|
290
|
+
throw error;
|
291
|
+
}
|
292
|
+
}
|