@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.
@@ -2,25 +2,25 @@ import { ProgressMeter } from "../progressmeter.js";
2
2
  import { chunk, sleep } from "../util.js";
3
3
 
4
4
  /**
5
- @typedef {"cc-by-nc-sa"
6
- | "cc-by-nc"
7
- | "cc-by-nc-nd"
8
- | "cc-by"
9
- | "cc-by-sa"
10
- | "cc-by-nd"
11
- | "pd"
12
- | "gdfl"
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: string;
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: InatLicenseCode;
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 iNatTaxonPhotos) {
92
- const url = taxonPhoto.photo.medium_url || taxonPhoto.photo.url;
93
- if (!url) continue;
94
- const ext = url.split(".").at(-1);
95
- if (!ext) continue;
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ca-plant-list/ca-plant-list",
3
- "version": "0.4.30",
3
+ "version": "0.4.32",
4
4
  "description": "Tools to create files for a website listing plants in an area of California.",
5
5
  "license": "MIT",
6
6
  "repository": {