yaaa
This commit is contained in:
commit
a6033f5fe6
4 changed files with 354 additions and 0 deletions
103
main.go
Normal file
103
main.go
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TokenResponse struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func authorizeRoute(w http.ResponseWriter, r *http.Request) {
|
||||||
|
code := r.URL.Query().Get("code")
|
||||||
|
if len(code) == 0 {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte(`{"error":"missing code"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
values := make(url.Values)
|
||||||
|
values["grant_type"] = []string{"authorization_code"}
|
||||||
|
values["client_id"] = []string{os.Getenv("APP_ID")}
|
||||||
|
values["client_secret"] = []string{os.Getenv("SECRET_KEY")}
|
||||||
|
values["code"] = []string{code}
|
||||||
|
values["redirect_uri"] = []string{os.Getenv("REDIRECT_URI")}
|
||||||
|
|
||||||
|
resp, err := http.PostForm("https://api.mercadolibre.com/oauth/token", values)
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(`{"error":"ML API error"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tokenResp := TokenResponse{}
|
||||||
|
|
||||||
|
rawJson, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(`{"error":"ML API error"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(`{"error":"ML API error"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.Unmarshal(rawJson, &tokenResp)
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(`{"error":"ML API error"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// if len(tokenResp.Error) != 0 {
|
||||||
|
// log.Println(string(rawJson))
|
||||||
|
// w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
// w.Write([]byte(`{"error":"ML API error"}`))
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
|
||||||
|
w.Write([]byte(rawJson))
|
||||||
|
}
|
||||||
|
|
||||||
|
func proxyRoute(res http.ResponseWriter, req *http.Request) {
|
||||||
|
// parse the url
|
||||||
|
url, _ := url.Parse("https://api.mercadolibre.com/")
|
||||||
|
|
||||||
|
// create the reverse proxy
|
||||||
|
proxy := httputil.NewSingleHostReverseProxy(url)
|
||||||
|
|
||||||
|
// Update the headers to allow for SSL redirection
|
||||||
|
req.URL.Host = url.Host
|
||||||
|
req.URL.Scheme = url.Scheme
|
||||||
|
req.Header.Set("X-Forwarded-Host", "")
|
||||||
|
req.Header.Set("X-Forwarded-For", "")
|
||||||
|
req.Header.Set("Origin", "")
|
||||||
|
req.Header.Set("Referer", "")
|
||||||
|
req.Host = url.Host
|
||||||
|
|
||||||
|
// Note that ServeHttp is non blocking and uses a go routine under the hood
|
||||||
|
proxy.ServeHTTP(res, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fs := http.FileServer(http.Dir("static"))
|
||||||
|
http.Handle("/", fs)
|
||||||
|
http.HandleFunc("/authorize", authorizeRoute)
|
||||||
|
http.HandleFunc("/pictures/items/upload", proxyRoute)
|
||||||
|
http.HandleFunc("/items", proxyRoute)
|
||||||
|
|
||||||
|
log.Fatalln(http.ListenAndServe(":6969", nil))
|
||||||
|
}
|
5
readme.md
Normal file
5
readme.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
# Odio nombrar cosas y odio a MercadoLibre
|
||||||
|
|
||||||
|
Una forma más rápida de vender libros en MercadoLibre. Lee el ISBN del libro con un escaner de código de barras, lo busca en la API de [openlibrary.org](https://openlibrary.org), consigue los metadatos y de paso te busca los precios. Super beta, proyecto personal, quizás lo mejore.
|
||||||
|
|
||||||
|
Requiere un servidor de API para conseguir el access token sin exponer el client secret y porque MeLi no sabe configurar CORS bien.
|
19
static/html5-qrcode.min.js
vendored
Normal file
19
static/html5-qrcode.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
227
static/index.html
Normal file
227
static/index.html
Normal file
|
@ -0,0 +1,227 @@
|
||||||
|
<!doctype html>
|
||||||
|
<meta charset=utf-8>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
#files-preview {
|
||||||
|
height: 50vh;
|
||||||
|
overflow: scroll;
|
||||||
|
border: 1px solid;
|
||||||
|
}
|
||||||
|
#files-preview img {
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Fotos</legend>
|
||||||
|
<input multiple type=file accept="image/*" capture=environment>
|
||||||
|
<ul id=files-preview></ul>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>ISBN/metadatos</legend>
|
||||||
|
<div id="reader" width="1800px"></div>
|
||||||
|
<input name=isbn placeholder=ISBN>
|
||||||
|
<input name=title placeholder=Titulo>
|
||||||
|
<input name=author placeholder=Autor>
|
||||||
|
<input name=publish-year placeholder="Año publicación">
|
||||||
|
<input name=publisher placeholder=Editorial>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<input name=price placeholder=Precio type=number>
|
||||||
|
<button id=force-prices-preview>Buscar con titulo y autor</button>
|
||||||
|
<ul id=prices-preview></ul>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<button id=go>Go!</button>
|
||||||
|
|
||||||
|
<ul id=log></ul>
|
||||||
|
|
||||||
|
<script src=html5-qrcode.min.js></script>
|
||||||
|
<script>
|
||||||
|
|
||||||
|
const goAuthorize = () =>
|
||||||
|
location.href = `https://auth.mercadolibre.com.ar/authorization?response_type=code&client_id=${CLIENT_ID}&redirect_uri=${location.protocol}//${location.host}`
|
||||||
|
|
||||||
|
const code = (new URLSearchParams(location.search)).get('code')
|
||||||
|
if (!code && !localStorage.accessToken) goAuthorize()
|
||||||
|
|
||||||
|
async function getAccessToken() {
|
||||||
|
if (localStorage.accessToken) return localStorage.accessToken
|
||||||
|
const request = await fetch("/authorize?code="+code)
|
||||||
|
const json = await request.json()
|
||||||
|
if (json.error === 'invalid_grant') {
|
||||||
|
goAuthorize()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
localStorage.accessToken = json.access_token
|
||||||
|
return json.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
const $ = s => document.querySelector(s)
|
||||||
|
const filesEl = $('input[type=file]')
|
||||||
|
const filesPreviewEl = $('#files-preview')
|
||||||
|
const pricesPreviewEl = $('#prices-preview')
|
||||||
|
const logEl = $('#log')
|
||||||
|
function appendLog (msg) {
|
||||||
|
const itemEl = document.createElement('li')
|
||||||
|
itemEl.append(msg)
|
||||||
|
logEl.append(itemEl)
|
||||||
|
}
|
||||||
|
|
||||||
|
const goEl = $('#go')
|
||||||
|
const inputs = Object.fromEntries([
|
||||||
|
'isbn',
|
||||||
|
'title',
|
||||||
|
'author',
|
||||||
|
'publish-year',
|
||||||
|
'publisher',
|
||||||
|
'price',
|
||||||
|
'access-token',
|
||||||
|
].map(
|
||||||
|
name => [name, $(`input[name=${name}]`)]
|
||||||
|
))
|
||||||
|
|
||||||
|
async function onScanSuccess(decodedText, decodedResult) {
|
||||||
|
alert(`Code matched = ${decodedText}`);
|
||||||
|
inputs.isbn.value = decodedText
|
||||||
|
const bibkey = `ISBN:${decodedText}`
|
||||||
|
const request = await fetch(`https://openlibrary.org/api/books?bibkeys=${bibkey}&jscmd=details&format=json`)
|
||||||
|
const json = await request.json()
|
||||||
|
inputs.title.value = json[bibkey]?.details?.title || ''
|
||||||
|
inputs.author.value = json[bibkey]?.details?.authors[0]?.name || ''
|
||||||
|
getPricePreview()
|
||||||
|
inputs.publisher.value = json[bibkey]?.details?.publishers[0] || ''
|
||||||
|
inputs['publish-year'].value = json[bibkey]?.details?.publish_date?.match(/\d{4}/)[0] || ''
|
||||||
|
}
|
||||||
|
async function getPricePreview() {
|
||||||
|
let params = new URLSearchParams()
|
||||||
|
params.set('q', `${inputs.title.value} ${inputs.author.value} usado`)
|
||||||
|
params.set('limit', '50')
|
||||||
|
try {
|
||||||
|
const request = await fetch(`https://api.mercadolibre.com/sites/MLA/search?${params}`)
|
||||||
|
const json = await request.json()
|
||||||
|
if (!json.results) throw new Error(JSON.stringify(json))
|
||||||
|
|
||||||
|
pricesPreviewEl.innerHTML=''
|
||||||
|
for (const result of json.results) {
|
||||||
|
const itemEl = document.createElement('li')
|
||||||
|
itemEl.append(`${result.title} - ${result.condition} - ${result.price}`)
|
||||||
|
pricesPreviewEl.append(itemEl)
|
||||||
|
}
|
||||||
|
} catch (error) { alert(error) }
|
||||||
|
}
|
||||||
|
$('#force-prices-preview').addEventListener('click', getPricePreview)
|
||||||
|
|
||||||
|
function onScanFailure(error) {
|
||||||
|
console.warn(`Code scan error = ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let html5QrcodeScanner = new Html5QrcodeScanner(
|
||||||
|
"reader",
|
||||||
|
{
|
||||||
|
fps: 10,
|
||||||
|
formatsToSupport: [Html5QrcodeSupportedFormats.EAN_13],
|
||||||
|
experimentalFeatures: {
|
||||||
|
useBarCodeDetectorIfSupported: true
|
||||||
|
},
|
||||||
|
rememberLastUsedCamera: true,
|
||||||
|
aspectRatio: 1.7777778
|
||||||
|
},
|
||||||
|
/* verbose= */ false)
|
||||||
|
|
||||||
|
html5QrcodeScanner.render(onScanSuccess, onScanFailure);
|
||||||
|
|
||||||
|
filesEl.addEventListener('change', async event => {
|
||||||
|
for (const file of event.target.files) {
|
||||||
|
const itemEl = document.createElement('li')
|
||||||
|
|
||||||
|
const imgEl = new Image
|
||||||
|
imgEl.src = URL.createObjectURL(file)
|
||||||
|
itemEl.append(imgEl)
|
||||||
|
|
||||||
|
const deleteBtnEl = document.createElement('button')
|
||||||
|
deleteBtnEl.append("Borrar")
|
||||||
|
deleteBtnEl.addEventListener('click', event => {
|
||||||
|
event.target.parentElement.remove()
|
||||||
|
})
|
||||||
|
itemEl.append(deleteBtnEl)
|
||||||
|
|
||||||
|
const id = await uploadImageToML(file)
|
||||||
|
imgEl.dataset.id = id
|
||||||
|
|
||||||
|
filesPreviewEl.append(itemEl)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function uploadImageToML(file) {
|
||||||
|
const formData = new FormData
|
||||||
|
formData.set("file", file)
|
||||||
|
|
||||||
|
appendLog(`Subiendo ${file.name}`)
|
||||||
|
//https://api.mercadolibre.com proxy
|
||||||
|
const request = await fetch('/pictures/items/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${await getAccessToken()}`,
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
const json = await request.json()
|
||||||
|
appendLog(`[${file.name}] Subido con ID ${json.id}`)
|
||||||
|
return json.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// categoria Libros, Revistas y Comics: MLA3025
|
||||||
|
goEl.addEventListener('click', async event => {
|
||||||
|
try {
|
||||||
|
const imageIds = [...filesPreviewEl.querySelectorAll('img')].map(
|
||||||
|
img => img.dataset.id
|
||||||
|
)
|
||||||
|
|
||||||
|
// https://developers.mercadolibre.com.ar/es_ar/publica-productos
|
||||||
|
const publishing = {
|
||||||
|
"title":inputs.title.value,
|
||||||
|
"category_id":"MLA412445",
|
||||||
|
"price":inputs.price.value,
|
||||||
|
"currency_id":"ARS",
|
||||||
|
"available_quantity":1,
|
||||||
|
"buying_mode":"buy_it_now",
|
||||||
|
"condition":"used",
|
||||||
|
"listing_type_id":"free",
|
||||||
|
"sale_terms":[
|
||||||
|
],
|
||||||
|
"pictures": imageIds.map(id => ({id})),
|
||||||
|
"attributes":[
|
||||||
|
//https://api.mercadolibre.com/categories/MLA3025/attributes
|
||||||
|
{ "id":"BOOK_TITLE", "value_name":inputs.title.value },
|
||||||
|
{ "id":"AUTHOR", "value_name":inputs.author.value },
|
||||||
|
{ "id":"BOOK_PUBLISHER", "value_name":inputs.publisher.value },
|
||||||
|
{ "id":"GTIN", "value_name":inputs.isbn.value },
|
||||||
|
{ "id":"FORMAT", "value_name":"Fisico" },
|
||||||
|
{ "id":"ITEM_CONDITION", "value_name":"Usado" },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
appendLog(`Creando publicación`)
|
||||||
|
//https://api.mercadolibre.com proxy
|
||||||
|
const request = await fetch("/items", {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${await getAccessToken()}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(publishing),
|
||||||
|
})
|
||||||
|
const json = await request.json()
|
||||||
|
if (!json.permalink) throw new Error(JSON.stringify(json))
|
||||||
|
location.href = json.permalink
|
||||||
|
} catch (error) {
|
||||||
|
alert(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
Loading…
Reference in a new issue