|
| 1 | +defmodule Chuko.Api.AnibisGql do |
| 2 | + @moduledoc """ |
| 3 | + Literal copy of the Tutti GraphQL API. Should combine them at some point. |
| 4 | + """ |
| 5 | + @behaviour Chuko.Api.Platform |
| 6 | + require Logger |
| 7 | + |
| 8 | + @query """ |
| 9 | + query SearchListings($query: String, $constraints: ListingSearchConstraints, $category: ID, $first: Int!, $offset: Int!, $sort: ListingSortMode!, $direction: SortDirection!) { |
| 10 | + searchListingsByQuery( |
| 11 | + query: $query |
| 12 | + constraints: $constraints |
| 13 | + category: $category |
| 14 | + ) { |
| 15 | + ...searchResultFields |
| 16 | + } |
| 17 | + } |
| 18 | +
|
| 19 | + fragment searchResultFields on ListingSearchResult { |
| 20 | + listings(first: $first, offset: $offset, sort: $sort, direction: $direction) { |
| 21 | + ...listingsConnectionField |
| 22 | + } |
| 23 | + filters { |
| 24 | + ...filterFields |
| 25 | + } |
| 26 | + suggestedCategories { |
| 27 | + ...suggestedCategoryFields |
| 28 | + } |
| 29 | + selectedCategory { |
| 30 | + ...selectedCategoryFields |
| 31 | + } |
| 32 | + } |
| 33 | +
|
| 34 | + fragment listingsConnectionField on ListingsConnection { |
| 35 | + totalCount |
| 36 | + edges { |
| 37 | + node { |
| 38 | + ...listingFields |
| 39 | + } |
| 40 | + } |
| 41 | + placements { |
| 42 | + keyValues { |
| 43 | + key |
| 44 | + value |
| 45 | + } |
| 46 | + pageName |
| 47 | + pagePath |
| 48 | + positions { |
| 49 | + adUnitID |
| 50 | + mobile |
| 51 | + position |
| 52 | + positionType |
| 53 | + } |
| 54 | + afs { |
| 55 | + customChannelID |
| 56 | + styleID |
| 57 | + adUnits { |
| 58 | + adUnitID |
| 59 | + mobile |
| 60 | + } |
| 61 | + } |
| 62 | + } |
| 63 | + } |
| 64 | +
|
| 65 | + fragment listingFields on Listing { |
| 66 | + listingID |
| 67 | + title |
| 68 | + body |
| 69 | + postcodeInformation { |
| 70 | + postcode |
| 71 | + locationName |
| 72 | + canton { |
| 73 | + shortName |
| 74 | + name |
| 75 | + } |
| 76 | + } |
| 77 | + timestamp |
| 78 | + formattedPrice |
| 79 | + formattedSource |
| 80 | + highlighted |
| 81 | + sellerInfo { |
| 82 | + alias |
| 83 | + logo { |
| 84 | + rendition { |
| 85 | + src |
| 86 | + } |
| 87 | + } |
| 88 | + } |
| 89 | + images(first: 15) { |
| 90 | + rendition { |
| 91 | + src |
| 92 | + } |
| 93 | + } |
| 94 | + thumbnail { |
| 95 | + normalRendition: rendition(width: 235, height: 167) { |
| 96 | + src |
| 97 | + } |
| 98 | + retinaRendition: rendition(width: 470, height: 334) { |
| 99 | + src |
| 100 | + } |
| 101 | + } |
| 102 | + seoInformation { |
| 103 | + deSlug: slug(language: DE) |
| 104 | + frSlug: slug(language: FR) |
| 105 | + itSlug: slug(language: IT) |
| 106 | + } |
| 107 | + } |
| 108 | +
|
| 109 | + fragment filterFields on ListingFilter { |
| 110 | + __typename |
| 111 | + ...nonGroupFilterFields |
| 112 | + } |
| 113 | +
|
| 114 | + fragment nonGroupFilterFields on ListingFilter { |
| 115 | + ...filterDescriptionFields |
| 116 | + ... on ListingIntervalFilter { |
| 117 | + ...intervalFilterFields |
| 118 | + } |
| 119 | + ... on ListingSingleSelectFilter { |
| 120 | + ...singleSelectFilterFields |
| 121 | + } |
| 122 | + ... on ListingMultiSelectFilter { |
| 123 | + ...multiSelectFilterFields |
| 124 | + } |
| 125 | + ... on ListingPricingFilter { |
| 126 | + ...pricingFilterFields |
| 127 | + } |
| 128 | + ... on ListingLocationFilter { |
| 129 | + ...locationFilterFields |
| 130 | + } |
| 131 | + } |
| 132 | +
|
| 133 | + fragment filterDescriptionFields on ListingsFilterDescription { |
| 134 | + name |
| 135 | + label |
| 136 | + disabled |
| 137 | + } |
| 138 | +
|
| 139 | + fragment intervalFilterFields on ListingIntervalFilter { |
| 140 | + ...filterDescriptionFields |
| 141 | + intervalType { |
| 142 | + __typename |
| 143 | + ... on ListingIntervalTypeText { |
| 144 | + ...intervalTypeTextFields |
| 145 | + } |
| 146 | + ... on ListingIntervalTypeSlider { |
| 147 | + ...intervalTypeSliderFields |
| 148 | + } |
| 149 | + } |
| 150 | + intervalValue: value { |
| 151 | + min |
| 152 | + max |
| 153 | + } |
| 154 | + step |
| 155 | + unit |
| 156 | + minField { |
| 157 | + placeholder |
| 158 | + } |
| 159 | + maxField { |
| 160 | + placeholder |
| 161 | + } |
| 162 | + } |
| 163 | +
|
| 164 | + fragment intervalTypeTextFields on ListingIntervalTypeText { |
| 165 | + minLimit |
| 166 | + maxLimit |
| 167 | + } |
| 168 | +
|
| 169 | + fragment intervalTypeSliderFields on ListingIntervalTypeSlider { |
| 170 | + sliderStart: minLimit |
| 171 | + sliderEnd: maxLimit |
| 172 | + } |
| 173 | +
|
| 174 | + fragment singleSelectFilterFields on ListingSingleSelectFilter { |
| 175 | + ...filterDescriptionFields |
| 176 | + ...selectFilterFields |
| 177 | + selectedOption: value |
| 178 | + } |
| 179 | +
|
| 180 | + fragment selectFilterFields on ListingSelectFilter { |
| 181 | + options { |
| 182 | + ...selectOptionFields |
| 183 | + } |
| 184 | + placeholder |
| 185 | + inline |
| 186 | + } |
| 187 | +
|
| 188 | + fragment selectOptionFields on ListingSelectOption { |
| 189 | + value |
| 190 | + label |
| 191 | + } |
| 192 | +
|
| 193 | + fragment multiSelectFilterFields on ListingMultiSelectFilter { |
| 194 | + ...filterDescriptionFields |
| 195 | + ...selectFilterFields |
| 196 | + selectedOptions: values |
| 197 | + } |
| 198 | +
|
| 199 | + fragment pricingFilterFields on ListingPricingFilter { |
| 200 | + ...filterDescriptionFields |
| 201 | + pricingValue: value { |
| 202 | + min |
| 203 | + max |
| 204 | + freeOnly |
| 205 | + } |
| 206 | + minField { |
| 207 | + placeholder |
| 208 | + } |
| 209 | + maxField { |
| 210 | + placeholder |
| 211 | + } |
| 212 | + } |
| 213 | +
|
| 214 | + fragment locationFilterFields on ListingLocationFilter { |
| 215 | + ...filterDescriptionFields |
| 216 | + value { |
| 217 | + radius |
| 218 | + selectedLocalities { |
| 219 | + ...localityFields |
| 220 | + } |
| 221 | + } |
| 222 | + } |
| 223 | +
|
| 224 | + fragment localityFields on Locality { |
| 225 | + localityID |
| 226 | + name |
| 227 | + localityType |
| 228 | + } |
| 229 | +
|
| 230 | + fragment suggestedCategoryFields on Category { |
| 231 | + categoryID |
| 232 | + label |
| 233 | + searchToken |
| 234 | + mainImage { |
| 235 | + rendition(width: 300) { |
| 236 | + src |
| 237 | + } |
| 238 | + } |
| 239 | + } |
| 240 | +
|
| 241 | + fragment selectedCategoryFields on Category { |
| 242 | + categoryID |
| 243 | + label |
| 244 | + ...categoryParent |
| 245 | + } |
| 246 | +
|
| 247 | + fragment categoryParent on Category { |
| 248 | + parent { |
| 249 | + categoryID |
| 250 | + label |
| 251 | + parent { |
| 252 | + categoryID |
| 253 | + label |
| 254 | + parent { |
| 255 | + categoryID |
| 256 | + label |
| 257 | + } |
| 258 | + } |
| 259 | + } |
| 260 | + } |
| 261 | + """ |
| 262 | + |
| 263 | + @url_item "https://www.anibis.ch/de/vi/" |
| 264 | + @url_api "https://www.anibis.ch/api/v10/graphql" |
| 265 | + |
| 266 | + alias Chuko.Structs.Item |
| 267 | + |
| 268 | + @impl true |
| 269 | + def search(query, session_id) when is_binary(query) do |
| 270 | + options = [ |
| 271 | + json: %{ |
| 272 | + query: @query, |
| 273 | + variables: %{ |
| 274 | + query: query, |
| 275 | + constraints: nil, |
| 276 | + category: nil, |
| 277 | + first: 100, |
| 278 | + # max 3000 |
| 279 | + offset: 0, |
| 280 | + direction: "DESCENDING", |
| 281 | + sort: "TIMESTAMP" |
| 282 | + } |
| 283 | + }, |
| 284 | + headers: [ |
| 285 | + user_agent: Chuko.AgentUser.get(), |
| 286 | + "Content-Type": "application/json", |
| 287 | + "x-tutti-client-identifier": "web/1.0.0+env-live.git-a70218e", |
| 288 | + "x-tutti-hash": Ecto.UUID.generate() |
| 289 | + ], |
| 290 | + max_retries: 2 |
| 291 | + ] |
| 292 | + |
| 293 | + # could be optimized by using the body |
| 294 | + amount = |
| 295 | + Req.post!(@url_api, put_in(options[:json][:variables][:first], 1)) |
| 296 | + |> then(fn %Req.Response{body: body} -> |
| 297 | + body["data"]["searchListingsByQuery"]["listings"]["totalCount"] |
| 298 | + end) |
| 299 | + # Max is 3000 but capping at 500 for rate limit reasons |
| 300 | + |> then(&if &1 > 500, do: 500, else: &1) |
| 301 | + |
| 302 | + 0..floor(amount / 100) |
| 303 | + |> Enum.map(&(&1 * 100)) |
| 304 | + |> Task.async_stream( |
| 305 | + fn offset -> |
| 306 | + Req.post!(@url_api, put_in(options[:json][:variables][:offset], offset)) |
| 307 | + |> then(fn %Req.Response{body: body} -> |
| 308 | + body["data"]["searchListingsByQuery"]["listings"]["edges"] |
| 309 | + end) |
| 310 | + end, |
| 311 | + timeout: 30_000 |
| 312 | + ) |
| 313 | + |> Stream.flat_map(fn {:ok, res} -> List.wrap(res) end) |
| 314 | + |> Enum.filter(&(not is_nil(&1))) |
| 315 | + |> Enum.map(&cast_item(&1["node"])) |
| 316 | + rescue |
| 317 | + err -> |
| 318 | + Logger.error(Exception.format(:error, err, __STACKTRACE__)) |
| 319 | + |
| 320 | + Phoenix.PubSub.broadcast!( |
| 321 | + Chuko.PubSub, |
| 322 | + session_id, |
| 323 | + {:search_failed, %{platform: "Anibis"}} |
| 324 | + ) |
| 325 | + |
| 326 | + [] |
| 327 | + end |
| 328 | + |
| 329 | + defp cast_item(json) do |
| 330 | + %Item{ |
| 331 | + id: "anibis-#{json["listingID"]}", |
| 332 | + name: json["title"], |
| 333 | + description: json["body"], |
| 334 | + currency: "CHF", |
| 335 | + price: parse_price(json["formattedPrice"]), |
| 336 | + offer_type: :buynow, |
| 337 | + images: parse_images(json["images"]), |
| 338 | + url: "#{@url_item}#{json["seoInformation"]["deSlug"]}/#{json["listingID"]}", |
| 339 | + location: json["postcodeInformation"]["canton"]["name"], |
| 340 | + platform: :anibis, |
| 341 | + platform_logo: "/images/anibis_logo.png", |
| 342 | + created_at: DateTime.from_iso8601(json["timestamp"]) |> then(fn {:ok, dt, _} -> dt end) |
| 343 | + } |
| 344 | + end |
| 345 | + |
| 346 | + defp parse_price(string) do |
| 347 | + string |
| 348 | + |> String.trim() |
| 349 | + |> String.replace("'", "") |
| 350 | + |> Integer.parse() |
| 351 | + |> case do |
| 352 | + {price, _} -> price / 1 |
| 353 | + :error -> 0.0 |
| 354 | + end |
| 355 | + end |
| 356 | + |
| 357 | + defp parse_images(images) when is_list(images), do: Enum.map(images, & &1["rendition"]["src"]) |
| 358 | + defp parse_images(_), do: [] |
| 359 | +end |
0 commit comments